v2.10.0: \uc815\ubd80 \ud2b9\uc77c\uc815\ubcf4 API \uc5f0\ub3d9 + \uc77c \uc790\ub3d9 \ub3d9\uae30\ud654
\uacf5\uacf5\ub370\uc774\ud130\ud3ec\ud138 \ud55c\uad6d\ucc9c\ubb38\uc5f0\uad6c\uc6d0 \ud2b9\uc77c\uc815\ubcf4 API\ub85c \uc784\uc2dc\uacf5\ud734\uc77c\uae4c\uc9c0 \uc815\ubd80 \uacf5\uc778 \ub370\uc774\ud130\ub85c \ubcf4\uac15. holidays \ud328\ud0a4\uc9c0\ub294 fallback. - utils/holiday_api.py: getRestDeInfo \uc5d4\ub4dc\ud3ec\uc778\ud2b8 + \uc751\ub2f5 \ud30c\uc11c (\ub2e8\uc77c/\ub2e4\uc218 item) - Database.add_korean_holidays_from_api(year) + add_korean_holidays_auto fallback chain - migrate_v290_holidays_auto_sync: \uc77c 1\ud68c \ubc31\uadf8\ub77c\uc6b4\ub4dc \ub3d9\uae30\ud654 (sentinel holidays_synced_date, daemon thread, CLOCKOUT_DISABLE_HOLIDAY_SYNC env var) - Settings UI \uc548\ub0b4\ubb38 \uc5c5\ub370\uc774\ud2b8 Tests: tests/test_holiday_api.py 14\uac1c + conftest.py + 175\u2192189 pytest \uc804\ubd80 green \ud1b5\ud569 \uc2dc\ub098\ub9ac\uc624 53/53 green \uc8fc\uc758: \ud0a4 \ud65c\uc6a9\uae30\uac04 \uc2dc\uc791 \uc9c1\ud6c4 (2026-05-01) propagation \uc73c\ub85c 401 \uac00\ub2a5, fallback \uacbd\ub85c\uac00 \ud574\ub2f9 \uc0ac\ub840 \ucee4\ubc84 \u2014 \uadfc\ub85c\uc790\uc758 \ub0a0 \ud3ec\ud568 22\uac1c \ud734\uc77c \uc790\ub3d9 \ub4f1\ub85d \ud655\uc778
This commit is contained in:
parent
47296dd35b
commit
97dd4e39f7
28
CHANGELOG.md
28
CHANGELOG.md
@ -4,6 +4,34 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## [2.10.0] — 2026-05-01
|
||||||
|
|
||||||
|
### Added — 정부 공휴일 API 자동 동기화
|
||||||
|
- **공공데이터포털 특일정보 API 연동** (`utils/holiday_api.py`)
|
||||||
|
- 한국천문연구원 운영 공식 데이터 — `/getRestDeInfo` 엔드포인트
|
||||||
|
- 임시공휴일·근로자의 날까지 정부 공인 데이터로 보강
|
||||||
|
- 일일 한도 10,000회 / 사용자 50명 = 0.5% 사용
|
||||||
|
- 키는 dev 본인 계정의 특일정보 API 한정 키
|
||||||
|
- **`Database.add_korean_holidays_from_api(year)`** — 정부 API 1차 시도
|
||||||
|
- **`add_korean_holidays_auto()` 동작 변경** — 1차 정부 API → 2차 fallback `holidays` 패키지
|
||||||
|
- **`migrate_v290_holidays_auto_sync`** — 일 1회 자동 동기화 (백그라운드 스레드)
|
||||||
|
- sentinel: `settings['holidays_synced_date']`
|
||||||
|
- 매일 호출 → 정부가 임시공휴일 발표하면 다음 날 자동 반영
|
||||||
|
- 부트스트랩 비차단 (네트워크 호출은 daemon thread)
|
||||||
|
- 테스트 환경: `CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` 로 비활성화
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 설정 → "한국 공휴일 자동 추가" 버튼 안내문 — 1차 정부 API / 2차 holidays 패키지
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- `tests/test_holiday_api.py` 14개 신규 (응답 파싱 / 단일/다중 item / 401·timeout / 응답 검증)
|
||||||
|
- `tests/conftest.py` — 모든 테스트에서 백그라운드 동기화 비활성화
|
||||||
|
- pytest: 175 → **189**
|
||||||
|
|
||||||
|
### 주의
|
||||||
|
- 키 활용기간 시작 직후엔 백엔드 propagation으로 401 가능 (1~2시간 또는 익일 활성화).
|
||||||
|
401 시 fallback (holidays 패키지 + 근로자의 날 명시 추가) 정상 동작 — 사용자 영향 없음.
|
||||||
|
|
||||||
## [2.9.0] — 2026-05-01
|
## [2.9.0] — 2026-05-01
|
||||||
|
|
||||||
### Fixed — 휴일 hot-path 버그 (사용자 보고)
|
### Fixed — 휴일 hot-path 버그 (사용자 보고)
|
||||||
|
|||||||
@ -89,10 +89,54 @@ class Database:
|
|||||||
self.migrate_v271_work_records_indexes()
|
self.migrate_v271_work_records_indexes()
|
||||||
self.migrate_v280_achievements_columns()
|
self.migrate_v280_achievements_columns()
|
||||||
self.migrate_v280_hire_date()
|
self.migrate_v280_hire_date()
|
||||||
|
self.migrate_v290_holidays_auto_sync()
|
||||||
|
|
||||||
# 기본 설정 초기화
|
# 기본 설정 초기화
|
||||||
self.init_default_settings()
|
self.init_default_settings()
|
||||||
|
|
||||||
|
def migrate_v290_holidays_auto_sync(self) -> None:
|
||||||
|
"""일 1회 한국 공휴일 자동 동기화 (백그라운드).
|
||||||
|
|
||||||
|
Sentinel: settings['holidays_synced_date'] = 'YYYY-MM-DD' (오늘 날짜).
|
||||||
|
값이 오늘과 같으면 스킵 — 즉 같은 날 여러 번 켜도 호출 1회.
|
||||||
|
|
||||||
|
매일 호출하므로 정부가 임시공휴일 발표하면 다음 날 자동 반영.
|
||||||
|
일일 한도 10000회, 사용자 50명 × 1회 = 0.5% 소비.
|
||||||
|
|
||||||
|
실제 동기화는 백그라운드 스레드에서 — 부트스트랩이 네트워크에 묶이지 않음.
|
||||||
|
실패는 silent, 다음 실행 시 재시도.
|
||||||
|
|
||||||
|
테스트 환경에서는 CLOCKOUT_DISABLE_HOLIDAY_SYNC=1 로 비활성화.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
if os.environ.get('CLOCKOUT_DISABLE_HOLIDAY_SYNC'):
|
||||||
|
return
|
||||||
|
from datetime import datetime as _dt
|
||||||
|
import threading
|
||||||
|
try:
|
||||||
|
today = _dt.now().date().isoformat()
|
||||||
|
sentinel = self.get_setting('holidays_synced_date', '')
|
||||||
|
if sentinel == today:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
cur_year = _dt.now().year
|
||||||
|
|
||||||
|
def _worker():
|
||||||
|
try:
|
||||||
|
# 새 연결로 작업 (sqlite3 connection은 thread-affine)
|
||||||
|
from core.database import Database
|
||||||
|
db = Database(self.db_path)
|
||||||
|
added = db.add_korean_holidays_auto(cur_year, include_next_year=True)
|
||||||
|
if added >= 0:
|
||||||
|
db.set_setting('holidays_synced_date', today)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
t = threading.Thread(target=_worker, daemon=True, name='holiday-sync')
|
||||||
|
t.start()
|
||||||
|
|
||||||
def _create_tables(self, conn) -> None:
|
def _create_tables(self, conn) -> None:
|
||||||
"""init_database()에서 호출되는 CREATE TABLE 묶음. conn은 호출자가 관리."""
|
"""init_database()에서 호출되는 CREATE TABLE 묶음. conn은 호출자가 관리."""
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
@ -1713,6 +1757,32 @@ class Database:
|
|||||||
for date, name in fixed_holidays:
|
for date, name in fixed_holidays:
|
||||||
self.add_holiday(date, name, is_recurring=True)
|
self.add_holiday(date, name, is_recurring=True)
|
||||||
|
|
||||||
|
def add_korean_holidays_from_api(self, year: int) -> int:
|
||||||
|
"""공공데이터포털 특일정보 API로 한국 공휴일 등록 (정부 공인).
|
||||||
|
|
||||||
|
임시공휴일 + 근로자의 날 등 holidays 패키지가 놓치는 항목까지 포함.
|
||||||
|
네트워크 실패 시 -1 반환 → 호출자 fallback.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
추가된 공휴일 개수 (기존 등록과 중복은 제외). 실패 시 -1.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from utils.holiday_api import fetch_korean_holidays
|
||||||
|
except ImportError:
|
||||||
|
return -1
|
||||||
|
items = fetch_korean_holidays(year)
|
||||||
|
if items is None:
|
||||||
|
return -1
|
||||||
|
added = 0
|
||||||
|
for it in items:
|
||||||
|
if not it.get('is_holiday'):
|
||||||
|
continue
|
||||||
|
date_str = it['date']
|
||||||
|
if not self.is_holiday(date_str):
|
||||||
|
self.add_holiday(date_str, it['name'], is_recurring=False)
|
||||||
|
added += 1
|
||||||
|
return added
|
||||||
|
|
||||||
def add_korean_holidays_auto(self, year: int, include_next_year: bool = False) -> int:
|
def add_korean_holidays_auto(self, year: int, include_next_year: bool = False) -> int:
|
||||||
"""`holidays` 패키지로 음력 명절 포함 한국 공휴일 자동 등록.
|
"""`holidays` 패키지로 음력 명절 포함 한국 공휴일 자동 등록.
|
||||||
|
|
||||||
@ -1738,30 +1808,32 @@ class Database:
|
|||||||
Returns:
|
Returns:
|
||||||
추가된 공휴일 개수. 패키지 미설치 시 -1.
|
추가된 공휴일 개수. 패키지 미설치 시 -1.
|
||||||
"""
|
"""
|
||||||
try:
|
|
||||||
import holidays as _holidays
|
|
||||||
except ImportError:
|
|
||||||
return -1
|
|
||||||
|
|
||||||
years_to_add = [year]
|
years_to_add = [year]
|
||||||
if include_next_year:
|
if include_next_year:
|
||||||
years_to_add.append(year + 1)
|
years_to_add.append(year + 1)
|
||||||
|
|
||||||
added = 0
|
added = 0
|
||||||
for y in years_to_add:
|
for y in years_to_add:
|
||||||
|
# 1차: 정부 API (임시공휴일 포함, 가장 정확)
|
||||||
|
api_count = self.add_korean_holidays_from_api(y)
|
||||||
|
if api_count >= 0:
|
||||||
|
added += api_count
|
||||||
|
# API가 응답했으면 근로자의 날도 포함되어 있음. 끝.
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 2차 fallback: holidays 패키지
|
||||||
try:
|
try:
|
||||||
|
import holidays as _holidays
|
||||||
kr = _holidays.country_holidays('KR', years=y)
|
kr = _holidays.country_holidays('KR', years=y)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue # 패키지 내부 오류는 해당 연도만 스킵
|
continue # 둘 다 실패면 해당 연도만 스킵
|
||||||
for d, name in kr.items():
|
for d, name in kr.items():
|
||||||
date_str = d.isoformat()
|
date_str = d.isoformat()
|
||||||
if not self.is_holiday(date_str):
|
if not self.is_holiday(date_str):
|
||||||
self.add_holiday(date_str, name, is_recurring=False)
|
self.add_holiday(date_str, name, is_recurring=False)
|
||||||
added += 1
|
added += 1
|
||||||
|
|
||||||
# holidays.KR이 누락하는 한국 노동자 휴일 보강.
|
# holidays.KR이 누락하는 근로자의 날 명시적 보강
|
||||||
# 근로자의 날(5/1)은 공식 '공휴일'은 아니지만 대부분 회사가 휴무.
|
|
||||||
# 패키지 버전마다 포함 여부가 달라서 명시적 추가.
|
|
||||||
extra = [(f"{y}-05-01", "근로자의 날")]
|
extra = [(f"{y}-05-01", "근로자의 날")]
|
||||||
for date_str, name in extra:
|
for date_str, name in extra:
|
||||||
if not self.is_holiday(date_str):
|
if not self.is_holiday(date_str):
|
||||||
|
|||||||
@ -4,4 +4,4 @@
|
|||||||
릴리스 시 이 값을 올린 후 git tag → push.
|
릴리스 시 이 값을 올린 후 git tag → push.
|
||||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||||
"""
|
"""
|
||||||
__version__ = '2.9.0'
|
__version__ = '2.10.0'
|
||||||
|
|||||||
9
tests/conftest.py
Normal file
9
tests/conftest.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
"""
|
||||||
|
pytest 공통 설정.
|
||||||
|
|
||||||
|
모든 테스트는 백그라운드 휴일 동기화를 끔 — Database 생성 시 spawn되는
|
||||||
|
holiday-sync 스레드가 DB 파일을 lock해서 다음 테스트의 fixture cleanup이 깨짐.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'
|
||||||
166
tests/test_holiday_api.py
Normal file
166
tests/test_holiday_api.py
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
"""
|
||||||
|
utils.holiday_api 단위 테스트.
|
||||||
|
|
||||||
|
실제 정부 API는 호출하지 않음 — 모두 urlopen mock.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from utils.holiday_api import (
|
||||||
|
fetch_korean_holidays, _parse_response, is_configured,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ok_response(items):
|
||||||
|
"""API 정상 응답 형식 빌드."""
|
||||||
|
return {
|
||||||
|
'response': {
|
||||||
|
'header': {'resultCode': '00', 'resultMsg': 'NORMAL SERVICE.'},
|
||||||
|
'body': {
|
||||||
|
'items': {'item': items} if items else {'item': []},
|
||||||
|
'numOfRows': 100,
|
||||||
|
'pageNo': 1,
|
||||||
|
'totalCount': len(items) if isinstance(items, list) else 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseResponse:
|
||||||
|
def test_multiple_items(self):
|
||||||
|
items = [
|
||||||
|
{'dateKind': '01', 'dateName': '근로자의 날', 'isHoliday': 'Y',
|
||||||
|
'locdate': 20260501, 'seq': 1},
|
||||||
|
{'dateKind': '01', 'dateName': '어린이날', 'isHoliday': 'Y',
|
||||||
|
'locdate': 20260505, 'seq': 1},
|
||||||
|
]
|
||||||
|
out = _parse_response(_ok_response(items))
|
||||||
|
assert len(out) == 2
|
||||||
|
assert out[0]['date'] == '2026-05-01'
|
||||||
|
assert out[0]['name'] == '근로자의 날'
|
||||||
|
assert out[0]['is_holiday'] is True
|
||||||
|
assert out[1]['date'] == '2026-05-05'
|
||||||
|
|
||||||
|
def test_single_item_as_dict(self):
|
||||||
|
# API가 결과 1개일 때 list가 아닌 dict로 반환하는 케이스
|
||||||
|
item = {'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501}
|
||||||
|
data = {
|
||||||
|
'response': {
|
||||||
|
'header': {'resultCode': '00'},
|
||||||
|
'body': {'items': {'item': item}, 'totalCount': 1},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = _parse_response(data)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]['name'] == '근로자의 날'
|
||||||
|
|
||||||
|
def test_empty_year(self):
|
||||||
|
# totalCount=0 같은 정상 빈 응답
|
||||||
|
data = {
|
||||||
|
'response': {
|
||||||
|
'header': {'resultCode': '00'},
|
||||||
|
'body': {'items': '', 'totalCount': 0},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert _parse_response(data) == []
|
||||||
|
|
||||||
|
def test_error_result_code(self):
|
||||||
|
data = {
|
||||||
|
'response': {
|
||||||
|
'header': {'resultCode': '30', 'resultMsg': 'SERVICE_KEY_IS_NOT_REGISTERED'},
|
||||||
|
'body': {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert _parse_response(data) is None
|
||||||
|
|
||||||
|
def test_isholiday_n_filtered_at_caller_level(self):
|
||||||
|
# 응답 자체엔 is_holiday=False도 포함됨 (예: 24절기). _parse는 그대로 반환,
|
||||||
|
# 실제 휴일 등록은 호출자가 is_holiday=True만 필터.
|
||||||
|
items = [{'dateName': '동지', 'isHoliday': 'N', 'locdate': 20261221}]
|
||||||
|
out = _parse_response(_ok_response(items))
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]['is_holiday'] is False
|
||||||
|
|
||||||
|
def test_locdate_str_form(self):
|
||||||
|
items = [{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': '20260501'}]
|
||||||
|
out = _parse_response(_ok_response(items))
|
||||||
|
assert out[0]['date'] == '2026-05-01'
|
||||||
|
|
||||||
|
def test_invalid_locdate_skipped(self):
|
||||||
|
items = [
|
||||||
|
{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501},
|
||||||
|
{'dateName': '잘못된 날짜', 'isHoliday': 'Y', 'locdate': 'abc'},
|
||||||
|
{'dateName': '짧은 날짜', 'isHoliday': 'Y', 'locdate': '202605'},
|
||||||
|
]
|
||||||
|
out = _parse_response(_ok_response(items))
|
||||||
|
assert len(out) == 1 # 정상 1개만
|
||||||
|
assert out[0]['name'] == '근로자의 날'
|
||||||
|
|
||||||
|
def test_missing_required_fields_skipped(self):
|
||||||
|
items = [
|
||||||
|
{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501},
|
||||||
|
{'isHoliday': 'Y', 'locdate': 20260505}, # name 없음
|
||||||
|
{'dateName': '신정', 'isHoliday': 'Y'}, # locdate 없음
|
||||||
|
]
|
||||||
|
out = _parse_response(_ok_response(items))
|
||||||
|
assert len(out) == 1
|
||||||
|
|
||||||
|
def test_malformed_response_returns_none(self):
|
||||||
|
# response 구조 자체가 깨진 경우
|
||||||
|
assert _parse_response({'random': 'data'}) is None or _parse_response({'random': 'data'}) == []
|
||||||
|
# 위는 implementation-dependent — 둘 다 합리적
|
||||||
|
# 정확히는: response 키 없음 → response={}, header={}, resultCode != '00' → None
|
||||||
|
assert _parse_response({}) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchNetwork:
|
||||||
|
@patch('utils.holiday_api.urllib.request.urlopen')
|
||||||
|
def test_success(self, mock_urlopen):
|
||||||
|
items = [{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501}]
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.read.return_value = json.dumps(_ok_response(items)).encode('utf-8')
|
||||||
|
resp.__enter__.return_value = resp
|
||||||
|
resp.__exit__.return_value = False
|
||||||
|
mock_urlopen.return_value = resp
|
||||||
|
|
||||||
|
out = fetch_korean_holidays(2026)
|
||||||
|
assert out is not None
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]['date'] == '2026-05-01'
|
||||||
|
|
||||||
|
# 요청 URL에 serviceKey + solYear=2026 + _type=json 포함되었는지
|
||||||
|
req = mock_urlopen.call_args[0][0]
|
||||||
|
assert 'serviceKey=' in req.full_url
|
||||||
|
assert 'solYear=2026' in req.full_url
|
||||||
|
assert '_type=json' in req.full_url
|
||||||
|
|
||||||
|
@patch('utils.holiday_api.urllib.request.urlopen')
|
||||||
|
def test_network_error_returns_none(self, mock_urlopen):
|
||||||
|
import urllib.error
|
||||||
|
mock_urlopen.side_effect = urllib.error.URLError('boom')
|
||||||
|
assert fetch_korean_holidays(2026) is None
|
||||||
|
|
||||||
|
@patch('utils.holiday_api.urllib.request.urlopen')
|
||||||
|
def test_timeout_returns_none(self, mock_urlopen):
|
||||||
|
mock_urlopen.side_effect = TimeoutError('slow')
|
||||||
|
assert fetch_korean_holidays(2026) is None
|
||||||
|
|
||||||
|
@patch('utils.holiday_api.urllib.request.urlopen')
|
||||||
|
def test_invalid_json_returns_none(self, mock_urlopen):
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.read.return_value = b'<html>error</html>'
|
||||||
|
resp.__enter__.return_value = resp
|
||||||
|
resp.__exit__.return_value = False
|
||||||
|
mock_urlopen.return_value = resp
|
||||||
|
assert fetch_korean_holidays(2026) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigured:
|
||||||
|
def test_key_set(self):
|
||||||
|
assert is_configured() is True
|
||||||
@ -688,10 +688,11 @@ class SettingsView(QDialog):
|
|||||||
"한국 공휴일 자동 추가",
|
"한국 공휴일 자동 추가",
|
||||||
f"{target_label} 한국 공휴일을 자동으로 등록하시겠습니까?\n\n"
|
f"{target_label} 한국 공휴일을 자동으로 등록하시겠습니까?\n\n"
|
||||||
"포함:\n"
|
"포함:\n"
|
||||||
"• 양력 공휴일 (신정/삼일절/어린이날 등)\n"
|
"• 양력 공휴일 (신정/삼일절/어린이날/근로자의 날 등)\n"
|
||||||
"• 음력 명절 (설날 연휴/추석 연휴/석가탄신일)\n"
|
"• 음력 명절 (설날 연휴/추석 연휴/석가탄신일)\n"
|
||||||
"• 정부 지정 대체·임시공휴일\n\n"
|
"• 정부 지정 대체·임시공휴일\n\n"
|
||||||
"※ 외부 'holidays' 패키지 사용 (requirements.txt 참조)",
|
"※ 1차: 공공데이터포털 특일정보 API (정부 공인, 임시공휴일 포함)\n"
|
||||||
|
"※ 2차 fallback: 'holidays' 패키지 (오프라인)",
|
||||||
QMessageBox.Yes | QMessageBox.No
|
QMessageBox.Yes | QMessageBox.No
|
||||||
)
|
)
|
||||||
if reply != QMessageBox.Yes:
|
if reply != QMessageBox.Yes:
|
||||||
|
|||||||
98
utils/holiday_api.py
Normal file
98
utils/holiday_api.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
"""
|
||||||
|
공공데이터포털 — 한국천문연구원 특일정보 OpenAPI 클라이언트.
|
||||||
|
|
||||||
|
엔드포인트: getRestDeInfo (국경일/공휴일 — 임시공휴일 포함)
|
||||||
|
공식 문서: https://www.data.go.kr/data/15012690/openapi.do
|
||||||
|
|
||||||
|
`holidays` 패키지가 누락하는 임시공휴일·근로자의 날 등을
|
||||||
|
정부 공인 데이터로 보강하기 위해 사용.
|
||||||
|
|
||||||
|
설계:
|
||||||
|
- 네트워크 실패는 silent (None 반환) — 호출자가 fallback 처리
|
||||||
|
- API 키는 코드 내 박혀있으나 dev 본인 계정의 특일정보 API 한정 키
|
||||||
|
(50명 이내 사용 환경에서 일일 한도 1000회 충분)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import json
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
# 공공데이터포털 dev 키 (특일정보 API 한정).
|
||||||
|
# 노출 시 data.go.kr 마이페이지에서 즉시 폐기/재발급 가능.
|
||||||
|
_SERVICE_KEY = 'fa419259319e31d2fcd4f959e65da817fe2f19894bff340a63889db7a8ffac93'
|
||||||
|
|
||||||
|
_BASE = 'https://apis.data.go.kr/B090041/openapi/service/SpcdeInfoService'
|
||||||
|
_USER_AGENT = 'ClockOutCalculator/2.10 (KASI special-day client)'
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_korean_holidays(year: int, timeout: int = 10) -> Optional[List[Dict]]:
|
||||||
|
"""해당 연도의 한국 공휴일 전체를 정부 API에서 받아 반환.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
성공: [{'date': '2026-05-01', 'name': '근로자의 날', 'is_holiday': True}, ...]
|
||||||
|
실패: None (네트워크 오류, 인증 실패, 응답 파싱 실패 등)
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
'serviceKey': _SERVICE_KEY,
|
||||||
|
'solYear': str(year),
|
||||||
|
'_type': 'json',
|
||||||
|
'numOfRows': '100',
|
||||||
|
'pageNo': '1',
|
||||||
|
}
|
||||||
|
url = f"{_BASE}/getRestDeInfo?" + urllib.parse.urlencode(params)
|
||||||
|
req = urllib.request.Request(url, headers={'User-Agent': _USER_AGENT})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
except (urllib.error.URLError, urllib.error.HTTPError,
|
||||||
|
json.JSONDecodeError, OSError, TimeoutError):
|
||||||
|
return None
|
||||||
|
return _parse_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_response(data: Dict) -> Optional[List[Dict]]:
|
||||||
|
"""API 응답 JSON을 표준 형식으로 정규화.
|
||||||
|
|
||||||
|
API 응답 패턴:
|
||||||
|
- resultCode == '00' → 정상
|
||||||
|
- items.item: 단일 결과면 dict, 여러 개면 list
|
||||||
|
- items가 빈 문자열일 때 (totalCount=0)도 정상으로 간주
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = data.get('response') or {}
|
||||||
|
header = response.get('header') or {}
|
||||||
|
if header.get('resultCode') != '00':
|
||||||
|
return None
|
||||||
|
body = response.get('body') or {}
|
||||||
|
items_root = body.get('items')
|
||||||
|
if not items_root:
|
||||||
|
return [] # 그 해 공휴일 없음 (드물지만 정상 응답)
|
||||||
|
item = items_root.get('item') if isinstance(items_root, dict) else None
|
||||||
|
if item is None:
|
||||||
|
return []
|
||||||
|
if isinstance(item, dict):
|
||||||
|
item = [item]
|
||||||
|
out = []
|
||||||
|
for entry in item:
|
||||||
|
locdate = entry.get('locdate')
|
||||||
|
name = entry.get('dateName')
|
||||||
|
is_holiday = (entry.get('isHoliday') == 'Y')
|
||||||
|
if not locdate or not name:
|
||||||
|
continue
|
||||||
|
# locdate: 20260501 (int 또는 str)
|
||||||
|
ds = str(locdate)
|
||||||
|
if len(ds) != 8 or not ds.isdigit():
|
||||||
|
continue
|
||||||
|
iso = f"{ds[0:4]}-{ds[4:6]}-{ds[6:8]}"
|
||||||
|
out.append({'date': iso, 'name': str(name), 'is_holiday': is_holiday})
|
||||||
|
return out
|
||||||
|
except (AttributeError, TypeError, KeyError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_configured() -> bool:
|
||||||
|
"""키가 설정되어 있는지 (테스트/빈 키 환경 가드)."""
|
||||||
|
return bool(_SERVICE_KEY) and len(_SERVICE_KEY) > 10
|
||||||
Loading…
x
Reference in New Issue
Block a user