핵심 기능: - 단축근무·표준·반일 등 다양한 근무 패턴 (5개 프리셋 + 사용자 정의) - Windows 이벤트 뷰어 자동 출퇴근 감지 - 30분 단위 연장근무 적립/사용 시스템 - 1.0/0.5/0.25일 연차·반차·반반차 - 자동 점심·저녁·외출·자동 백업·화면 잠금 자동 외출 - 한국 공휴일 자동 등록 (음력 포함, holidays 패키지) - matplotlib 차트 기반 주간/월간/패턴 통계 - 미니 위젯 + 시스템 트레이 통합 - 한국어/English i18n - 자가 업데이트 (updater.exe + Gitea Releases) 아키텍처: - core/ (db, time_calculator, notifier, i18n, version, settings_keys) - ui/ (main_window + 9 dialogs + 3 controllers) - utils/ (backup, lock_detector, debug_log, updater_client, time_format) - tests/ (66 pytest 단위) + 통합/i18n GUI 검증 CI/CD: - .gitea/workflows/ci.yml: push 시 pytest + 통합 테스트 - .gitea/workflows/release.yml: v* 태그 push 시 두 .exe 자동 빌드 + Releases 첨부 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# core 모듈
|
||||
+1603
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
Windows 이벤트 뷰어 모니터
|
||||
시스템 부팅, 로그인, 종료 시간을 감지
|
||||
"""
|
||||
import win32evtlog
|
||||
import win32evtlogutil
|
||||
import win32con
|
||||
import win32security
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta, date
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
|
||||
class EventMonitor:
|
||||
"""Windows 이벤트 로그 모니터링 클래스"""
|
||||
|
||||
# 이벤트 ID 정의
|
||||
EVENT_SYSTEM_BOOT = 6005 # 시스템 부팅
|
||||
EVENT_SYSTEM_START = 6009 # 시스템 시작
|
||||
EVENT_USER_LOGON = 4624 # 사용자 로그인
|
||||
EVENT_SYSTEM_SHUTDOWN = 6006 # 시스템 종료
|
||||
EVENT_SLEEP_ENTER = 42 # 절전 모드 진입 (Kernel-Power)
|
||||
|
||||
def __init__(self):
|
||||
self.server = None # 로컬 컴퓨터
|
||||
self.logtype = "System" # 시스템 로그
|
||||
|
||||
def get_boot_time_powershell(self) -> Optional[datetime]:
|
||||
"""
|
||||
PowerShell을 사용하여 시스템 부팅 시간 조회
|
||||
오늘 부팅한 경우에만 반환
|
||||
Returns:
|
||||
datetime: 오늘의 부팅 시간, 오늘 부팅 안 했으면 None
|
||||
"""
|
||||
try:
|
||||
# PowerShell 명령어 실행
|
||||
result = subprocess.run(
|
||||
['powershell', '-Command',
|
||||
'(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString("yyyy-MM-dd HH:mm:ss")'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
boot_time_str = result.stdout.strip()
|
||||
# "yyyy-MM-dd HH:mm:ss" 형식으로 파싱
|
||||
boot_time = datetime.strptime(boot_time_str, "%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# 오늘 부팅한 경우에만 반환
|
||||
today = datetime.now().date()
|
||||
if boot_time.date() == today:
|
||||
return boot_time
|
||||
else:
|
||||
# 오늘 부팅하지 않음 (절전 모드에서 깨어난 경우)
|
||||
print(f"Last boot was on {boot_time.date()}, not today ({today})")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"PowerShell boot time query failed: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def get_today_boot_time(self) -> Optional[datetime]:
|
||||
"""
|
||||
오늘 첫 부팅 시간 조회 (절전 모드 복귀 포함)
|
||||
오늘의 가장 빠른 시스템 시작 시간을 반환
|
||||
Returns:
|
||||
datetime: 첫 부팅/시작 시간, 없으면 None
|
||||
"""
|
||||
today = datetime.now().date()
|
||||
earliest_time = None
|
||||
|
||||
# Kernel-General (Event ID 1, 12) - 시스템 시작/재개
|
||||
kernel_general_events = self._get_events_by_id(1, limit=50)
|
||||
for event in kernel_general_events:
|
||||
event_time = event['time']
|
||||
if event_time.date() == today:
|
||||
if earliest_time is None or event_time < earliest_time:
|
||||
earliest_time = event_time
|
||||
|
||||
# Event ID 12도 확인
|
||||
kernel_general_12 = self._get_events_by_id(12, limit=50)
|
||||
for event in kernel_general_12:
|
||||
event_time = event['time']
|
||||
if event_time.date() == today:
|
||||
if earliest_time is None or event_time < earliest_time:
|
||||
earliest_time = event_time
|
||||
|
||||
# Kernel-General에서 찾았으면 반환
|
||||
if earliest_time:
|
||||
return earliest_time
|
||||
|
||||
# 못 찾았으면 전통적인 부팅 이벤트 확인
|
||||
# 6005 - 시스템 부팅
|
||||
boot_events = self._get_events_by_id(self.EVENT_SYSTEM_BOOT, limit=10)
|
||||
for event in boot_events:
|
||||
event_time = event['time']
|
||||
if event_time.date() == today:
|
||||
if earliest_time is None or event_time < earliest_time:
|
||||
earliest_time = event_time
|
||||
|
||||
# 6009 - 시스템 시작
|
||||
start_events = self._get_events_by_id(self.EVENT_SYSTEM_START, limit=10)
|
||||
for event in start_events:
|
||||
event_time = event['time']
|
||||
if event_time.date() == today:
|
||||
if earliest_time is None or event_time < earliest_time:
|
||||
earliest_time = event_time
|
||||
|
||||
return earliest_time
|
||||
|
||||
def get_last_shutdown_time(self) -> Optional[datetime]:
|
||||
"""
|
||||
마지막 종료 시간 조회
|
||||
Returns:
|
||||
datetime: 마지막 종료 시간, 없으면 None
|
||||
"""
|
||||
shutdown_events = self._get_events_by_id(self.EVENT_SYSTEM_SHUTDOWN, limit=5)
|
||||
|
||||
if shutdown_events:
|
||||
return shutdown_events[0]['time']
|
||||
|
||||
return None
|
||||
|
||||
def get_yesterday_shutdown_time(self) -> Optional[datetime]:
|
||||
"""
|
||||
어제의 종료 시간 조회 (퇴근 시간으로 사용)
|
||||
Returns:
|
||||
datetime: 어제의 종료 시간, 없으면 None
|
||||
"""
|
||||
yesterday = (datetime.now().date() - timedelta(days=1))
|
||||
shutdown_events = self._get_events_by_id(self.EVENT_SYSTEM_SHUTDOWN, limit=20)
|
||||
|
||||
for event in shutdown_events:
|
||||
event_time = event['time']
|
||||
if event_time.date() == yesterday:
|
||||
return event_time
|
||||
|
||||
return None
|
||||
|
||||
def get_shutdown_time_by_date(self, target_date: date) -> Optional[datetime]:
|
||||
"""
|
||||
특정 날짜의 종료 시간 조회 (정상 종료 + 절전 모드)
|
||||
Args:
|
||||
target_date: 조회할 날짜
|
||||
Returns:
|
||||
datetime: 해당 날짜의 종료 시간, 없으면 None
|
||||
"""
|
||||
# 정상 종료 이벤트 검색
|
||||
shutdown_events = self._get_events_by_id(self.EVENT_SYSTEM_SHUTDOWN, limit=2000)
|
||||
|
||||
# 절전 모드 진입 이벤트도 검색
|
||||
sleep_events = self._get_events_by_id(self.EVENT_SLEEP_ENTER, limit=2000)
|
||||
|
||||
# 해당 날짜의 모든 종료/절전 시간을 찾아서 가장 늦은 시간 반환
|
||||
matching_events = []
|
||||
|
||||
# 정상 종료 이벤트 확인
|
||||
for event in shutdown_events:
|
||||
event_time = event['time']
|
||||
if event_time.date() == target_date:
|
||||
matching_events.append(('shutdown', event_time))
|
||||
|
||||
# 절전 모드 이벤트 확인
|
||||
for event in sleep_events:
|
||||
event_time = event['time']
|
||||
if event_time.date() == target_date:
|
||||
matching_events.append(('sleep', event_time))
|
||||
|
||||
if matching_events:
|
||||
# 가장 늦은 시간 반환 (타입에 관계없이)
|
||||
latest_type, latest_time = max(matching_events, key=lambda x: x[1])
|
||||
return latest_time
|
||||
|
||||
return None
|
||||
|
||||
def get_today_logon_time(self) -> Optional[datetime]:
|
||||
"""
|
||||
오늘 첫 로그인 시간 조회 (보조 수단)
|
||||
Returns:
|
||||
datetime: 첫 로그인 시간, 없으면 None
|
||||
"""
|
||||
try:
|
||||
today = datetime.now().date()
|
||||
hand = win32evtlog.OpenEventLog(self.server, "Security")
|
||||
flags = win32evtlog.EVENTLOG_BACKWARDS_READ | win32evtlog.EVENTLOG_SEQUENTIAL_READ
|
||||
|
||||
events = []
|
||||
total = 0
|
||||
|
||||
while total < 100: # 최근 100개만 확인
|
||||
event_list = win32evtlog.ReadEventLog(hand, flags, 0)
|
||||
if not event_list:
|
||||
break
|
||||
|
||||
for event in event_list:
|
||||
if event.EventID == self.EVENT_USER_LOGON:
|
||||
event_time = datetime.fromtimestamp(int(event.TimeGenerated))
|
||||
if event_time.date() == today:
|
||||
events.append(event_time)
|
||||
|
||||
total += len(event_list)
|
||||
|
||||
win32evtlog.CloseEventLog(hand)
|
||||
|
||||
if events:
|
||||
return min(events) # 가장 빠른 로그인 시간
|
||||
|
||||
except Exception as e:
|
||||
print(f"로그인 이벤트 조회 실패: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _get_events_by_id(self, event_id: int, limit: int = 10) -> List[Dict]:
|
||||
"""
|
||||
특정 이벤트 ID로 이벤트 조회
|
||||
Args:
|
||||
event_id: 이벤트 ID
|
||||
limit: 조회할 최대 개수
|
||||
Returns:
|
||||
List[Dict]: 이벤트 목록
|
||||
"""
|
||||
try:
|
||||
hand = win32evtlog.OpenEventLog(self.server, self.logtype)
|
||||
flags = win32evtlog.EVENTLOG_BACKWARDS_READ | win32evtlog.EVENTLOG_SEQUENTIAL_READ
|
||||
|
||||
events = []
|
||||
total = 0
|
||||
|
||||
while len(events) < limit and total < 10000:
|
||||
event_list = win32evtlog.ReadEventLog(hand, flags, 0)
|
||||
if not event_list:
|
||||
break
|
||||
|
||||
for event in event_list:
|
||||
# 하위 16비트가 실제 이벤트 ID
|
||||
actual_event_id = event.EventID & 0xFFFF
|
||||
|
||||
if actual_event_id == event_id:
|
||||
# pywintypes.datetime을 Python datetime으로 변환
|
||||
time_generated = event.TimeGenerated
|
||||
if hasattr(time_generated, 'timestamp'):
|
||||
# pywintypes.datetime인 경우
|
||||
event_time = datetime.fromtimestamp(time_generated.timestamp())
|
||||
else:
|
||||
# 이미 타임스탬프인 경우
|
||||
event_time = datetime.fromtimestamp(int(time_generated))
|
||||
|
||||
events.append({
|
||||
'event_id': actual_event_id,
|
||||
'time': event_time,
|
||||
'source': event.SourceName
|
||||
})
|
||||
|
||||
if len(events) >= limit:
|
||||
break
|
||||
|
||||
total += len(event_list)
|
||||
|
||||
win32evtlog.CloseEventLog(hand)
|
||||
return events
|
||||
|
||||
except Exception as e:
|
||||
print(f"이벤트 조회 실패: {e}")
|
||||
return []
|
||||
|
||||
def get_work_start_time(self) -> Optional[datetime]:
|
||||
"""
|
||||
출근 시간 자동 감지 (부팅 또는 로그인 중 빠른 시간)
|
||||
우선순위:
|
||||
1. PowerShell을 통한 부팅 시간 (가장 확실)
|
||||
2. 이벤트 뷰어를 통한 부팅 시간
|
||||
3. 이벤트 뷰어를 통한 로그인 시간
|
||||
Returns:
|
||||
datetime: 출근 시간, 없으면 None
|
||||
"""
|
||||
# 1순위: PowerShell로 부팅 시간 조회 (가장 확실한 방법)
|
||||
powershell_boot_time = self.get_boot_time_powershell()
|
||||
if powershell_boot_time:
|
||||
return powershell_boot_time
|
||||
|
||||
# 2순위: 이벤트 뷰어로 부팅 시간 조회
|
||||
boot_time = self.get_today_boot_time()
|
||||
if boot_time:
|
||||
return boot_time
|
||||
|
||||
# 3순위: 로그인 시간 조회
|
||||
logon_time = self.get_today_logon_time()
|
||||
if logon_time:
|
||||
return logon_time
|
||||
|
||||
return None
|
||||
|
||||
def test_event_log_access(self) -> bool:
|
||||
"""
|
||||
이벤트 로그 접근 가능 여부 테스트
|
||||
Returns:
|
||||
bool: 접근 가능하면 True
|
||||
"""
|
||||
try:
|
||||
hand = win32evtlog.OpenEventLog(self.server, self.logtype)
|
||||
win32evtlog.CloseEventLog(hand)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"이벤트 로그 접근 실패: {e}")
|
||||
print("관리자 권한으로 실행해야 할 수 있습니다.")
|
||||
return False
|
||||
|
||||
|
||||
# 테스트 코드
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
# UTF-8 출력 설정
|
||||
if sys.platform == 'win32':
|
||||
import codecs
|
||||
sys.stdout = codecs.getwriter('utf-8')(sys.stdout.buffer, 'strict')
|
||||
|
||||
monitor = EventMonitor()
|
||||
|
||||
print("=== Windows Event Monitor Test ===\n")
|
||||
|
||||
# PowerShell 부팅 시간 (1순위)
|
||||
print("1. PowerShell Boot Time Check:")
|
||||
powershell_boot = monitor.get_boot_time_powershell()
|
||||
if powershell_boot:
|
||||
print(f" [OK] Boot time: {powershell_boot.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
else:
|
||||
print(" [X] PowerShell query failed")
|
||||
|
||||
print("\n2. Event Log Access Check:")
|
||||
if monitor.test_event_log_access():
|
||||
print(" [OK] Event log access successful")
|
||||
else:
|
||||
print(" [X] Event log access failed (try running as administrator)")
|
||||
|
||||
print("\n3. Event Viewer Boot Time Check:")
|
||||
boot_time = monitor.get_today_boot_time()
|
||||
if boot_time:
|
||||
print(f" [OK] Boot time: {boot_time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
else:
|
||||
print(" [X] No boot record found today")
|
||||
|
||||
print("\n4. Event Viewer Logon Time Check:")
|
||||
logon_time = monitor.get_today_logon_time()
|
||||
if logon_time:
|
||||
print(f" [OK] Logon time: {logon_time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
else:
|
||||
print(" [X] No logon record found today")
|
||||
|
||||
print("\n" + "="*50)
|
||||
# 출근 시간 (자동 감지 - 최종 결과)
|
||||
work_start = monitor.get_work_start_time()
|
||||
if work_start:
|
||||
print(f"FINAL: Detected work start time")
|
||||
print(f" {work_start.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
else:
|
||||
print("FINAL: Work start time detection FAILED")
|
||||
print("="*50)
|
||||
+568
@@ -0,0 +1,568 @@
|
||||
"""
|
||||
경량 i18n.
|
||||
|
||||
`tr('key')` → 현재 언어의 번역 문자열. 키 미존재 시 ko 폴백, ko도 없으면
|
||||
키 그대로 반환 (미번역도 동작). 점진 도입 가능.
|
||||
|
||||
새 언어 추가:
|
||||
1. _DICT['en'] 옆에 'ja'/'zh' 등 추가
|
||||
2. 사용자가 설정 → 언어 콤보에서 선택
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
_current_lang = 'ko'
|
||||
|
||||
_DICT = {
|
||||
'ko': {
|
||||
# === 메뉴/버튼 ===
|
||||
'menu.stats': '통계',
|
||||
'menu.calendar': '캘린더',
|
||||
'menu.daily_report': '일일보고',
|
||||
'menu.help': '도움말',
|
||||
'menu.settings': '설정',
|
||||
'btn.clock_out': '퇴근하기',
|
||||
'btn.clock_out_cancel': '🔄 퇴근 취소',
|
||||
'btn.lunch_add': '점심시간 추가',
|
||||
'btn.lunch_applied': '점심시간 (적용됨)',
|
||||
'btn.dinner_add': '저녁시간 추가',
|
||||
'btn.dinner_applied': '저녁시간 (적용됨)',
|
||||
'btn.break_out': '🚪 외출 시작',
|
||||
'btn.break_in': '↩️ 복귀',
|
||||
'btn.save': '💾 저장',
|
||||
'btn.close': '닫기',
|
||||
'btn.apply': '적용',
|
||||
'btn.cancel': '취소',
|
||||
'btn.add': '추가',
|
||||
'btn.delete': '삭제',
|
||||
'btn.edit': '편집',
|
||||
'btn.confirm': '확인',
|
||||
'btn.ok': '확인',
|
||||
|
||||
# === 윈도우/다이얼로그 제목 ===
|
||||
'window.main_title': '퇴근시간 계산기',
|
||||
'window.settings': '⚙️ 설정',
|
||||
'window.help': '📖 사용 설명서',
|
||||
'window.stats': '📊 근무 통계',
|
||||
'window.calendar': '📅 캘린더',
|
||||
'window.mini_widget': '퇴근시간',
|
||||
'window.clock_in_dialog': '출근 시간',
|
||||
'window.break_view': '외출 관리',
|
||||
'window.overtime_view': '연장근무 관리',
|
||||
'window.leave_view': '연차 관리',
|
||||
|
||||
# === 라벨 ===
|
||||
'label.remaining': '남은 시간',
|
||||
'label.overtime_progress': '추가 근무 중',
|
||||
'label.expected_clock_out': '예상 퇴근',
|
||||
'label.clock_in_time': '출근 시간',
|
||||
'label.clock_out_time': '퇴근 시간',
|
||||
'label.work_hours_label': '하루 기본 근무:',
|
||||
'label.lunch_default': '점심시간 기본:',
|
||||
'label.dinner_default': '저녁시간 기본:',
|
||||
'label.work_pattern': '근무 패턴:',
|
||||
'label.time_format': '시간 형식:',
|
||||
'label.theme': '테마:',
|
||||
'label.language': '언어 / Language:',
|
||||
'label.unit_hour': '시간',
|
||||
'label.unit_minute': '분',
|
||||
'label.unit_day': '일',
|
||||
'label.unit_count': '개',
|
||||
'label.weekday_mon': '월',
|
||||
'label.weekday_tue': '화',
|
||||
'label.weekday_wed': '수',
|
||||
'label.weekday_thu': '목',
|
||||
'label.weekday_fri': '금',
|
||||
'label.weekday_sat': '토',
|
||||
'label.weekday_sun': '일',
|
||||
'label.am': '오전',
|
||||
'label.pm': '오후',
|
||||
|
||||
# === 알림 ===
|
||||
'notif.clock_out_soon.title': '⏰ 퇴근 시간 임박',
|
||||
'notif.clock_out_soon.body': '퇴근까지 {minutes}분 남았습니다.\n마무리 준비를 시작하세요!',
|
||||
'notif.lunch_reminder.title': '🍱 점심시간 등록',
|
||||
'notif.lunch_reminder.body': '점심시간을 등록하지 않으셨네요.\n점심시간을 추가하시겠어요?',
|
||||
'notif.overtime_earning.title': '🔥 연장근무 적립 예정',
|
||||
'notif.overtime_earning.body': '오늘 {time_str}의 연장근무가 적립될 예정입니다!',
|
||||
'notif.overtime_threshold.title': '💰 연장근무 적립 알림',
|
||||
'notif.overtime_threshold.body': '적립된 연장근무가 {hours:.1f}시간 입니다.\n사용을 고려해보세요!',
|
||||
'notif.health.title': '⚠️ 건강 경고',
|
||||
'notif.health.body': '{days}일 연속 연장근무 중입니다.\n건강을 챙기세요!',
|
||||
'notif.weekly_52.title': '🚨 주 52시간 초과',
|
||||
'notif.weekly_52.body': '이번 주 총 근무시간이 {hours:.1f}시간입니다.\n법정 근로시간을 초과했습니다!',
|
||||
|
||||
# === 메시지박스 ===
|
||||
'msg.save_success.title': '저장 완료',
|
||||
'msg.save_success.body': '설정이 저장되었습니다.',
|
||||
'msg.input_error.title': '입력 오류',
|
||||
'msg.work_min_too_small': '하루 기본 근무 시간은 최소 30분 이상이어야 합니다.',
|
||||
'msg.no_clock_in.title': '외출 불가',
|
||||
'msg.no_clock_in.body': '출근하지 않은 상태입니다.',
|
||||
'msg.already_break.body': '이미 외출 중입니다.',
|
||||
'msg.no_record.title': '기록 없음',
|
||||
'msg.no_record.body': '오늘 출근 기록이 없습니다.',
|
||||
'msg.confirm_delete.title': '삭제 확인',
|
||||
'msg.no_data.title': '데이터 없음',
|
||||
|
||||
# === 트레이 ===
|
||||
'tray.open': '프로그램 열기',
|
||||
'tray.mini_widget': '📌 미니 위젯',
|
||||
'tray.toggle_lunch': '🍱 점심시간 토글',
|
||||
'tray.quit': '종료',
|
||||
'tray.tooltip_remaining': '퇴근까지: {time}',
|
||||
'tray.tooltip_overtime': '추가 근무 중: {time}',
|
||||
'tray.background': '프로그램이 트레이에서 실행 중입니다.',
|
||||
|
||||
# === 그룹 박스 ===
|
||||
'group.work_time': '근무 시간 설정',
|
||||
'group.notification': '알림 및 표시 설정',
|
||||
'group.overtime': '연장근무 설정',
|
||||
'group.leave': '휴가 설정',
|
||||
'group.holiday': '공휴일 설정',
|
||||
'group.data': '데이터 관리',
|
||||
|
||||
# === StatsView ===
|
||||
'stats.title': '근무 통계',
|
||||
'stats.tab_weekly': '주간',
|
||||
'stats.tab_monthly': '월간',
|
||||
'stats.tab_pattern': '패턴 분석',
|
||||
'stats.weekly_summary': '이번 주 요약',
|
||||
'stats.monthly_summary': '이번 달 요약',
|
||||
'stats.total_hours': '총 근무시간:',
|
||||
'stats.work_days': '근무일수:',
|
||||
'stats.avg_hours': '평균 근무시간:',
|
||||
'stats.total_overtime': '총 연장근무:',
|
||||
'stats.pattern_insights': '근무 패턴 인사이트',
|
||||
'stats.analyzing': '데이터를 분석 중입니다...',
|
||||
'stats.no_data': '아직 충분한 데이터가 없습니다.',
|
||||
|
||||
# === CalendarView ===
|
||||
'cal.title': '월간 근무 캘린더',
|
||||
'cal.year_label': '년',
|
||||
'cal.month_label': '월',
|
||||
'cal.prev': '◀ 이전',
|
||||
'cal.next': '다음 ▶',
|
||||
'cal.today': '오늘',
|
||||
'cal.no_record': '기록 없음',
|
||||
'cal.edit_record': '기록 편집',
|
||||
|
||||
# === HelpView (각 탭의 큰 HTML은 별도 키) ===
|
||||
'help.tab_intro': '👋 시작하기',
|
||||
'help.tab_work_hours': '🕘 근무시간',
|
||||
'help.tab_overtime': '🏦 연장근무',
|
||||
'help.tab_leave': '🌴 연차/휴가',
|
||||
'help.tab_break': '🚪 외출/저녁',
|
||||
'help.tab_faq': '❓ 자주 묻는 질문',
|
||||
},
|
||||
'en': {
|
||||
# === Menu/Buttons ===
|
||||
'menu.stats': 'Stats',
|
||||
'menu.calendar': 'Calendar',
|
||||
'menu.daily_report': 'Daily Report',
|
||||
'menu.help': 'Help',
|
||||
'menu.settings': 'Settings',
|
||||
'btn.clock_out': 'Clock Out',
|
||||
'btn.clock_out_cancel': '🔄 Cancel Clock-out',
|
||||
'btn.lunch_add': 'Add Lunch',
|
||||
'btn.lunch_applied': 'Lunch (Applied)',
|
||||
'btn.dinner_add': 'Add Dinner',
|
||||
'btn.dinner_applied': 'Dinner (Applied)',
|
||||
'btn.break_out': '🚪 Start Break',
|
||||
'btn.break_in': '↩️ Return',
|
||||
'btn.save': '💾 Save',
|
||||
'btn.close': 'Close',
|
||||
'btn.apply': 'Apply',
|
||||
'btn.cancel': 'Cancel',
|
||||
'btn.add': 'Add',
|
||||
'btn.delete': 'Delete',
|
||||
'btn.edit': 'Edit',
|
||||
'btn.confirm': 'Confirm',
|
||||
'btn.ok': 'OK',
|
||||
|
||||
# === Windows ===
|
||||
'window.main_title': 'Clock-out Time Calculator',
|
||||
'window.settings': '⚙️ Settings',
|
||||
'window.help': '📖 User Guide',
|
||||
'window.stats': '📊 Statistics',
|
||||
'window.calendar': '📅 Calendar',
|
||||
'window.mini_widget': 'Clock-out',
|
||||
'window.clock_in_dialog': 'Clock-in Time',
|
||||
'window.break_view': 'Break Management',
|
||||
'window.overtime_view': 'Overtime Management',
|
||||
'window.leave_view': 'Leave Management',
|
||||
|
||||
# === Labels ===
|
||||
'label.remaining': 'Remaining',
|
||||
'label.overtime_progress': 'Overtime',
|
||||
'label.expected_clock_out': 'Expected Clock-out',
|
||||
'label.clock_in_time': 'Clock-in Time',
|
||||
'label.clock_out_time': 'Clock-out Time',
|
||||
'label.work_hours_label': 'Daily work:',
|
||||
'label.lunch_default': 'Lunch break:',
|
||||
'label.dinner_default': 'Dinner break:',
|
||||
'label.work_pattern': 'Work pattern:',
|
||||
'label.time_format': 'Time format:',
|
||||
'label.theme': 'Theme:',
|
||||
'label.language': 'Language / 언어:',
|
||||
'label.unit_hour': 'h',
|
||||
'label.unit_minute': 'min',
|
||||
'label.unit_day': 'day(s)',
|
||||
'label.unit_count': '',
|
||||
'label.weekday_mon': 'Mon',
|
||||
'label.weekday_tue': 'Tue',
|
||||
'label.weekday_wed': 'Wed',
|
||||
'label.weekday_thu': 'Thu',
|
||||
'label.weekday_fri': 'Fri',
|
||||
'label.weekday_sat': 'Sat',
|
||||
'label.weekday_sun': 'Sun',
|
||||
'label.am': 'AM',
|
||||
'label.pm': 'PM',
|
||||
|
||||
# === Notifications ===
|
||||
'notif.clock_out_soon.title': '⏰ Clock-out Soon',
|
||||
'notif.clock_out_soon.body': "{minutes} minutes until clock-out.\nWrap things up!",
|
||||
'notif.lunch_reminder.title': '🍱 Lunch Reminder',
|
||||
'notif.lunch_reminder.body': "You haven't registered lunch yet.\nWant to add it?",
|
||||
'notif.overtime_earning.title': '🔥 Overtime Will Accrue',
|
||||
'notif.overtime_earning.body': "{time_str} of overtime will be banked today!",
|
||||
'notif.overtime_threshold.title': '💰 Overtime Balance High',
|
||||
'notif.overtime_threshold.body': "{hours:.1f} hours of overtime banked.\nConsider using some.",
|
||||
'notif.health.title': '⚠️ Health Warning',
|
||||
'notif.health.body': "{days} consecutive days of overtime.\nTake care of your health!",
|
||||
'notif.weekly_52.title': '🚨 Weekly 52h Exceeded',
|
||||
'notif.weekly_52.body': "This week's total work hours: {hours:.1f}\nLegal limit exceeded!",
|
||||
|
||||
# === Message Boxes ===
|
||||
'msg.save_success.title': 'Saved',
|
||||
'msg.save_success.body': 'Settings saved successfully.',
|
||||
'msg.input_error.title': 'Input Error',
|
||||
'msg.work_min_too_small': 'Daily work time must be at least 30 minutes.',
|
||||
'msg.no_clock_in.title': 'Cannot Take Break',
|
||||
'msg.no_clock_in.body': 'Not clocked in.',
|
||||
'msg.already_break.body': 'Already on break.',
|
||||
'msg.no_record.title': 'No Record',
|
||||
'msg.no_record.body': 'No clock-in record for today.',
|
||||
'msg.confirm_delete.title': 'Confirm Delete',
|
||||
'msg.no_data.title': 'No Data',
|
||||
|
||||
# === Tray ===
|
||||
'tray.open': 'Open Program',
|
||||
'tray.mini_widget': '📌 Mini Widget',
|
||||
'tray.toggle_lunch': '🍱 Toggle Lunch',
|
||||
'tray.quit': 'Quit',
|
||||
'tray.tooltip_remaining': 'Until clock-out: {time}',
|
||||
'tray.tooltip_overtime': 'Overtime: {time}',
|
||||
'tray.background': 'Program is running in the tray.',
|
||||
|
||||
# === Groups ===
|
||||
'group.work_time': 'Work Time Settings',
|
||||
'group.notification': 'Notifications & Display',
|
||||
'group.overtime': 'Overtime Settings',
|
||||
'group.leave': 'Leave Settings',
|
||||
'group.holiday': 'Holidays',
|
||||
'group.data': 'Data Management',
|
||||
|
||||
# === StatsView ===
|
||||
'stats.title': 'Work Statistics',
|
||||
'stats.tab_weekly': 'Weekly',
|
||||
'stats.tab_monthly': 'Monthly',
|
||||
'stats.tab_pattern': 'Patterns',
|
||||
'stats.weekly_summary': 'This Week',
|
||||
'stats.monthly_summary': 'This Month',
|
||||
'stats.total_hours': 'Total hours:',
|
||||
'stats.work_days': 'Work days:',
|
||||
'stats.avg_hours': 'Avg hours/day:',
|
||||
'stats.total_overtime': 'Total overtime:',
|
||||
'stats.pattern_insights': 'Work Pattern Insights',
|
||||
'stats.analyzing': 'Analyzing...',
|
||||
'stats.no_data': 'Not enough data yet.',
|
||||
|
||||
# === CalendarView ===
|
||||
'cal.title': 'Monthly Calendar',
|
||||
'cal.year_label': 'Y',
|
||||
'cal.month_label': 'M',
|
||||
'cal.prev': '◀ Prev',
|
||||
'cal.next': 'Next ▶',
|
||||
'cal.today': 'Today',
|
||||
'cal.no_record': 'No record',
|
||||
'cal.edit_record': 'Edit record',
|
||||
|
||||
# === HelpView ===
|
||||
'help.tab_intro': '👋 Getting Started',
|
||||
'help.tab_work_hours': '🕘 Work Hours',
|
||||
'help.tab_overtime': '🏦 Overtime',
|
||||
'help.tab_leave': '🌴 Leave',
|
||||
'help.tab_break': '🚪 Break/Dinner',
|
||||
'help.tab_faq': '❓ FAQ',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# === HelpView 큰 HTML 콘텐츠 (별도 사전) ===
|
||||
_HELP_HTML = {
|
||||
'ko': {
|
||||
'help.html.intro': """
|
||||
<h2>👋 환영합니다!</h2>
|
||||
<p><b>퇴근시간 계산기</b>는 출근 시간 자동 감지부터 연장근무 적립·사용까지
|
||||
하루 근무를 정리해 주는 데스크톱 앱입니다.</p>
|
||||
|
||||
<h3>한눈에 보는 기본 흐름</h3>
|
||||
<ol>
|
||||
<li><b>출근</b> — 컴퓨터 켜진 시각이 자동으로 출근 시간으로 기록돼요.</li>
|
||||
<li><b>근무 중</b> — 메인 화면에 퇴근까지 남은 시간이 1초마다 갱신됩니다.</li>
|
||||
<li><b>점심/저녁</b> — 식사 시간 버튼을 누르면 그만큼 근무시간이 늘어납니다.</li>
|
||||
<li><b>외출</b> — "외출 시작/복귀" 버튼으로 잠깐 자리 비운 시간을 추적합니다.</li>
|
||||
<li><b>퇴근</b> — 퇴근 버튼을 누르면 연장근무가 30분 단위로 자동 적립됩니다.</li>
|
||||
</ol>
|
||||
|
||||
<h3>처음 켰다면 꼭 확인하세요</h3>
|
||||
<ul>
|
||||
<li><b>설정 → 근무 시간</b>: 본인 근무 패턴(8시간 / 단축 7h30m / 6시간 등)을
|
||||
프리셋에서 선택하거나 직접 입력하세요.</li>
|
||||
<li><b>설정 → 휴가 → 연간 연차</b>: 본인 연차 일수를 맞춰 두면 잔여 연차가
|
||||
자동 계산됩니다.</li>
|
||||
<li><b>관리자 권한</b>이 필요할 수 있어요. 부팅 시간이 자동 감지되지 않으면
|
||||
관리자 권한으로 실행해 보세요.</li>
|
||||
</ul>
|
||||
""",
|
||||
'help.html.work_hours': """
|
||||
<h2>🕘 근무시간 설정</h2>
|
||||
|
||||
<h3>표준 근무 / 단축근무 / 시간제 모두 지원</h3>
|
||||
<ul>
|
||||
<li><b>표준 8시간</b> — 점심 60분 (기본값)</li>
|
||||
<li><b>단축근무 7시간 30분</b> — 점심 30분</li>
|
||||
<li><b>단축근무 7시간</b> — 점심 60분</li>
|
||||
<li><b>단축근무 6시간</b> — 점심 30분</li>
|
||||
<li><b>반일 4시간</b> — 점심 없음</li>
|
||||
<li><b>사용자 정의</b> — 시간/분 직접 입력 (5분 단위)</li>
|
||||
</ul>
|
||||
|
||||
<h3>💡 단축근무 사용자 안내</h3>
|
||||
<p>예) 하루 7시간 30분 근무 + 점심 30분</p>
|
||||
<ol>
|
||||
<li>설정 → 근무 시간 → <b>근무 패턴</b>에서
|
||||
<i>"단축근무 7시간 30분 (점심 30분)"</i> 선택</li>
|
||||
<li>또는 직접 입력: <b>하루 기본 근무</b>에 <code>7 시간 30 분</code>,
|
||||
<b>점심시간 기본</b>에 <code>30 분</code></li>
|
||||
<li><b>저장</b> 클릭하면 즉시 메인 화면 계산이 갱신됩니다.</li>
|
||||
</ol>
|
||||
|
||||
<h3>점심시간 자동 적용</h3>
|
||||
<p>설정에서 <b>"자동 적용"</b>을 체크하면 출근 후 <b>4시간 경과</b> 시
|
||||
점심시간이 자동으로 켜집니다.</p>
|
||||
""",
|
||||
'help.html.overtime': """
|
||||
<h2>🏦 연장근무 30분 단위 적립 시스템</h2>
|
||||
|
||||
<h3>적립 규칙</h3>
|
||||
<p>정규 퇴근시간 이후 일한 시간은 <b>30분 단위로 절삭</b>되어 적립됩니다.</p>
|
||||
<ul>
|
||||
<li>1시간 35분 일했다면 → <b>1시간 30분</b> 적립</li>
|
||||
<li>55분 일했다면 → <b>30분</b> 적립</li>
|
||||
<li>29분 일했다면 → <b>0분</b> 적립</li>
|
||||
</ul>
|
||||
|
||||
<h3>사용 방법</h3>
|
||||
<p>적립된 연장근무는 메인 화면 <b>"30분 사용" / "1시간 사용"</b> 버튼으로
|
||||
쓸 수 있어요. 사용한 만큼 그날 퇴근시간이 앞당겨집니다.</p>
|
||||
|
||||
<h3>주말·공휴일 근무</h3>
|
||||
<p>주말 또는 등록된 공휴일에 일한 시간은
|
||||
<b>모든 시간이 연장근무로 적립</b>됩니다.</p>
|
||||
""",
|
||||
'help.html.leave': """
|
||||
<h2>🌴 연차·반차 관리</h2>
|
||||
|
||||
<h3>연차 잔액 자동 계산</h3>
|
||||
<p>잔액 = <b>연간 연차</b> − (프로그램 외 사용분 + 프로그램에서 기록된 사용분)</p>
|
||||
|
||||
<h3>반차·반반차 지원</h3>
|
||||
<ul>
|
||||
<li><b>1.0일</b> — 종일 연차</li>
|
||||
<li><b>0.5일</b> — 반차 (4시간)</li>
|
||||
<li><b>0.25일</b> — 반반차 (2시간)</li>
|
||||
</ul>
|
||||
|
||||
<h3>단축근무자의 연차 환산</h3>
|
||||
<p>1일 연차의 시간 길이는 설정의 <b>하루 기본 근무</b>를 따릅니다.
|
||||
예: 7시간 30분 근무자는 1일 연차가 7시간 30분(=450분)으로 환산됩니다.</p>
|
||||
""",
|
||||
'help.html.break': """
|
||||
<h2>🚪 외출 / 저녁시간</h2>
|
||||
|
||||
<h3>외출 (잠깐 자리 비움)</h3>
|
||||
<p>병원, 잠깐 외근 등으로 자리를 비울 때 <b>외출 시작 → 복귀</b> 버튼으로
|
||||
시간을 추적하세요.</p>
|
||||
|
||||
<h3>화면 잠금 자동 외출</h3>
|
||||
<p>설정에서 <b>"화면 잠금 시 자동 외출/복귀"</b>를 켜면 PC 잠금 시 자동으로
|
||||
외출이 시작되고, 풀리면 복귀로 처리됩니다.</p>
|
||||
|
||||
<h3>저녁시간</h3>
|
||||
<p>야근하면서 저녁을 먹는다면 <b>저녁시간 추가</b> 버튼을 눌러주세요.</p>
|
||||
""",
|
||||
'help.html.faq': """
|
||||
<h2>❓ 자주 묻는 질문</h2>
|
||||
|
||||
<h3>Q. 출근 시간이 잘못 잡혔어요</h3>
|
||||
<p>메인 화면 출근 시각 옆 <b>편집(연필)</b> 아이콘으로 수정할 수 있어요.</p>
|
||||
|
||||
<h3>Q. 단축근무 7시간 30분으로 설정하고 싶어요</h3>
|
||||
<p>설정 → 근무 시간 → 근무 패턴에서 프리셋을 선택하거나 시·분을 직접 입력하세요.</p>
|
||||
|
||||
<h3>Q. 데이터는 어디에 저장되나요?</h3>
|
||||
<p>실행 폴더의 <code>database.db</code> (SQLite). 자동 백업은
|
||||
<code>~/.clockout_backups/</code>에 1일 1회 회전됩니다.</p>
|
||||
|
||||
""",
|
||||
},
|
||||
'en': {
|
||||
'help.html.intro': """
|
||||
<h2>👋 Welcome!</h2>
|
||||
<p><b>Clock-out Time Calculator</b> is a desktop app that organizes your daily work
|
||||
— from auto-detecting clock-in time to banking and using overtime.</p>
|
||||
|
||||
<h3>Basic Flow</h3>
|
||||
<ol>
|
||||
<li><b>Clock in</b> — System boot time is auto-recorded as your clock-in.</li>
|
||||
<li><b>Working</b> — Remaining time updates every second on the main screen.</li>
|
||||
<li><b>Lunch/Dinner</b> — Press the meal buttons to extend work time.</li>
|
||||
<li><b>Break</b> — Track time away with "Start Break / Return" buttons.</li>
|
||||
<li><b>Clock out</b> — Press the button; overtime is banked in 30-min units.</li>
|
||||
</ol>
|
||||
|
||||
<h3>First-Time Setup</h3>
|
||||
<ul>
|
||||
<li><b>Settings → Work Time</b>: pick your pattern from presets
|
||||
(8h / 7h30m / 6h / 4h half-day) or enter custom values.</li>
|
||||
<li><b>Settings → Leave</b>: set your annual leave days.</li>
|
||||
<li><b>Admin rights</b> may be required for boot-time auto-detection.</li>
|
||||
</ul>
|
||||
""",
|
||||
'help.html.work_hours': """
|
||||
<h2>🕘 Work Time Settings</h2>
|
||||
|
||||
<h3>Supports Standard / Reduced / Part-time</h3>
|
||||
<ul>
|
||||
<li><b>Standard 8h</b> — 60-min lunch (default)</li>
|
||||
<li><b>Reduced 7h30m</b> — 30-min lunch</li>
|
||||
<li><b>Reduced 7h</b> — 60-min lunch</li>
|
||||
<li><b>Reduced 6h</b> — 30-min lunch</li>
|
||||
<li><b>Half-day 4h</b> — no lunch</li>
|
||||
<li><b>Custom</b> — direct input (5-min granularity)</li>
|
||||
</ul>
|
||||
|
||||
<h3>💡 For Reduced-Hours Users</h3>
|
||||
<p>Example: 7h30m daily + 30-min lunch</p>
|
||||
<ol>
|
||||
<li>Settings → Work Time → pick <i>"Reduced 7h30m (30-min lunch)"</i> preset</li>
|
||||
<li>Or enter directly: 7h 30min daily, 30min lunch</li>
|
||||
<li>Click <b>Save</b> — main screen recalculates immediately.</li>
|
||||
</ol>
|
||||
|
||||
<h3>Auto Lunch</h3>
|
||||
<p>Enable "Auto Apply" in settings to automatically turn on lunch
|
||||
after 4 hours from clock-in.</p>
|
||||
""",
|
||||
'help.html.overtime': """
|
||||
<h2>🏦 30-Minute Overtime Banking</h2>
|
||||
|
||||
<h3>Banking Rule</h3>
|
||||
<p>Time worked past your scheduled clock-out is <b>truncated to 30-min units</b>.</p>
|
||||
<ul>
|
||||
<li>1h35m worked → <b>1h30m</b> banked</li>
|
||||
<li>55min worked → <b>30min</b> banked</li>
|
||||
<li>29min worked → <b>0min</b> banked</li>
|
||||
</ul>
|
||||
|
||||
<h3>Using Banked Overtime</h3>
|
||||
<p>Use buttons on the main screen to consume banked overtime.
|
||||
Each unit lets you clock out earlier on a chosen day.</p>
|
||||
|
||||
<h3>Weekend/Holiday Work</h3>
|
||||
<p>All hours worked on weekends or registered holidays are
|
||||
<b>banked entirely as overtime</b>.</p>
|
||||
""",
|
||||
'help.html.leave': """
|
||||
<h2>🌴 Annual Leave Management</h2>
|
||||
|
||||
<h3>Auto Balance Calculation</h3>
|
||||
<p>Balance = annual leave − (pre-program usage + recorded usage)</p>
|
||||
|
||||
<h3>Half / Quarter Day</h3>
|
||||
<ul>
|
||||
<li><b>1.0 day</b> — full leave</li>
|
||||
<li><b>0.5 day</b> — half (4h)</li>
|
||||
<li><b>0.25 day</b> — quarter (2h)</li>
|
||||
</ul>
|
||||
|
||||
<h3>Reduced-Hours Conversion</h3>
|
||||
<p>1 leave day equals your configured daily work time.
|
||||
Example: 7h30m worker → 1 day = 7h30m (450 min).</p>
|
||||
""",
|
||||
'help.html.break': """
|
||||
<h2>🚪 Break / Dinner</h2>
|
||||
|
||||
<h3>Break (Briefly Away)</h3>
|
||||
<p>For short absences (medical, errands), use <b>Start Break / Return</b>.</p>
|
||||
|
||||
<h3>Auto Break on Screen Lock</h3>
|
||||
<p>Enable "Auto break on screen lock" in settings — when the PC locks,
|
||||
a break starts automatically; on unlock, you return.</p>
|
||||
|
||||
<h3>Dinner</h3>
|
||||
<p>For overtime with dinner, press <b>Add Dinner</b>.</p>
|
||||
""",
|
||||
'help.html.faq': """
|
||||
<h2>❓ FAQ</h2>
|
||||
|
||||
<h3>Q. Clock-in time is wrong</h3>
|
||||
<p>Click the pencil icon next to clock-in time on the main screen to edit.</p>
|
||||
|
||||
<h3>Q. How to set 7h30m work day?</h3>
|
||||
<p>Settings → Work Time → pick the preset or enter hours/minutes directly.</p>
|
||||
|
||||
<h3>Q. Where is data stored?</h3>
|
||||
<p><code>database.db</code> in the program folder (SQLite). Daily auto-backups
|
||||
rotate in <code>~/.clockout_backups/</code>.</p>
|
||||
|
||||
""",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def set_language(lang: str) -> None:
|
||||
global _current_lang
|
||||
if lang in _DICT:
|
||||
_current_lang = lang
|
||||
|
||||
|
||||
def get_language() -> str:
|
||||
return _current_lang
|
||||
|
||||
|
||||
def tr(key: str, **kwargs) -> str:
|
||||
"""번역 조회. 키 미존재 시 ko 폴백 → 키 그대로."""
|
||||
table = _DICT.get(_current_lang, _DICT['ko'])
|
||||
text = table.get(key) or _DICT['ko'].get(key) or key
|
||||
if kwargs:
|
||||
try:
|
||||
return text.format(**kwargs)
|
||||
except (KeyError, IndexError):
|
||||
return text
|
||||
return text
|
||||
|
||||
|
||||
def tr_html(key: str) -> str:
|
||||
"""HTML 콘텐츠 조회 (HelpView용)."""
|
||||
table = _HELP_HTML.get(_current_lang, _HELP_HTML['ko'])
|
||||
return table.get(key) or _HELP_HTML['ko'].get(key) or f"<p>missing: {key}</p>"
|
||||
|
||||
|
||||
def available_languages() -> list:
|
||||
return list(_DICT.keys())
|
||||
|
||||
|
||||
def language_label(code: str) -> str:
|
||||
return {'ko': '한국어', 'en': 'English'}.get(code, code)
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
알림 시스템
|
||||
퇴근 시간 알림, 점심시간 알림 등
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from PyQt5.QtCore import QTimer, QObject, pyqtSignal
|
||||
|
||||
from core.settings_keys import (
|
||||
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_OVERTIME, NOTIF_HEALTH,
|
||||
)
|
||||
from core.i18n import tr
|
||||
|
||||
|
||||
class Notifier(QObject):
|
||||
"""알림 시스템 클래스"""
|
||||
|
||||
notification_signal = pyqtSignal(str, str) # (제목, 메시지)
|
||||
|
||||
def __init__(self, parent=None, db=None):
|
||||
super().__init__(parent)
|
||||
self.db = db # 설정 키 가드용 (None이면 모든 알림 활성)
|
||||
self.timer = QTimer()
|
||||
self.timer.timeout.connect(self.check_notifications)
|
||||
self.timer.start(60000) # 1분마다 체크
|
||||
|
||||
# 알림 상태 추적
|
||||
self.notified_30min = False
|
||||
self.notified_lunch = False
|
||||
self.notified_overtime = False
|
||||
self.notified_health = False
|
||||
self.notified_weekly = False
|
||||
self.notified_threshold = False
|
||||
|
||||
self.last_check_date = datetime.now().date()
|
||||
|
||||
def _enabled(self, key: str) -> bool:
|
||||
"""설정에서 해당 알림이 켜져 있는지. db 없으면 기본 켜짐."""
|
||||
if self.db is None:
|
||||
return True
|
||||
val = self.db.get_setting(key, 'true')
|
||||
return str(val).lower() in ('1', 'true', 'yes')
|
||||
|
||||
def check_notifications(self):
|
||||
"""알림 체크"""
|
||||
now = datetime.now()
|
||||
current_date = now.date()
|
||||
|
||||
# 날짜가 바뀌면 알림 상태 리셋
|
||||
if current_date != self.last_check_date:
|
||||
self.reset_notifications()
|
||||
self.last_check_date = current_date
|
||||
|
||||
def check_clock_out_soon(self, clock_out_time: datetime, current_time: Optional[datetime] = None):
|
||||
"""
|
||||
퇴근 30분 전 알림
|
||||
Args:
|
||||
clock_out_time: 예상 퇴근 시간
|
||||
current_time: 현재 시간 (None이면 지금)
|
||||
"""
|
||||
if current_time is None:
|
||||
current_time = datetime.now()
|
||||
|
||||
if not self._enabled(NOTIF_CLOCK_OUT):
|
||||
return
|
||||
time_diff = clock_out_time - current_time
|
||||
|
||||
# 30분 이내, 아직 알림 안 했으면
|
||||
if 0 < time_diff.total_seconds() <= 1800 and not self.notified_30min:
|
||||
minutes_left = int(time_diff.total_seconds() / 60)
|
||||
self.notification_signal.emit(
|
||||
tr('notif.clock_out_soon.title'),
|
||||
tr('notif.clock_out_soon.body', minutes=minutes_left),
|
||||
)
|
||||
self.notified_30min = True
|
||||
|
||||
def check_lunch_reminder(self, clock_in_time: datetime, lunch_enabled: bool,
|
||||
current_time: Optional[datetime] = None):
|
||||
"""
|
||||
점심시간 등록 알림
|
||||
Args:
|
||||
clock_in_time: 출근 시간
|
||||
lunch_enabled: 점심시간 등록 여부
|
||||
current_time: 현재 시간
|
||||
"""
|
||||
if current_time is None:
|
||||
current_time = datetime.now()
|
||||
if not self._enabled(NOTIF_LUNCH):
|
||||
return
|
||||
|
||||
# 이미 점심 등록했거나, 이미 알림 보냈으면 스킵
|
||||
if lunch_enabled or self.notified_lunch:
|
||||
return
|
||||
|
||||
# 출근 후 4시간 경과 (점심시간으로 추정)
|
||||
time_since_clock_in = current_time - clock_in_time
|
||||
if time_since_clock_in.total_seconds() >= 4 * 3600:
|
||||
self.notification_signal.emit(
|
||||
tr('notif.lunch_reminder.title'),
|
||||
tr('notif.lunch_reminder.body'),
|
||||
)
|
||||
self.notified_lunch = True
|
||||
|
||||
def check_overtime_earning(self, overtime_minutes: int):
|
||||
"""
|
||||
연장근무 적립 알림
|
||||
Args:
|
||||
overtime_minutes: 예상 연장근무 시간 (분)
|
||||
"""
|
||||
if not self._enabled(NOTIF_OVERTIME):
|
||||
return
|
||||
if overtime_minutes >= 30 and not self.notified_overtime:
|
||||
hours = overtime_minutes // 60
|
||||
mins = overtime_minutes % 60
|
||||
|
||||
from utils.time_format import format_hours_minutes
|
||||
time_str = format_hours_minutes(overtime_minutes, omit_zero_minutes=True)
|
||||
|
||||
self.notification_signal.emit(
|
||||
tr('notif.overtime_earning.title'),
|
||||
tr('notif.overtime_earning.body', time_str=time_str),
|
||||
)
|
||||
self.notified_overtime = True
|
||||
|
||||
def notify_overtime_threshold(self, total_overtime_hours: float):
|
||||
"""연장근무 누적 알림 (20시간 이상)"""
|
||||
if not self._enabled(NOTIF_OVERTIME):
|
||||
return
|
||||
if total_overtime_hours >= 20 and not self.notified_threshold:
|
||||
self.notification_signal.emit(
|
||||
tr('notif.overtime_threshold.title'),
|
||||
tr('notif.overtime_threshold.body', hours=total_overtime_hours),
|
||||
)
|
||||
self.notified_threshold = True
|
||||
|
||||
def notify_health_warning(self, consecutive_overtime_days: int):
|
||||
"""건강 경고 (연속 연장근무 일수)"""
|
||||
if not self._enabled(NOTIF_HEALTH):
|
||||
return
|
||||
if consecutive_overtime_days >= 3 and not self.notified_health:
|
||||
self.notification_signal.emit(
|
||||
tr('notif.health.title'),
|
||||
tr('notif.health.body', days=consecutive_overtime_days),
|
||||
)
|
||||
self.notified_health = True
|
||||
|
||||
def notify_weekly_hours(self, total_hours: float):
|
||||
"""주 52시간 경고"""
|
||||
if not self._enabled(NOTIF_HEALTH):
|
||||
return
|
||||
if total_hours > 52 and not self.notified_weekly:
|
||||
self.notification_signal.emit(
|
||||
tr('notif.weekly_52.title'),
|
||||
tr('notif.weekly_52.body', hours=total_hours),
|
||||
)
|
||||
self.notified_weekly = True
|
||||
|
||||
def reset_notifications(self):
|
||||
"""알림 상태 리셋 (날짜 변경 시)"""
|
||||
self.notified_30min = False
|
||||
self.notified_lunch = False
|
||||
self.notified_overtime = False
|
||||
self.notified_health = False
|
||||
self.notified_weekly = False
|
||||
self.notified_threshold = False
|
||||
|
||||
|
||||
# 테스트 코드
|
||||
if __name__ == "__main__":
|
||||
from PyQt5.QtWidgets import QApplication, QMessageBox
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
notifier = Notifier()
|
||||
|
||||
# 시그널 연결 (테스트)
|
||||
def show_notification(title, message):
|
||||
QMessageBox.information(None, title, message)
|
||||
|
||||
notifier.notification_signal.connect(show_notification)
|
||||
|
||||
# 테스트: 퇴근 30분 전
|
||||
clock_out = datetime.now() + timedelta(minutes=25)
|
||||
notifier.check_clock_out_soon(clock_out)
|
||||
|
||||
print("Notifier test completed")
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
설정 키 상수.
|
||||
|
||||
여러 모듈에서 raw 문자열로 쓰던 키를 단일 출처로 모음. 오타 방지·grep 용이.
|
||||
새 키 추가 시 여기에 등록 후 `init_default_settings()`에 기본값도 추가.
|
||||
"""
|
||||
|
||||
# 근무시간
|
||||
WORK_HOURS = 'work_hours'
|
||||
WORK_MINUTES = 'work_minutes'
|
||||
LUNCH_DURATION_MINUTES = 'lunch_duration_minutes'
|
||||
DINNER_DURATION_MINUTES = 'dinner_duration_minutes'
|
||||
WORKDAY_BOUNDARY_HOUR = 'workday_boundary_hour'
|
||||
|
||||
# 자동화
|
||||
AUTO_DETECT_BOOT = 'auto_detect_boot'
|
||||
AUTO_LUNCH = 'auto_lunch'
|
||||
AUTO_OVERTIME = 'auto_overtime'
|
||||
AUTO_BREAK_ON_LOCK = 'auto_break_on_lock'
|
||||
CLOCK_IN_ON_UNLOCK = 'clock_in_on_unlock' # 첫 잠금 해제를 출근으로 사용 (PC 안 끄는 사용자용)
|
||||
|
||||
# 알림
|
||||
NOTIFICATION_ENABLED = 'notification_enabled'
|
||||
NOTIFICATION_BEFORE_MINUTES = 'notification_before_minutes'
|
||||
NOTIF_CLOCK_OUT = 'notification_clock_out'
|
||||
NOTIF_LUNCH = 'notification_lunch'
|
||||
NOTIF_OVERTIME = 'notification_overtime'
|
||||
NOTIF_HEALTH = 'notification_health'
|
||||
|
||||
# 연차
|
||||
ANNUAL_LEAVE_TOTAL = 'annual_leave_total'
|
||||
ANNUAL_LEAVE_DAYS = 'annual_leave_days'
|
||||
ANNUAL_LEAVE_USED = 'annual_leave_used'
|
||||
LEAVE_BALANCE = 'leave_balance'
|
||||
INITIAL_OVERTIME_MINUTES = 'initial_overtime_minutes'
|
||||
INITIAL_LEAVE_USED_HOURS = 'initial_leave_used_hours'
|
||||
|
||||
# UI/표시
|
||||
THEME = 'theme'
|
||||
TIME_FORMAT = 'time_format'
|
||||
LANGUAGE = 'language'
|
||||
OVERTIME_UNIT = 'overtime_unit'
|
||||
|
||||
# 통합/외부
|
||||
DB_PATH_OVERRIDE = 'db_path_override'
|
||||
|
||||
# 백업
|
||||
LAST_BACKUP_DATE = 'last_backup_date'
|
||||
|
||||
# 마이그레이션 sentinel
|
||||
ANNUAL_LEAVE_KEYS_MIGRATED = 'annual_leave_keys_migrated'
|
||||
BALANCE_ADJUSTMENT_MIGRATED_V2 = 'balance_adjustment_migrated_v2'
|
||||
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
시간 계산 로직
|
||||
근무시간, 퇴근시간, 연장근무 계산
|
||||
"""
|
||||
from datetime import datetime, time, timedelta
|
||||
from typing import Tuple, Optional
|
||||
|
||||
|
||||
class TimeCalculator:
|
||||
"""근무시간 계산 클래스"""
|
||||
|
||||
def __init__(self, work_hours=None, lunch_duration_minutes: int = 60, dinner_duration_minutes: int = 60,
|
||||
work_minutes: int = None):
|
||||
"""
|
||||
Args:
|
||||
work_hours: 기본 근무시간 (시간 단위, float 허용 - 호환성용)
|
||||
work_minutes: 기본 근무시간 (분 단위, 지정 시 work_hours보다 우선)
|
||||
lunch_duration_minutes: 점심시간 (분)
|
||||
dinner_duration_minutes: 저녁시간 (분)
|
||||
"""
|
||||
if work_minutes is not None:
|
||||
self.work_minutes = int(work_minutes)
|
||||
elif work_hours is not None:
|
||||
self.work_minutes = int(round(float(work_hours) * 60))
|
||||
else:
|
||||
self.work_minutes = 480
|
||||
|
||||
self.lunch_duration_minutes = lunch_duration_minutes
|
||||
self.dinner_duration_minutes = dinner_duration_minutes
|
||||
|
||||
@property
|
||||
def work_hours(self):
|
||||
return self.work_minutes / 60.0
|
||||
|
||||
def calculate_clock_out_time(self, clock_in: datetime,
|
||||
include_lunch: bool = False,
|
||||
include_dinner: bool = False,
|
||||
break_minutes: int = 0) -> datetime:
|
||||
"""
|
||||
퇴근시간 계산
|
||||
Args:
|
||||
clock_in: 출근 시간
|
||||
include_lunch: 점심시간 포함 여부
|
||||
include_dinner: 저녁시간 포함 여부
|
||||
break_minutes: 외출 시간 (분)
|
||||
Returns:
|
||||
datetime: 예상 퇴근 시간
|
||||
"""
|
||||
total_minutes = self.work_minutes + break_minutes
|
||||
if include_lunch:
|
||||
total_minutes += self.lunch_duration_minutes
|
||||
if include_dinner:
|
||||
total_minutes += self.dinner_duration_minutes
|
||||
|
||||
clock_out = clock_in + timedelta(minutes=total_minutes)
|
||||
return clock_out
|
||||
|
||||
def calculate_remaining_time(self, clock_in: datetime,
|
||||
include_lunch: bool = False,
|
||||
include_dinner: bool = False,
|
||||
current_time: Optional[datetime] = None,
|
||||
break_minutes: int = 0) -> timedelta:
|
||||
"""
|
||||
퇴근까지 남은 시간 계산
|
||||
Args:
|
||||
clock_in: 출근 시간
|
||||
include_lunch: 점심시간 포함 여부
|
||||
include_dinner: 저녁시간 포함 여부
|
||||
current_time: 현재 시간 (None이면 지금 시간 사용)
|
||||
break_minutes: 외출 시간 (분)
|
||||
Returns:
|
||||
timedelta: 남은 시간 (음수면 이미 퇴근 시간 경과)
|
||||
"""
|
||||
if current_time is None:
|
||||
current_time = datetime.now()
|
||||
|
||||
# calculate_clock_out_time()에서 이미 break_minutes를 처리하므로
|
||||
# 여기서 중복으로 추가하지 않음
|
||||
clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner, break_minutes)
|
||||
|
||||
remaining = clock_out - current_time
|
||||
return remaining
|
||||
|
||||
def calculate_work_progress(self, clock_in: datetime,
|
||||
include_lunch: bool = False,
|
||||
include_dinner: bool = False,
|
||||
current_time: Optional[datetime] = None,
|
||||
break_minutes: int = 0,
|
||||
overtime_used_minutes: int = 0) -> float:
|
||||
"""
|
||||
근무 진행률 계산 (0.0 ~ 1.0)
|
||||
Args:
|
||||
clock_in: 출근 시간
|
||||
include_lunch: 점심시간 포함 여부
|
||||
include_dinner: 저녁시간 포함 여부
|
||||
current_time: 현재 시간
|
||||
break_minutes: 외출 시간 (분) - 필요 근무시간 증가
|
||||
overtime_used_minutes: 사용한 추가근무 (분) - 필요 근무시간 감소
|
||||
Returns:
|
||||
float: 진행률 (0.0 ~ 1.0)
|
||||
"""
|
||||
if current_time is None:
|
||||
current_time = datetime.now()
|
||||
|
||||
# 전체 필요 근무 시간 (초)
|
||||
# = 기본 근무시간 + 점심시간 + 저녁시간 + 외출시간 - 추가근무 사용시간
|
||||
base_work_seconds = self.work_minutes * 60
|
||||
lunch_seconds = self.lunch_duration_minutes * 60 if include_lunch else 0
|
||||
dinner_seconds = self.dinner_duration_minutes * 60 if include_dinner else 0
|
||||
total_work_seconds = base_work_seconds + lunch_seconds + dinner_seconds + (break_minutes * 60) - (overtime_used_minutes * 60)
|
||||
|
||||
# 경과 시간 (초)
|
||||
elapsed_seconds = (current_time - clock_in).total_seconds()
|
||||
|
||||
# 진행률 계산 (0.0 ~ 1.0 범위로 제한)
|
||||
if total_work_seconds <= 0:
|
||||
# 필요 시간이 0 이하면 100% 완료로 처리
|
||||
return 1.0
|
||||
|
||||
progress = elapsed_seconds / total_work_seconds
|
||||
return max(min(progress, 1.0), 0.0)
|
||||
|
||||
def calculate_total_work_time(self, clock_in: datetime,
|
||||
clock_out: datetime) -> float:
|
||||
"""
|
||||
총 근무시간 계산 (시간 단위)
|
||||
Args:
|
||||
clock_in: 출근 시간
|
||||
clock_out: 퇴근 시간
|
||||
Returns:
|
||||
float: 총 근무시간 (시간)
|
||||
"""
|
||||
work_duration = clock_out - clock_in
|
||||
return work_duration.total_seconds() / 3600
|
||||
|
||||
def calculate_overtime(self, clock_in: datetime, clock_out: datetime,
|
||||
include_lunch: bool = False, include_dinner: bool = False,
|
||||
break_minutes: int = 0) -> Tuple[int, int]:
|
||||
"""
|
||||
연장근무 시간 계산 (실제 시간, 30분 단위 적립)
|
||||
Args:
|
||||
clock_in: 출근 시간
|
||||
clock_out: 퇴근 시간
|
||||
include_lunch: 점심시간 포함 여부
|
||||
include_dinner: 저녁시간 포함 여부
|
||||
break_minutes: 외출 시간 (분) - 연장근무 계산에서 제외
|
||||
Returns:
|
||||
Tuple[int, int]: (실제 연장근무 분, 30분 단위 적립 분)
|
||||
"""
|
||||
expected_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner, break_minutes)
|
||||
|
||||
if clock_out <= expected_clock_out:
|
||||
return 0, 0 # 연장근무 없음
|
||||
|
||||
overtime_duration = clock_out - expected_clock_out
|
||||
overtime_minutes = int(overtime_duration.total_seconds() / 60)
|
||||
|
||||
# 30분 단위로 절삭
|
||||
overtime_earned = (overtime_minutes // 30) * 30
|
||||
|
||||
return overtime_minutes, overtime_earned
|
||||
|
||||
def format_time_delta(self, td: timedelta) -> str:
|
||||
"""
|
||||
timedelta를 HH:MM:SS 형식으로 변환
|
||||
Args:
|
||||
td: timedelta 객체
|
||||
Returns:
|
||||
str: "HH:MM:SS" 형식 문자열
|
||||
"""
|
||||
total_seconds = int(abs(td.total_seconds()))
|
||||
hours = total_seconds // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
seconds = total_seconds % 60
|
||||
|
||||
sign = "-" if td.total_seconds() < 0 else ""
|
||||
return f"{sign}{hours:02d}:{minutes:02d}:{seconds:02d}"
|
||||
|
||||
def format_overtime_tokens(self, total_minutes: int) -> Tuple[int, str]:
|
||||
"""
|
||||
연장근무 시간을 토큰 형식으로 변환
|
||||
Args:
|
||||
total_minutes: 총 연장근무 시간 (분)
|
||||
Returns:
|
||||
Tuple[int, str]: (토큰 개수, 시간 문자열)
|
||||
"""
|
||||
tokens = total_minutes // 30 # 30분 = 1토큰
|
||||
hours = total_minutes // 60
|
||||
minutes = total_minutes % 60
|
||||
|
||||
if hours > 0 and minutes > 0:
|
||||
time_str = f"{hours}시간 {minutes}분"
|
||||
elif hours > 0:
|
||||
time_str = f"{hours}시간"
|
||||
else:
|
||||
time_str = f"{minutes}분"
|
||||
|
||||
return tokens, time_str
|
||||
|
||||
def parse_time_string(self, time_str: str) -> datetime:
|
||||
"""
|
||||
시간 문자열을 datetime으로 변환
|
||||
Args:
|
||||
time_str: "HH:MM:SS" 또는 "HH:MM" 형식
|
||||
Returns:
|
||||
datetime: 오늘 날짜 + 해당 시간
|
||||
"""
|
||||
try:
|
||||
# "HH:MM:SS" 형식
|
||||
if len(time_str.split(':')) == 3:
|
||||
t = datetime.strptime(time_str, "%H:%M:%S").time()
|
||||
else:
|
||||
# "HH:MM" 형식
|
||||
t = datetime.strptime(time_str, "%H:%M").time()
|
||||
|
||||
return datetime.combine(datetime.today(), t)
|
||||
except ValueError as e:
|
||||
raise ValueError(f"잘못된 시간 형식: {time_str}") from e
|
||||
|
||||
def is_overtime_needed(self, clock_in: datetime,
|
||||
target_overtime_minutes: int,
|
||||
include_lunch: bool = False,
|
||||
include_dinner: bool = False) -> datetime:
|
||||
"""
|
||||
특정 연장근무 시간을 채우기 위한 퇴근 시간 계산
|
||||
Args:
|
||||
clock_in: 출근 시간
|
||||
target_overtime_minutes: 목표 연장근무 시간 (분)
|
||||
include_lunch: 점심시간 포함 여부
|
||||
include_dinner: 저녁시간 포함 여부
|
||||
Returns:
|
||||
datetime: 목표 달성을 위한 퇴근 시간
|
||||
"""
|
||||
normal_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner)
|
||||
return normal_clock_out + timedelta(minutes=target_overtime_minutes)
|
||||
|
||||
def is_weekend(self, date_obj: datetime) -> bool:
|
||||
"""
|
||||
주말 여부 확인
|
||||
Args:
|
||||
date_obj: 확인할 날짜
|
||||
Returns:
|
||||
bool: 토요일(5) 또는 일요일(6)이면 True
|
||||
"""
|
||||
return date_obj.weekday() in [5, 6]
|
||||
|
||||
def is_holiday(self, date_obj: datetime, db=None) -> bool:
|
||||
"""
|
||||
공휴일 여부 확인
|
||||
Args:
|
||||
date_obj: 확인할 날짜
|
||||
db: Database 인스턴스 (None이면 False 반환)
|
||||
Returns:
|
||||
bool: 공휴일이면 True
|
||||
"""
|
||||
if db is None:
|
||||
return False
|
||||
date_str = date_obj.strftime("%Y-%m-%d")
|
||||
return db.is_holiday(date_str)
|
||||
|
||||
def is_non_working_day(self, date_obj: datetime, db=None) -> bool:
|
||||
"""
|
||||
비근무일 여부 확인 (주말 또는 공휴일)
|
||||
Args:
|
||||
date_obj: 확인할 날짜
|
||||
db: Database 인스턴스 (공휴일 체크용)
|
||||
Returns:
|
||||
bool: 주말 또는 공휴일이면 True
|
||||
"""
|
||||
if self.is_weekend(date_obj):
|
||||
return True
|
||||
return self.is_holiday(date_obj, db)
|
||||
|
||||
def get_day_type(self, date_obj: datetime, db=None) -> str:
|
||||
"""
|
||||
근무일 유형 반환
|
||||
Args:
|
||||
date_obj: 확인할 날짜
|
||||
db: Database 인스턴스
|
||||
Returns:
|
||||
str: 'weekend', 'holiday', 'normal' 중 하나
|
||||
"""
|
||||
if self.is_weekend(date_obj):
|
||||
return 'weekend'
|
||||
if self.is_holiday(date_obj, db):
|
||||
return 'holiday'
|
||||
return 'normal'
|
||||
|
||||
|
||||
# 테스트 코드
|
||||
if __name__ == "__main__":
|
||||
calc = TimeCalculator(work_hours=8, lunch_duration_minutes=60)
|
||||
|
||||
print("=== 시간 계산기 테스트 ===\n")
|
||||
|
||||
# 출근 시간 설정
|
||||
clock_in = datetime.now().replace(hour=9, minute=0, second=0, microsecond=0)
|
||||
print(f"출근 시간: {clock_in.strftime('%H:%M:%S')}")
|
||||
|
||||
# 퇴근 시간 계산 (점심 없음)
|
||||
clock_out_no_lunch = calc.calculate_clock_out_time(clock_in, include_lunch=False)
|
||||
print(f"퇴근 시간 (점심 제외): {clock_out_no_lunch.strftime('%H:%M:%S')}")
|
||||
|
||||
# 퇴근 시간 계산 (점심 포함)
|
||||
clock_out_with_lunch = calc.calculate_clock_out_time(clock_in, include_lunch=True)
|
||||
print(f"퇴근 시간 (점심 포함): {clock_out_with_lunch.strftime('%H:%M:%S')}")
|
||||
|
||||
# 현재 시간 기준 남은 시간
|
||||
remaining = calc.calculate_remaining_time(clock_in, include_lunch=True)
|
||||
print(f"\n남은 시간: {calc.format_time_delta(remaining)}")
|
||||
|
||||
# 진행률
|
||||
progress = calc.calculate_work_progress(clock_in, include_lunch=True)
|
||||
print(f"진행률: {progress * 100:.1f}%")
|
||||
|
||||
# 연장근무 계산
|
||||
actual_clock_out = clock_out_with_lunch + timedelta(hours=2, minutes=15)
|
||||
overtime_actual, overtime_earned = calc.calculate_overtime(
|
||||
clock_in, actual_clock_out, include_lunch=True
|
||||
)
|
||||
print(f"\n실제 퇴근: {actual_clock_out.strftime('%H:%M:%S')}")
|
||||
print(f"연장근무: {overtime_actual}분 (적립: {overtime_earned}분)")
|
||||
|
||||
# 토큰 형식
|
||||
tokens, time_str = calc.format_overtime_tokens(overtime_earned)
|
||||
print(f"토큰: 🕐 × {tokens} ({time_str})")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
앱 버전 상수.
|
||||
|
||||
릴리스 시 이 값을 올린 후 git tag → push.
|
||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||
"""
|
||||
__version__ = '2.2.0'
|
||||
Reference in New Issue
Block a user