Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 73 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 서버로 작동하는 코드

### 작동 테스트
- 애플 캘린더 : ✅
- 구글 캘린더 : ✅
Expand All @@ -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형식으로 가져옵니다.
Expand Down
4 changes: 4 additions & 0 deletions api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""API module."""
from .routes import get_school, index, school_search

__all__ = ["get_school", "index", "school_search"]
45 changes: 45 additions & 0 deletions api/routes.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 4 additions & 0 deletions config/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Configuration module."""
from .settings import settings

__all__ = ["settings"]
36 changes: 36 additions & 0 deletions config/settings.py
Original file line number Diff line number Diff line change
@@ -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()
27 changes: 27 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Models module."""
from .schemas import SchoolSearch
from .entities import SchoolEvent, SchoolSchedule

__all__ = ["SchoolSearch", "SchoolEvent", "SchoolSchedule"]
30 changes: 30 additions & 0 deletions models/entities.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions models/schemas.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions services/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
52 changes: 52 additions & 0 deletions services/cache_service.py
Original file line number Diff line number Diff line change
@@ -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()
59 changes: 59 additions & 0 deletions services/ics_service.py
Original file line number Diff line number Diff line change
@@ -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()
Loading