From 97dd4e39f7e5555d857ae97486ff3d829920adfd Mon Sep 17 00:00:00 2001 From: KINDNICK Date: Fri, 1 May 2026 13:51:33 +0900 Subject: [PATCH] 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 --- CHANGELOG.md | 28 +++++++ core/database.py | 90 ++++++++++++++++++--- core/version.py | 2 +- tests/conftest.py | 9 +++ tests/test_holiday_api.py | 166 ++++++++++++++++++++++++++++++++++++++ ui/settings_view.py | 5 +- utils/holiday_api.py | 98 ++++++++++++++++++++++ 7 files changed, 386 insertions(+), 12 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_holiday_api.py create mode 100644 utils/holiday_api.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 381bfd5..245c1c4 100644 --- a/CHANGELOG.md +++ b/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/). +## [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 ### Fixed — 휴일 hot-path 버그 (사용자 보고) diff --git a/core/database.py b/core/database.py index 6aacc89..52ae024 100644 --- a/core/database.py +++ b/core/database.py @@ -89,10 +89,54 @@ class Database: self.migrate_v271_work_records_indexes() self.migrate_v280_achievements_columns() self.migrate_v280_hire_date() + self.migrate_v290_holidays_auto_sync() # 기본 설정 초기화 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: """init_database()에서 호출되는 CREATE TABLE 묶음. conn은 호출자가 관리.""" cursor = conn.cursor() @@ -1713,6 +1757,32 @@ class Database: for date, name in fixed_holidays: 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: """`holidays` 패키지로 음력 명절 포함 한국 공휴일 자동 등록. @@ -1738,30 +1808,32 @@ class Database: Returns: 추가된 공휴일 개수. 패키지 미설치 시 -1. """ - try: - import holidays as _holidays - except ImportError: - return -1 - years_to_add = [year] if include_next_year: years_to_add.append(year + 1) added = 0 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: + import holidays as _holidays kr = _holidays.country_holidays('KR', years=y) except Exception: - continue # 패키지 내부 오류는 해당 연도만 스킵 + continue # 둘 다 실패면 해당 연도만 스킵 for d, name in kr.items(): date_str = d.isoformat() if not self.is_holiday(date_str): self.add_holiday(date_str, name, is_recurring=False) added += 1 - # holidays.KR이 누락하는 한국 노동자 휴일 보강. - # 근로자의 날(5/1)은 공식 '공휴일'은 아니지만 대부분 회사가 휴무. - # 패키지 버전마다 포함 여부가 달라서 명시적 추가. + # holidays.KR이 누락하는 근로자의 날 명시적 보강 extra = [(f"{y}-05-01", "근로자의 날")] for date_str, name in extra: if not self.is_holiday(date_str): diff --git a/core/version.py b/core/version.py index 7cbc8fd..006e7ec 100644 --- a/core/version.py +++ b/core/version.py @@ -4,4 +4,4 @@ 릴리스 시 이 값을 올린 후 git tag → push. CHANGELOG.md의 최상단 항목과 일치시킬 것. """ -__version__ = '2.9.0' +__version__ = '2.10.0' diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..887c13b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,9 @@ +""" +pytest 공통 설정. + +모든 테스트는 백그라운드 휴일 동기화를 끔 — Database 생성 시 spawn되는 +holiday-sync 스레드가 DB 파일을 lock해서 다음 테스트의 fixture cleanup이 깨짐. +""" +import os + +os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1' diff --git a/tests/test_holiday_api.py b/tests/test_holiday_api.py new file mode 100644 index 0000000..8551b8b --- /dev/null +++ b/tests/test_holiday_api.py @@ -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'error' + 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 diff --git a/ui/settings_view.py b/ui/settings_view.py index f181319..1eaa310 100644 --- a/ui/settings_view.py +++ b/ui/settings_view.py @@ -688,10 +688,11 @@ class SettingsView(QDialog): "한국 공휴일 자동 추가", f"{target_label} 한국 공휴일을 자동으로 등록하시겠습니까?\n\n" "포함:\n" - "• 양력 공휴일 (신정/삼일절/어린이날 등)\n" + "• 양력 공휴일 (신정/삼일절/어린이날/근로자의 날 등)\n" "• 음력 명절 (설날 연휴/추석 연휴/석가탄신일)\n" "• 정부 지정 대체·임시공휴일\n\n" - "※ 외부 'holidays' 패키지 사용 (requirements.txt 참조)", + "※ 1차: 공공데이터포털 특일정보 API (정부 공인, 임시공휴일 포함)\n" + "※ 2차 fallback: 'holidays' 패키지 (오프라인)", QMessageBox.Yes | QMessageBox.No ) if reply != QMessageBox.Yes: diff --git a/utils/holiday_api.py b/utils/holiday_api.py new file mode 100644 index 0000000..7e6f27d --- /dev/null +++ b/utils/holiday_api.py @@ -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