KINDNICK bedbb1e9ec
Some checks failed
CI / test (push) Has been cancelled
Initial release v2.2.0
핵심 기능:
- 단축근무·표준·반일 등 다양한 근무 패턴 (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>
2026-04-30 12:54:40 +09:00

188 lines
6.5 KiB
Python

"""
알림 시스템
퇴근 시간 알림, 점심시간 알림 등
"""
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")