Added — 도전과제 시스템 (153개 자동 평가) - core/achievements.py: 16개 카테고리, 5단계 등급, 시크릿 9개, 메타 도전과제 - ui/achievements_view.py: 4탭 다이얼로그 (전체/진행중/완료/시크릿) - 5분 throttle 자동 평가 + 시스템 알림 + Discord embed push - achievements 테이블 확장 (code/category/tier/is_secret/progress/target) - hire_date 자동 추적, 뷰 진입 카운터 8개 settings 키 Changed — 다크 테마 디자인 리뉴얼 - ui/dark_components.py: 재사용 가능한 다크 컴포넌트 (header/card/button/progress) - 통계/도움말/도전과제 다이얼로그 일관 다크 톤 - matplotlib 차트 다크 테마 적용 (figure/axes/grid/legend) - 등급별 카드 그라디언트 (브론즈/실버/골드/플래티넘/레전드) Fixed — 안정성·일관성 - 타임존 자정 경계 버그 (has_notification_today UTC vs localtime mismatch) - DB 연결 누수: _conn() 컨텍스트 매니저 도입, 40+ 메서드 변환 - DB 이중 부트스트랩 제거 (MainWindow(db=None) 옵션 인자) - crash_handler 다단계 폴백 (DB → 파일 → stderr) - updater PID race: 지수 backoff 재시도 (총 ~9초) - Discord URL 형식 검증 (snowflake regex) - 일일 보고서 저녁 섹션, MealTimeDialog 출/퇴근 범위 검증 - check_dinner_reminder 신규, 알림 임계값 5개 설정화 - closeEvent timer/notifier 정리 (aboutToQuit hook) - 마이그레이션 12개 모두 _conn() + try/finally - DB 인덱스 5개 추가 (break/overtime/leave date) Tests - pytest 116/116 PASS, 통합 시나리오 48/48 PASS Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
245 lines
9.6 KiB
Python
245 lines
9.6 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_DINNER, NOTIF_OVERTIME, NOTIF_HEALTH,
|
|
LUNCH_REMINDER_HOURS, DINNER_REMINDER_HOURS,
|
|
OVERTIME_THRESHOLD_HOURS, WEEKLY_HOURS_THRESHOLD, HEALTH_CONSECUTIVE_OT_DAYS,
|
|
OVERTIME_UNIT,
|
|
)
|
|
from core.i18n import tr
|
|
|
|
|
|
def _get_int_setting(db, key: str, default: int, lo: int, hi: int) -> int:
|
|
"""db에서 정수 설정값을 안전하게 읽어 [lo, hi]로 클램프."""
|
|
if db is None:
|
|
return default
|
|
try:
|
|
v = int(db.get_setting(key, str(default)) or default)
|
|
except (ValueError, TypeError):
|
|
v = default
|
|
return max(lo, min(hi, v))
|
|
|
|
|
|
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_dinner = 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
|
|
|
|
# 사용자 설정 N분 이내 알림 (기본 30, 설정에서 1~120 범위)
|
|
threshold_min = 30
|
|
if self.db is not None:
|
|
try:
|
|
threshold_min = int(self.db.get_setting('notification_before_minutes', '30') or '30')
|
|
threshold_min = max(1, min(120, threshold_min))
|
|
except (ValueError, TypeError):
|
|
threshold_min = 30
|
|
threshold_sec = threshold_min * 60
|
|
|
|
if 0 < time_diff.total_seconds() <= threshold_sec 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):
|
|
"""점심시간 등록 알림. 출근 후 LUNCH_REMINDER_HOURS 경과 시."""
|
|
if current_time is None:
|
|
current_time = datetime.now()
|
|
if not self._enabled(NOTIF_LUNCH):
|
|
return
|
|
if lunch_enabled or self.notified_lunch:
|
|
return
|
|
|
|
threshold_hours = _get_int_setting(self.db, LUNCH_REMINDER_HOURS, 4, 1, 12)
|
|
if (current_time - clock_in_time).total_seconds() >= threshold_hours * 3600:
|
|
self.notification_signal.emit(
|
|
tr('notif.lunch_reminder.title'),
|
|
tr('notif.lunch_reminder.body'),
|
|
)
|
|
self.notified_lunch = True
|
|
|
|
def check_dinner_reminder(self, clock_in_time: datetime, dinner_enabled: bool,
|
|
current_time: Optional[datetime] = None):
|
|
"""저녁시간 등록 알림. 출근 후 DINNER_REMINDER_HOURS 경과 시.
|
|
|
|
야근(연장근무) 사용자가 저녁을 깜빡 잊는 패턴 대응.
|
|
"""
|
|
if current_time is None:
|
|
current_time = datetime.now()
|
|
if not self._enabled(NOTIF_DINNER):
|
|
return
|
|
if dinner_enabled or self.notified_dinner:
|
|
return
|
|
|
|
threshold_hours = _get_int_setting(self.db, DINNER_REMINDER_HOURS, 8, 1, 16)
|
|
if (current_time - clock_in_time).total_seconds() >= threshold_hours * 3600:
|
|
self.notification_signal.emit(
|
|
tr('notif.dinner_reminder.title'),
|
|
tr('notif.dinner_reminder.body'),
|
|
)
|
|
self.notified_dinner = True
|
|
|
|
def check_overtime_earning(self, overtime_minutes: int):
|
|
"""연장근무 적립 알림. OVERTIME_UNIT 이상 적립 예정 시 한 번."""
|
|
if not self._enabled(NOTIF_OVERTIME):
|
|
return
|
|
# overtime_unit 설정값을 임계로 사용 (15/30/60 — 사용자가 선택한 단위)
|
|
unit = _get_int_setting(self.db, OVERTIME_UNIT, 30, 1, 240)
|
|
if overtime_minutes >= unit and not self.notified_overtime:
|
|
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):
|
|
"""연장근무 누적 알림 (OVERTIME_THRESHOLD_HOURS 이상)"""
|
|
if not self._enabled(NOTIF_OVERTIME):
|
|
return
|
|
threshold = _get_int_setting(self.db, OVERTIME_THRESHOLD_HOURS, 20, 1, 200)
|
|
if total_overtime_hours >= threshold 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):
|
|
"""건강 경고 (연속 연장근무 HEALTH_CONSECUTIVE_OT_DAYS일 이상)"""
|
|
if not self._enabled(NOTIF_HEALTH):
|
|
return
|
|
threshold = _get_int_setting(self.db, HEALTH_CONSECUTIVE_OT_DAYS, 3, 1, 14)
|
|
if consecutive_overtime_days >= threshold 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):
|
|
"""주 X시간 경고 (WEEKLY_HOURS_THRESHOLD)"""
|
|
if not self._enabled(NOTIF_HEALTH):
|
|
return
|
|
threshold = _get_int_setting(self.db, WEEKLY_HOURS_THRESHOLD, 52, 20, 168)
|
|
if total_hours > threshold 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 check_health_break(self, clock_in_time, break_minutes: int, current_time=None):
|
|
"""장시간 연속 근무 휴식 알림.
|
|
|
|
조건: HEALTH_BREAK_ENABLED=true, (출근 경과 - 외출시간) >= HEALTH_BREAK_HOURS,
|
|
오늘 미발송. 5분 throttle은 호출자(NotificationOrchestrator)에서.
|
|
"""
|
|
if current_time is None:
|
|
current_time = datetime.now()
|
|
if self.db is None:
|
|
return
|
|
if not self._enabled('health_break_enabled'):
|
|
return
|
|
if self.db.has_notification_today('system', 'health_break'):
|
|
return
|
|
threshold_hours = _get_int_setting(self.db, 'health_break_hours', 4, 1, 12)
|
|
elapsed_sec = (current_time - clock_in_time).total_seconds() - break_minutes * 60
|
|
if elapsed_sec >= threshold_hours * 3600:
|
|
self.notification_signal.emit(
|
|
tr('notif.health_break.title'),
|
|
tr('notif.health_break.body', hours=threshold_hours),
|
|
)
|
|
self.db.log_notification('system', 'health_break')
|
|
|
|
def reset_notifications(self):
|
|
"""알림 상태 리셋 (날짜 변경 시)"""
|
|
self.notified_30min = False
|
|
self.notified_lunch = False
|
|
self.notified_dinner = 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")
|