diff --git a/README.md b/README.md index 42cec4b..265510d 100755 --- a/README.md +++ b/README.md @@ -7,17 +7,74 @@ Service URL(부산소프트웨어마이스터고등학교) : [https://obtuse.kr/ - ATPT_OFCDC_SC_CODE = 시도교육청코드 - SD_SCHUL_CODE = 표준행정코드 +## 프로젝트 구조 + +이 프로젝트는 모듈화된 구조로 각 컴포넌트의 역할과 책임이 명확히 분리되어 있습니다: + +``` +├── main.py # 애플리케이션 진입점 +├── config/ # 설정 관리 +│ ├── __init__.py +│ └── settings.py # 설정 로딩 및 관리 +├── models/ # 데이터 모델 +│ ├── __init__.py +│ ├── entities.py # 비즈니스 엔티티 +│ └── schemas.py # API 요청/응답 스키마 +├── services/ # 비즈니스 로직 서비스 +│ ├── __init__.py +│ ├── neis_service.py # NEIS API 상호작용 +│ ├── ics_service.py # ICS 파일 변환 +│ ├── school_service.py # 학교 데이터 관리 +│ └── cache_service.py # 캐싱 관리 +├── utils/ # 유틸리티 함수 +│ ├── __init__.py +│ ├── date_utils.py # 날짜 처리 +│ └── file_utils.py # 파일 시스템 작업 +├── api/ # API 라우트 +│ ├── __init__.py +│ └── routes.py # FastAPI 엔드포인트 +├── templates/ # HTML 템플릿 +├── data/ # 학교 데이터 파일 +└── server.py # 레거시 단일 파일 (호환성을 위해 유지) +``` + +## 실행 방법 + +### 요구사항 +- Python 3.8 이상 +- 필요한 패키지들 (requirements.txt 참고) + +### 설정 +1. `config.json` 파일을 생성하고 NEIS API 키를 설정합니다: +```json +{ + "neisKey": "your-neis-api-key-here", + "cache_day": 7 +} +``` + +### 서버 실행 +```bash +# 의존성 설치 +pip install -r requirements.txt + +# 서버 실행 (모듈화된 버전) +uvicorn main:app --host 0.0.0.0 --port 8000 + +# 또는 레거시 버전 +uvicorn server:app --host 0.0.0.0 --port 8000 +``` + +### API 사용법 +- 웹 인터페이스: `http://localhost:8000` +- 학교 검색: `POST /search` +- ICS 파일 다운로드: `GET /school?ATPT_OFCDC_SC_CODE={코드}&SD_SCHUL_CODE={코드}` + ## 시도교육청코드 / 행정표준코드 조회방법 - [https://open.neis.go.kr/portal/data/service/selectServicePage.do?page=1&rows=10&sortColumn=&sortDirection=&infId=OPEN17020190531110010104913&infSeq=1](https://open.neis.go.kr/portal/data/service/selectServicePage.do?page=1&rows=10&sortColumn=&sortDirection=&infId=OPEN17020190531110010104913&infSeq=1)에 접속해서 시도교육청코드와 행정표준코드를 조회할 수 있습니다. - [![image.png](image.png)](image.png) -## 참고 - -- simple.js : 나이스 api 호출하지 않음 -- index.js : 나이스 api 호출 -- server.js : api 서버로 작동하는 코드 - ### 작동 테스트 - 애플 캘린더 : ✅ - 구글 캘린더 : ✅ @@ -28,6 +85,16 @@ Service URL(부산소프트웨어마이스터고등학교) : [https://obtuse.kr/ (다른 캘린더에서도 작동하지 않는다면 Issue 등록해주시면 감사하겠습니다.) (해결해서 Pull Requst 보내주시면 더 감사하겠습니다.) +## 레거시 파일 참고 + +이 프로젝트는 기존 단일 파일 구조에서 모듈화된 구조로 리팩토링되었습니다: + +### Python 버전 +- `server.py` : 기존 단일 파일 (147줄, 호환성을 위해 유지) +- `main.py` + 모듈들 : 새로운 모듈화 구조 (354줄, 16개 파일) + +### JavaScript 레거시 (.legacy 폴더) + ## simple.js 사용법 - 나이스 학사일정 api를 이용해 학사일정을 json형식으로 가져옵니다. diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..5122e86 --- /dev/null +++ b/api/__init__.py @@ -0,0 +1,4 @@ +"""API module.""" +from .routes import get_school, index, school_search + +__all__ = ["get_school", "index", "school_search"] \ No newline at end of file diff --git a/api/routes.py b/api/routes.py new file mode 100644 index 0000000..b337ff4 --- /dev/null +++ b/api/routes.py @@ -0,0 +1,45 @@ +"""API routes for the application.""" +from fastapi import Request, HTTPException +from fastapi.responses import FileResponse, JSONResponse +from fastapi.templating import Jinja2Templates + +from models import SchoolSearch +from services import neis_service, ics_service, school_service, cache_service + + +templates = Jinja2Templates(directory="templates") + + +async def get_school(ATPT_OFCDC_SC_CODE: str, SD_SCHUL_CODE: int): + """Get school schedule as ICS file.""" + if ATPT_OFCDC_SC_CODE is None or SD_SCHUL_CODE is None: + return {"message": "시도교육청 코드와 학교 코드를 입력해주세요. 예) /school?ATPT_OFCDC_SC_CODE=C10&SD_SCHUL_CODE=7150658"} + + # Check cache first + cached_content = cache_service.get_cached_content(ATPT_OFCDC_SC_CODE, SD_SCHUL_CODE) + if cached_content: + file_path = cache_service.get_cache_path(ATPT_OFCDC_SC_CODE, SD_SCHUL_CODE) + return FileResponse(file_path, media_type='text/calendar', filename='school_schedule.ics') + + # Fetch from NEIS API and convert to ICS + json_data = await neis_service.get_school_schedule(ATPT_OFCDC_SC_CODE, SD_SCHUL_CODE) + ics_data = ics_service.convert_to_ics(json_data) + + # Save to cache + file_path = cache_service.save_to_cache(ATPT_OFCDC_SC_CODE, SD_SCHUL_CODE, ics_data) + + return FileResponse(file_path, media_type='text/calendar', filename='school_schedule.ics') + + +async def index(request: Request): + """Render the index page.""" + return templates.TemplateResponse("index.html", {"request": request}) + + +async def school_search(school_search: SchoolSearch): + """Search for schools.""" + result = school_service.search_schools( + school_search.ATPT_OFCDC_SC_CODE, + school_search.SCHUL_NM + ) + return JSONResponse(content=result) \ No newline at end of file diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..0b8737a --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,4 @@ +"""Configuration module.""" +from .settings import settings + +__all__ = ["settings"] \ No newline at end of file diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..8879296 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,36 @@ +"""Configuration management module.""" +import json +import os +from typing import Dict, Any + + +class Settings: + """Application settings management.""" + + def __init__(self, config_path: str = "config.json"): + self.config_path = config_path + self._config = self._load_config() + + def _load_config(self) -> Dict[str, Any]: + """Load configuration from JSON file.""" + try: + with open(self.config_path, "r") as f: + return json.load(f) + except FileNotFoundError: + raise FileNotFoundError(f"Configuration file {self.config_path} not found") + except json.JSONDecodeError: + raise ValueError(f"Invalid JSON in configuration file {self.config_path}") + + @property + def neis_key(self) -> str: + """Get NEIS API key.""" + return self._config.get("neisKey", "") + + @property + def cache_days(self) -> int: + """Get cache expiration days.""" + return self._config.get("cache_day", 7) + + +# Global settings instance +settings = Settings() \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..76dae6a --- /dev/null +++ b/main.py @@ -0,0 +1,27 @@ +"""Main application entry point.""" +from fastapi import FastAPI, Request + +from api import get_school, index, school_search +from models import SchoolSearch + + +app = FastAPI() + + +# Register routes +@app.get("/school") +async def school_endpoint(ATPT_OFCDC_SC_CODE: str, SD_SCHUL_CODE: int): + """Get school schedule endpoint.""" + return await get_school(ATPT_OFCDC_SC_CODE, SD_SCHUL_CODE) + + +@app.get("/") +async def index_endpoint(request: Request): + """Index page endpoint.""" + return await index(request) + + +@app.post("/search") +async def search_endpoint(school_search_data: SchoolSearch): + """School search endpoint.""" + return await school_search(school_search_data) \ No newline at end of file diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..7839eea --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,5 @@ +"""Models module.""" +from .schemas import SchoolSearch +from .entities import SchoolEvent, SchoolSchedule + +__all__ = ["SchoolSearch", "SchoolEvent", "SchoolSchedule"] \ No newline at end of file diff --git a/models/entities.py b/models/entities.py new file mode 100644 index 0000000..8634547 --- /dev/null +++ b/models/entities.py @@ -0,0 +1,30 @@ +"""Data entity classes.""" +from typing import Dict, List, Any +from datetime import datetime + + +class SchoolEvent: + """Represents a school event from NEIS API.""" + + def __init__(self, event_data: Dict[str, Any]): + self.date = event_data.get('AA_YMD', '') + self.event_name = event_data.get('EVENT_NM', '') + self.event_content = event_data.get('EVENT_CNTNT', '') + self.school_name = event_data.get('SCHUL_NM', '') + + +class SchoolSchedule: + """Represents a complete school schedule.""" + + def __init__(self, schedule_data: Dict[str, Any]): + self.events: List[SchoolEvent] = [] + self.school_name = "" + + if 'SchoolSchedule' in schedule_data: + for item in schedule_data['SchoolSchedule']: + if 'row' in item: + for event_data in item['row']: + event = SchoolEvent(event_data) + self.events.append(event) + if not self.school_name and event.school_name: + self.school_name = event.school_name \ No newline at end of file diff --git a/models/schemas.py b/models/schemas.py new file mode 100644 index 0000000..bd65476 --- /dev/null +++ b/models/schemas.py @@ -0,0 +1,8 @@ +"""Pydantic models for API requests and responses.""" +from pydantic import BaseModel + + +class SchoolSearch(BaseModel): + """Model for school search requests.""" + ATPT_OFCDC_SC_CODE: str + SCHUL_NM: str \ No newline at end of file diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..13ce623 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,7 @@ +"""Services module.""" +from .neis_service import neis_service +from .ics_service import ics_service +from .school_service import school_service +from .cache_service import cache_service + +__all__ = ["neis_service", "ics_service", "school_service", "cache_service"] \ No newline at end of file diff --git a/services/cache_service.py b/services/cache_service.py new file mode 100644 index 0000000..59e7502 --- /dev/null +++ b/services/cache_service.py @@ -0,0 +1,52 @@ +"""Cache service for managing cached ICS files.""" +import os +import re +from datetime import datetime, timedelta +from typing import Optional + +from config import settings +from utils import ensure_directory_existence + + +class CacheService: + """Service for managing cached ICS files.""" + + def __init__(self, cache_dir: str = "cache"): + self.cache_dir = cache_dir + + def get_cache_path(self, atpt_ofcdc_sc_code: str, sd_schul_code: int) -> str: + """Get the cache file path for given school codes.""" + return os.path.join(self.cache_dir, atpt_ofcdc_sc_code, f"{sd_schul_code}.ics") + + def get_cached_content(self, atpt_ofcdc_sc_code: str, sd_schul_code: int) -> Optional[str]: + """Get cached ICS content if valid, otherwise return None.""" + file_path = self.get_cache_path(atpt_ofcdc_sc_code, sd_schul_code) + + if not os.path.exists(file_path): + return None + + with open(file_path, "r", encoding="utf8") as file: + content = file.read() + match = re.search( + r'X-CREATED-TIME:(\d{4}-\d{2}-\d{2})T\d{2}:\d{2}:\d{2}.\d{3}Z', + content + ) + + if match and datetime.now() - datetime.fromisoformat(match.group(1)) < timedelta(days=settings.cache_days): + return content + + return None + + def save_to_cache(self, atpt_ofcdc_sc_code: str, sd_schul_code: int, content: str) -> str: + """Save ICS content to cache and return the file path.""" + file_path = self.get_cache_path(atpt_ofcdc_sc_code, sd_schul_code) + ensure_directory_existence(file_path) + + with open(file_path, "w", encoding="utf8") as file: + file.write(content) + + return file_path + + +# Global service instance +cache_service = CacheService() \ No newline at end of file diff --git a/services/ics_service.py b/services/ics_service.py new file mode 100644 index 0000000..7396b47 --- /dev/null +++ b/services/ics_service.py @@ -0,0 +1,59 @@ +"""ICS conversion service for converting school data to ICS format.""" +from datetime import datetime +from typing import Dict, Any + +from utils import parse_date, format_date +from models import SchoolSchedule, SchoolEvent + + +class IcsService: + """Service for converting school schedules to ICS format.""" + + def convert_to_ics(self, data: Dict[str, Any]) -> str: + """Convert NEIS schedule data to ICS format.""" + schedule = SchoolSchedule(data) + today = datetime.utcnow().strftime('%Y%m%dT%H%M%SZ') + + ics = f"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//obtuse.kr//SchoolScheduleToICS//KO +CALSCALE:GREGORIAN +X-WR-CALNAME:{schedule.school_name} 학사일정 +X-WR-TIMEZONE:Asia/Seoul +BEGIN:VTIMEZONE +TZID:Asia/Seoul +TZURL:https://www.tzurl.org/zoneinfo-outlook/Asia/Seoul +X-LIC-LOCATION:Asia/Seoul +BEGIN:STANDARD +DTSTART:19700101T000000 +TZNAME:KST +TZOFFSETFROM:+0900 +TZOFFSETTO:+0900 +END:STANDARD +END:VTIMEZONE +X-CREATED-TIME:{datetime.utcnow().isoformat()}\n""" + + for event in schedule.events: + start_date = parse_date(event.date) + ics += f"""BEGIN:VEVENT +DTSTART;VALUE=DATE:{format_date(start_date)} +TRANSP:OPAQUE +DTSTAMP:{today} +X-MICROSOFT-CDO-BUSYSTATUS:BUSY +CLASS:PUBLIC +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:{event.event_name} +TRIGGER:-P1D +END:VALARM +SUMMARY:{event.event_name} +DESCRIPTION:{event.event_content} +LOCATION:{event.school_name} +END:VEVENT\n""" + + ics += "END:VCALENDAR" + return ics + + +# Global service instance +ics_service = IcsService() \ No newline at end of file diff --git a/services/neis_service.py b/services/neis_service.py new file mode 100644 index 0000000..5637d7c --- /dev/null +++ b/services/neis_service.py @@ -0,0 +1,33 @@ +"""NEIS API service for fetching school schedule data.""" +import requests +from datetime import datetime +from typing import Dict, Any +from fastapi import HTTPException + +from config import settings + + +class NeisService: + """Service for interacting with NEIS API.""" + + def __init__(self): + self.base_url = "https://open.neis.go.kr/hub/SchoolSchedule" + + async def get_school_schedule(self, atpt_ofcdc_sc_code: str, sd_schul_code: int) -> Dict[str, Any]: + """Fetch school schedule from NEIS API.""" + url = f"{self.base_url}?KEY={settings.neis_key}&Type=json&ATPT_OFCDC_SC_CODE={atpt_ofcdc_sc_code}&SD_SCHUL_CODE={sd_schul_code}&MLSV_FROM_YMD={datetime.now().year}0101&MLSV_TO_YMD={(datetime.now().year + 1)}0101&pSize=1000" + + print(url) # For debugging + response = requests.get(url) + + if response.status_code == 200: + return response.json() + else: + raise HTTPException( + status_code=response.status_code, + detail="Error fetching data from NEIS server" + ) + + +# Global service instance +neis_service = NeisService() \ No newline at end of file diff --git a/services/school_service.py b/services/school_service.py new file mode 100644 index 0000000..06f452d --- /dev/null +++ b/services/school_service.py @@ -0,0 +1,31 @@ +"""School data service for managing school information.""" +import pandas as pd +from typing import List, Dict, Any + + +class SchoolService: + """Service for managing school data.""" + + def __init__(self, data_file: str = "data/학교기본정보2024_11_30.csv"): + self.data_file = data_file + self._school_data = self._load_school_data() + + def _load_school_data(self) -> pd.DataFrame: + """Load school data from CSV file.""" + with open(self.data_file, "r", encoding='cp949') as f: + return pd.read_csv(f) + + def search_schools(self, atpt_ofcdc_sc_code: str, school_name: str) -> List[Dict[str, Any]]: + """Search for schools by education office code and name.""" + result = self._school_data[ + (self._school_data['시도교육청코드'] == atpt_ofcdc_sc_code) & ( + self._school_data['학교명'].str.contains(school_name, na=False) | + self._school_data['영문학교명'].str.contains(school_name, na=False) + ) + ].fillna(value="").to_dict(orient='records') + + return result + + +# Global service instance +school_service = SchoolService() \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..7f9826e --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,5 @@ +"""Utilities module.""" +from .date_utils import parse_date, format_date +from .file_utils import ensure_directory_existence + +__all__ = ["parse_date", "format_date", "ensure_directory_existence"] \ No newline at end of file diff --git a/utils/date_utils.py b/utils/date_utils.py new file mode 100644 index 0000000..afc1de6 --- /dev/null +++ b/utils/date_utils.py @@ -0,0 +1,15 @@ +"""Date utilities for parsing and formatting dates.""" +from datetime import datetime + + +def parse_date(date_string: str) -> datetime: + """Parse NEIS date string (YYYYMMDD) to datetime object.""" + year = int(date_string[:4]) + month = int(date_string[4:6]) + day = int(date_string[6:8]) + return datetime(year, month, day) + + +def format_date(date: datetime) -> str: + """Format datetime object to YYYYMMDD string.""" + return date.strftime("%Y%m%d") \ No newline at end of file diff --git a/utils/file_utils.py b/utils/file_utils.py new file mode 100644 index 0000000..aa076c4 --- /dev/null +++ b/utils/file_utils.py @@ -0,0 +1,9 @@ +"""File system utilities.""" +import os + + +def ensure_directory_existence(file_path: str) -> None: + """Ensure the directory for the given file path exists.""" + dirname = os.path.dirname(file_path) + if not os.path.exists(dirname): + os.makedirs(dirname) \ No newline at end of file