v2.2.4: implement auto_overtime + overtime_unit + notif_before_minutes; remove 3 dead settings
Implemented (previously UI-only with no business effect): - auto_overtime: when OFF, prompt user on clock-out before banking - overtime_unit: 15/30/60-min truncation choice now actually applied - notification_before_minutes: 30-min hardcode -> user-configurable 1~120 Removed dead keys (no readers in business logic): - auto_detect_boot, notification_enabled, annual_leave_used Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b91b229731
commit
14d88656fe
20
CHANGELOG.md
20
CHANGELOG.md
@ -4,6 +4,26 @@ 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.2.4] — 2026-04-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`auto_overtime` 옵션 실제 동작** — 기존엔 UI만 있고 동작 안 했음
|
||||||
|
- **OFF**: 퇴근 시 적립 가능한 분이 있으면 "적립할까요?" 다이얼로그 표시 (Y/N)
|
||||||
|
- **ON** (기본): 자동 적립 (이전과 동일)
|
||||||
|
- **`overtime_unit` 실제 동작** — 30/15/60분 단위 적립 선택 반영
|
||||||
|
- 기존엔 무조건 30분 절삭. 이제 설정값 사용 (`time_calculator.calculate_overtime` `unit_minutes` 파라미터)
|
||||||
|
- **퇴근 알림 시점 사용자 설정** (`notification_before_minutes`)
|
||||||
|
- 기존 30분 하드코드 → 1~120분 SpinBox로 사용자 지정
|
||||||
|
- 설정 → 알림 그룹에 "퇴근 알림 시점" SpinBox
|
||||||
|
|
||||||
|
### Removed (죽은 옵션 정리)
|
||||||
|
- `auto_detect_boot` — 부팅 감지 비활성화 모드. 어디서도 안 쓰임
|
||||||
|
- `notification_enabled` — 마스터 스위치. 4개 개별 NOTIF_* 키로 충분
|
||||||
|
- `annual_leave_used` — `leave_balance`로 대체된 레거시 카운터
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- 죽은 옵션이 사용자에게 "동작하는 것처럼" 보이던 신뢰성 문제 일괄 해소
|
||||||
|
|
||||||
## [2.2.3] — 2026-04-30
|
## [2.2.3] — 2026-04-30
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@ -520,21 +520,19 @@ class Database:
|
|||||||
'work_minutes': '480',
|
'work_minutes': '480',
|
||||||
'lunch_duration_minutes': '60',
|
'lunch_duration_minutes': '60',
|
||||||
'dinner_duration_minutes': '60',
|
'dinner_duration_minutes': '60',
|
||||||
'auto_detect_boot': 'true',
|
|
||||||
'auto_lunch': 'false',
|
'auto_lunch': 'false',
|
||||||
|
'auto_overtime': 'true',
|
||||||
'theme': 'light',
|
'theme': 'light',
|
||||||
'notification_enabled': 'true',
|
|
||||||
'notification_before_minutes': '30',
|
'notification_before_minutes': '30',
|
||||||
'notification_clock_out': 'true',
|
'notification_clock_out': 'true',
|
||||||
'notification_lunch': 'true',
|
'notification_lunch': 'true',
|
||||||
'notification_overtime': 'true',
|
'notification_overtime': 'true',
|
||||||
'notification_health': 'true',
|
'notification_health': 'true',
|
||||||
'annual_leave_total': '15',
|
'annual_leave_total': '15',
|
||||||
'annual_leave_days': '15', # UI에서 사용하는 키 (annual_leave_total과 동기화)
|
'annual_leave_days': '15', # annual_leave_total과 자동 동기화
|
||||||
'annual_leave_used': '0',
|
|
||||||
'workday_boundary_hour': '6',
|
'workday_boundary_hour': '6',
|
||||||
'overtime_unit': '30',
|
'overtime_unit': '30',
|
||||||
'time_format': '24'
|
'time_format': '24',
|
||||||
}
|
}
|
||||||
|
|
||||||
conn = self.get_connection()
|
conn = self.get_connection()
|
||||||
|
|||||||
@ -65,8 +65,17 @@ class Notifier(QObject):
|
|||||||
return
|
return
|
||||||
time_diff = clock_out_time - current_time
|
time_diff = clock_out_time - current_time
|
||||||
|
|
||||||
# 30분 이내, 아직 알림 안 했으면
|
# 사용자 설정 N분 이내 알림 (기본 30, 설정에서 1~120 범위)
|
||||||
if 0 < time_diff.total_seconds() <= 1800 and not self.notified_30min:
|
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)
|
minutes_left = int(time_diff.total_seconds() / 60)
|
||||||
self.notification_signal.emit(
|
self.notification_signal.emit(
|
||||||
tr('notif.clock_out_soon.title'),
|
tr('notif.clock_out_soon.title'),
|
||||||
|
|||||||
@ -13,14 +13,12 @@ DINNER_DURATION_MINUTES = 'dinner_duration_minutes'
|
|||||||
WORKDAY_BOUNDARY_HOUR = 'workday_boundary_hour'
|
WORKDAY_BOUNDARY_HOUR = 'workday_boundary_hour'
|
||||||
|
|
||||||
# 자동화
|
# 자동화
|
||||||
AUTO_DETECT_BOOT = 'auto_detect_boot'
|
|
||||||
AUTO_LUNCH = 'auto_lunch'
|
AUTO_LUNCH = 'auto_lunch'
|
||||||
AUTO_OVERTIME = 'auto_overtime'
|
AUTO_OVERTIME = 'auto_overtime'
|
||||||
AUTO_BREAK_ON_LOCK = 'auto_break_on_lock'
|
AUTO_BREAK_ON_LOCK = 'auto_break_on_lock'
|
||||||
CLOCK_IN_ON_UNLOCK = 'clock_in_on_unlock' # 첫 잠금 해제를 출근으로 사용 (PC 안 끄는 사용자용)
|
CLOCK_IN_ON_UNLOCK = 'clock_in_on_unlock' # 첫 잠금 해제를 출근으로 사용 (PC 안 끄는 사용자용)
|
||||||
|
|
||||||
# 알림
|
# 알림
|
||||||
NOTIFICATION_ENABLED = 'notification_enabled'
|
|
||||||
NOTIFICATION_BEFORE_MINUTES = 'notification_before_minutes'
|
NOTIFICATION_BEFORE_MINUTES = 'notification_before_minutes'
|
||||||
NOTIF_CLOCK_OUT = 'notification_clock_out'
|
NOTIF_CLOCK_OUT = 'notification_clock_out'
|
||||||
NOTIF_LUNCH = 'notification_lunch'
|
NOTIF_LUNCH = 'notification_lunch'
|
||||||
@ -30,7 +28,6 @@ NOTIF_HEALTH = 'notification_health'
|
|||||||
# 연차
|
# 연차
|
||||||
ANNUAL_LEAVE_TOTAL = 'annual_leave_total'
|
ANNUAL_LEAVE_TOTAL = 'annual_leave_total'
|
||||||
ANNUAL_LEAVE_DAYS = 'annual_leave_days'
|
ANNUAL_LEAVE_DAYS = 'annual_leave_days'
|
||||||
ANNUAL_LEAVE_USED = 'annual_leave_used'
|
|
||||||
LEAVE_BALANCE = 'leave_balance'
|
LEAVE_BALANCE = 'leave_balance'
|
||||||
INITIAL_OVERTIME_MINUTES = 'initial_overtime_minutes'
|
INITIAL_OVERTIME_MINUTES = 'initial_overtime_minutes'
|
||||||
INITIAL_LEAVE_USED_HOURS = 'initial_leave_used_hours'
|
INITIAL_LEAVE_USED_HOURS = 'initial_leave_used_hours'
|
||||||
|
|||||||
@ -135,17 +135,19 @@ class TimeCalculator:
|
|||||||
|
|
||||||
def calculate_overtime(self, clock_in: datetime, clock_out: datetime,
|
def calculate_overtime(self, clock_in: datetime, clock_out: datetime,
|
||||||
include_lunch: bool = False, include_dinner: bool = False,
|
include_lunch: bool = False, include_dinner: bool = False,
|
||||||
break_minutes: int = 0) -> Tuple[int, int]:
|
break_minutes: int = 0,
|
||||||
|
unit_minutes: int = 30) -> Tuple[int, int]:
|
||||||
"""
|
"""
|
||||||
연장근무 시간 계산 (실제 시간, 30분 단위 적립)
|
연장근무 시간 계산 (실제 시간, unit_minutes 단위 적립)
|
||||||
Args:
|
Args:
|
||||||
clock_in: 출근 시간
|
clock_in: 출근 시간
|
||||||
clock_out: 퇴근 시간
|
clock_out: 퇴근 시간
|
||||||
include_lunch: 점심시간 포함 여부
|
include_lunch: 점심시간 포함 여부
|
||||||
include_dinner: 저녁시간 포함 여부
|
include_dinner: 저녁시간 포함 여부
|
||||||
break_minutes: 외출 시간 (분) - 연장근무 계산에서 제외
|
break_minutes: 외출 시간 (분) - 연장근무 계산에서 제외
|
||||||
|
unit_minutes: 적립 단위 (분, 기본 30). 사용자 설정 OVERTIME_UNIT.
|
||||||
Returns:
|
Returns:
|
||||||
Tuple[int, int]: (실제 연장근무 분, 30분 단위 적립 분)
|
Tuple[int, int]: (실제 연장근무 분, unit_minutes 단위 적립 분)
|
||||||
"""
|
"""
|
||||||
expected_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner, break_minutes)
|
expected_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner, break_minutes)
|
||||||
|
|
||||||
@ -155,8 +157,8 @@ class TimeCalculator:
|
|||||||
overtime_duration = clock_out - expected_clock_out
|
overtime_duration = clock_out - expected_clock_out
|
||||||
overtime_minutes = int(overtime_duration.total_seconds() / 60)
|
overtime_minutes = int(overtime_duration.total_seconds() / 60)
|
||||||
|
|
||||||
# 30분 단위로 절삭
|
unit = unit_minutes if unit_minutes > 0 else 30
|
||||||
overtime_earned = (overtime_minutes // 30) * 30
|
overtime_earned = (overtime_minutes // unit) * unit
|
||||||
|
|
||||||
return overtime_minutes, overtime_earned
|
return overtime_minutes, overtime_earned
|
||||||
|
|
||||||
|
|||||||
@ -4,4 +4,4 @@
|
|||||||
릴리스 시 이 값을 올린 후 git tag → push.
|
릴리스 시 이 값을 올린 후 git tag → push.
|
||||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||||
"""
|
"""
|
||||||
__version__ = '2.2.3'
|
__version__ = '2.2.4'
|
||||||
|
|||||||
@ -8,7 +8,10 @@ import pytest
|
|||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
from utils.updater_client import _parse_version, is_newer, RELEASES_API
|
from utils.updater_client import (
|
||||||
|
_parse_version, is_newer, RELEASES_API,
|
||||||
|
UP_TO_DATE, NETWORK_ERROR, NO_RELEASE, NO_ASSET,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestVersionParsing:
|
class TestVersionParsing:
|
||||||
@ -59,6 +62,31 @@ class TestApiUrl:
|
|||||||
importlib.reload(updater_client)
|
importlib.reload(updater_client)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckForUpdate:
|
||||||
|
"""check_for_update returns (info, reason) tuple."""
|
||||||
|
|
||||||
|
def test_returns_tuple(self, monkeypatch):
|
||||||
|
# 네트워크 호출이 실패하도록 잘못된 URL
|
||||||
|
monkeypatch.setenv('CLOCKOUT_RELEASES_API', 'http://127.0.0.1:1/nope')
|
||||||
|
import importlib
|
||||||
|
from utils import updater_client
|
||||||
|
importlib.reload(updater_client)
|
||||||
|
try:
|
||||||
|
result = updater_client.check_for_update('1.0.0', timeout=1)
|
||||||
|
assert isinstance(result, tuple) and len(result) == 2
|
||||||
|
info, reason = result
|
||||||
|
assert info is None
|
||||||
|
assert reason == updater_client.NETWORK_ERROR
|
||||||
|
finally:
|
||||||
|
monkeypatch.delenv('CLOCKOUT_RELEASES_API', raising=False)
|
||||||
|
importlib.reload(updater_client)
|
||||||
|
|
||||||
|
def test_constants_distinct(self):
|
||||||
|
# 4개 상수가 모두 서로 다른 값
|
||||||
|
values = {UP_TO_DATE, NETWORK_ERROR, NO_RELEASE, NO_ASSET}
|
||||||
|
assert len(values) == 4
|
||||||
|
|
||||||
|
|
||||||
class TestUpdaterScript:
|
class TestUpdaterScript:
|
||||||
"""updater.py 자체 로직."""
|
"""updater.py 자체 로직."""
|
||||||
|
|
||||||
|
|||||||
@ -1081,18 +1081,21 @@ class MainWindow(QMainWindow):
|
|||||||
# 오늘의 외출 시간 가져오기
|
# 오늘의 외출 시간 가져오기
|
||||||
break_minutes = self.db.get_total_break_minutes_today()
|
break_minutes = self.db.get_total_break_minutes_today()
|
||||||
|
|
||||||
|
# 적립 단위(분) — 사용자 설정. 기본 30, 옵션 15/60.
|
||||||
|
unit_minutes = self.db.get_setting_int('overtime_unit', 30)
|
||||||
|
if unit_minutes not in (15, 30, 60):
|
||||||
|
unit_minutes = 30
|
||||||
|
|
||||||
if is_non_working_day:
|
if is_non_working_day:
|
||||||
# 주말/공휴일: 모든 시간을 연장근무로 처리 (외출 시간 제외)
|
# 주말/공휴일: 모든 시간을 연장근무로 처리 (외출 시간 제외)
|
||||||
work_minutes = int(total_hours * 60)
|
work_minutes = int(total_hours * 60)
|
||||||
if self.lunch_break_enabled:
|
if self.lunch_break_enabled:
|
||||||
work_minutes -= self.time_calc.lunch_duration_minutes # 점심시간 제외
|
work_minutes -= self.time_calc.lunch_duration_minutes
|
||||||
if self.dinner_break_enabled:
|
if self.dinner_break_enabled:
|
||||||
work_minutes -= self.time_calc.dinner_duration_minutes # 저녁시간 제외
|
work_minutes -= self.time_calc.dinner_duration_minutes
|
||||||
work_minutes -= break_minutes # 외출시간 제외
|
work_minutes -= break_minutes
|
||||||
# 음수 방지
|
|
||||||
work_minutes = max(0, work_minutes)
|
work_minutes = max(0, work_minutes)
|
||||||
# 30분 단위로 절삭
|
overtime_earned = (work_minutes // unit_minutes) * unit_minutes
|
||||||
overtime_earned = (work_minutes // 30) * 30
|
|
||||||
overtime_actual = work_minutes
|
overtime_actual = work_minutes
|
||||||
else:
|
else:
|
||||||
# 평일: 정상 연장근무 계산 (외출 시간 포함)
|
# 평일: 정상 연장근무 계산 (외출 시간 포함)
|
||||||
@ -1100,9 +1103,27 @@ class MainWindow(QMainWindow):
|
|||||||
self.clock_in_time, now,
|
self.clock_in_time, now,
|
||||||
include_lunch=self.lunch_break_enabled,
|
include_lunch=self.lunch_break_enabled,
|
||||||
include_dinner=self.dinner_break_enabled,
|
include_dinner=self.dinner_break_enabled,
|
||||||
break_minutes=break_minutes
|
break_minutes=break_minutes,
|
||||||
|
unit_minutes=unit_minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# AUTO_OVERTIME 가드: 자동 적립 OFF + 적립할 게 있으면 사용자에게 확인
|
||||||
|
auto_overtime = self.db.get_setting_bool('auto_overtime', True)
|
||||||
|
if not auto_overtime and overtime_earned > 0:
|
||||||
|
from utils.time_format import format_hours_minutes
|
||||||
|
time_str = format_hours_minutes(overtime_earned, omit_zero_minutes=True)
|
||||||
|
actual_str = format_hours_minutes(overtime_actual, omit_zero_minutes=True)
|
||||||
|
ask = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"연장근무 적립 확인",
|
||||||
|
f"연장근무 {actual_str} 발생, {time_str} 적립 대상입니다.\n\n"
|
||||||
|
f"적립하시겠습니까?\n"
|
||||||
|
f"(아니오 선택 시 이번 퇴근분은 적립되지 않습니다)",
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
)
|
||||||
|
if ask != QMessageBox.Yes:
|
||||||
|
overtime_earned = 0 # 적립 스킵 (overtime_actual은 기록용으로 유지)
|
||||||
|
|
||||||
# DB 업데이트
|
# DB 업데이트
|
||||||
today = datetime.now().date().isoformat()
|
today = datetime.now().date().isoformat()
|
||||||
clock_out_str = now.strftime("%H:%M:%S")
|
clock_out_str = now.strftime("%H:%M:%S")
|
||||||
@ -1601,16 +1622,30 @@ class MainWindow(QMainWindow):
|
|||||||
def check_for_updates(self, silent: bool = False):
|
def check_for_updates(self, silent: bool = False):
|
||||||
"""업데이트 확인. silent=True면 새 버전 있을 때만 알림 (시작 시 자동 체크용)."""
|
"""업데이트 확인. silent=True면 새 버전 있을 때만 알림 (시작 시 자동 체크용)."""
|
||||||
from core.version import __version__
|
from core.version import __version__
|
||||||
from utils.updater_client import check_for_update, download_update, apply_update
|
from utils.updater_client import (
|
||||||
|
check_for_update, download_update, apply_update,
|
||||||
info = check_for_update(__version__)
|
UP_TO_DATE, NETWORK_ERROR, NO_RELEASE, NO_ASSET,
|
||||||
if info is None:
|
|
||||||
if not silent:
|
|
||||||
QMessageBox.information(
|
|
||||||
self,
|
|
||||||
"업데이트 확인",
|
|
||||||
f"현재 최신 버전입니다 (v{__version__})."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
info, reason = check_for_update(__version__)
|
||||||
|
if info is None:
|
||||||
|
if silent:
|
||||||
|
return
|
||||||
|
# 사용자가 명시적으로 트리거한 경우만 메시지 표시
|
||||||
|
messages = {
|
||||||
|
UP_TO_DATE: ("업데이트 확인", f"현재 최신 버전입니다 (v{__version__})."),
|
||||||
|
NETWORK_ERROR: ("연결 실패",
|
||||||
|
"업데이트 서버에 연결할 수 없습니다.\n"
|
||||||
|
"네트워크 상태를 확인해 주세요."),
|
||||||
|
NO_RELEASE: ("릴리스 없음",
|
||||||
|
"업데이트 저장소에서 릴리스를 찾을 수 없습니다.\n"
|
||||||
|
"(저장소 비공개 또는 첫 릴리스 전)"),
|
||||||
|
NO_ASSET: ("자산 누락",
|
||||||
|
"새 버전은 있지만 다운로드 가능한 main.exe 자산이 없습니다.\n"
|
||||||
|
"관리자에게 문의하세요."),
|
||||||
|
}
|
||||||
|
title, body = messages.get(reason, ("업데이트 확인", "알 수 없는 응답입니다."))
|
||||||
|
QMessageBox.information(self, title, body)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 빌드된 환경이 아니면 (개발 .py) 실제 적용 불가 — 알림만
|
# 빌드된 환경이 아니면 (개발 .py) 실제 적용 불가 — 알림만
|
||||||
|
|||||||
@ -18,6 +18,7 @@ from core.settings_keys import (
|
|||||||
WORK_HOURS, WORK_MINUTES, LUNCH_DURATION_MINUTES, DINNER_DURATION_MINUTES,
|
WORK_HOURS, WORK_MINUTES, LUNCH_DURATION_MINUTES, DINNER_DURATION_MINUTES,
|
||||||
AUTO_LUNCH, AUTO_BREAK_ON_LOCK, AUTO_OVERTIME,
|
AUTO_LUNCH, AUTO_BREAK_ON_LOCK, AUTO_OVERTIME,
|
||||||
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_OVERTIME, NOTIF_HEALTH,
|
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_OVERTIME, NOTIF_HEALTH,
|
||||||
|
NOTIFICATION_BEFORE_MINUTES,
|
||||||
THEME, TIME_FORMAT, LANGUAGE, OVERTIME_UNIT, ANNUAL_LEAVE_DAYS,
|
THEME, TIME_FORMAT, LANGUAGE, OVERTIME_UNIT, ANNUAL_LEAVE_DAYS,
|
||||||
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
|
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
|
||||||
DB_PATH_OVERRIDE, CLOCK_IN_ON_UNLOCK,
|
DB_PATH_OVERRIDE, CLOCK_IN_ON_UNLOCK,
|
||||||
@ -275,6 +276,22 @@ class SettingsView(QDialog):
|
|||||||
check_row2.addWidget(self.health_notification_check)
|
check_row2.addWidget(self.health_notification_check)
|
||||||
layout.addLayout(check_row2)
|
layout.addLayout(check_row2)
|
||||||
|
|
||||||
|
# 퇴근 N분 전 알림 시점 설정
|
||||||
|
before_row = QHBoxLayout()
|
||||||
|
before_label = QLabel("퇴근 알림 시점:")
|
||||||
|
before_label.setFixedWidth(110)
|
||||||
|
self.notif_before_spin = QSpinBox()
|
||||||
|
self.notif_before_spin.setRange(1, 120)
|
||||||
|
self.notif_before_spin.setSingleStep(5)
|
||||||
|
self.notif_before_spin.setValue(30)
|
||||||
|
self.notif_before_spin.setSuffix(" 분 전")
|
||||||
|
self.notif_before_spin.setFixedWidth(110)
|
||||||
|
self.notif_before_spin.setToolTip("퇴근 임박 알림이 표시될 시점 (분 단위)")
|
||||||
|
before_row.addWidget(before_label)
|
||||||
|
before_row.addWidget(self.notif_before_spin)
|
||||||
|
before_row.addStretch()
|
||||||
|
layout.addLayout(before_row)
|
||||||
|
|
||||||
# 시간 형식 + 테마 한 줄에
|
# 시간 형식 + 테마 한 줄에
|
||||||
format_row = QHBoxLayout()
|
format_row = QHBoxLayout()
|
||||||
time_format_label = QLabel("시간 형식:")
|
time_format_label = QLabel("시간 형식:")
|
||||||
@ -796,6 +813,11 @@ class SettingsView(QDialog):
|
|||||||
self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True))
|
self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True))
|
||||||
self.overtime_notification_check.setChecked(settings.get(NOTIF_OVERTIME, True))
|
self.overtime_notification_check.setChecked(settings.get(NOTIF_OVERTIME, True))
|
||||||
self.health_notification_check.setChecked(settings.get(NOTIF_HEALTH, True))
|
self.health_notification_check.setChecked(settings.get(NOTIF_HEALTH, True))
|
||||||
|
if hasattr(self, 'notif_before_spin'):
|
||||||
|
try:
|
||||||
|
self.notif_before_spin.setValue(int(settings.get(NOTIFICATION_BEFORE_MINUTES, 30)))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
self.notif_before_spin.setValue(30)
|
||||||
|
|
||||||
# 시간 형식 (콤보박스는 문자열로 저장하므로 변환)
|
# 시간 형식 (콤보박스는 문자열로 저장하므로 변환)
|
||||||
time_format = settings.get(TIME_FORMAT, '24')
|
time_format = settings.get(TIME_FORMAT, '24')
|
||||||
@ -879,6 +901,7 @@ class SettingsView(QDialog):
|
|||||||
NOTIF_LUNCH: self.lunch_notification_check.isChecked(),
|
NOTIF_LUNCH: self.lunch_notification_check.isChecked(),
|
||||||
NOTIF_OVERTIME: self.overtime_notification_check.isChecked(),
|
NOTIF_OVERTIME: self.overtime_notification_check.isChecked(),
|
||||||
NOTIF_HEALTH: self.health_notification_check.isChecked(),
|
NOTIF_HEALTH: self.health_notification_check.isChecked(),
|
||||||
|
NOTIFICATION_BEFORE_MINUTES: self.notif_before_spin.value(),
|
||||||
TIME_FORMAT: self.time_format_combo.currentData(),
|
TIME_FORMAT: self.time_format_combo.currentData(),
|
||||||
OVERTIME_UNIT: self.overtime_unit_combo.currentData(),
|
OVERTIME_UNIT: self.overtime_unit_combo.currentData(),
|
||||||
AUTO_OVERTIME: self.auto_overtime_check.isChecked(),
|
AUTO_OVERTIME: self.auto_overtime_check.isChecked(),
|
||||||
|
|||||||
@ -77,37 +77,55 @@ def is_newer(remote: str, local: str) -> bool:
|
|||||||
return _parse_version(remote) > _parse_version(local)
|
return _parse_version(remote) > _parse_version(local)
|
||||||
|
|
||||||
|
|
||||||
def check_for_update(current_version: str, timeout: int = 5) -> Optional[ReleaseInfo]:
|
# 업데이트 체크 결과 상수 (info=None 일 때 reason 식별)
|
||||||
"""GitHub Releases API 조회. 새 버전 있으면 ReleaseInfo, 없으면 None.
|
UP_TO_DATE = 'up_to_date'
|
||||||
|
NETWORK_ERROR = 'network_error'
|
||||||
|
NO_RELEASE = 'no_release' # latest 응답 자체가 없음 (404 등)
|
||||||
|
NO_ASSET = 'no_asset' # release는 있으나 main.exe 자산 없음
|
||||||
|
|
||||||
네트워크 오류 시 None 반환 (앱 시작을 막지 않음).
|
|
||||||
|
def check_for_update(current_version: str, timeout: int = 5):
|
||||||
|
"""Releases API 조회.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(ReleaseInfo, None) — 새 버전 있음
|
||||||
|
(None, UP_TO_DATE) — 이미 최신
|
||||||
|
(None, NETWORK_ERROR) — 네트워크/타임아웃 실패
|
||||||
|
(None, NO_RELEASE) — 저장소 비공개/리소스 없음 (404)
|
||||||
|
(None, NO_ASSET) — 새 버전이지만 main.exe 자산 미첨부
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(RELEASES_API, headers={'User-Agent': USER_AGENT})
|
req = urllib.request.Request(RELEASES_API, headers={'User-Agent': USER_AGENT})
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
data = json.loads(resp.read())
|
data = json.loads(resp.read())
|
||||||
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError):
|
except urllib.error.HTTPError as e:
|
||||||
return None
|
if e.code == 404:
|
||||||
|
return None, NO_RELEASE
|
||||||
|
return None, NETWORK_ERROR
|
||||||
|
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError, OSError):
|
||||||
|
return None, NETWORK_ERROR
|
||||||
|
|
||||||
tag = data.get('tag_name', '')
|
tag = data.get('tag_name', '')
|
||||||
if not tag or not is_newer(tag, current_version):
|
if not tag:
|
||||||
return None
|
return None, NO_RELEASE
|
||||||
|
if not is_newer(tag, current_version):
|
||||||
|
return None, UP_TO_DATE
|
||||||
|
|
||||||
asset_url = None
|
asset_url = None
|
||||||
for asset in data.get('assets', []):
|
for asset in data.get('assets', []):
|
||||||
if asset.get('name') == ASSET_NAME:
|
if asset.get('name') == ASSET_NAME:
|
||||||
asset_url = asset.get('browser_download_url')
|
asset_url = asset.get('browser_download_url')
|
||||||
break
|
break
|
||||||
|
|
||||||
if not asset_url:
|
if not asset_url:
|
||||||
return None
|
return None, NO_ASSET
|
||||||
|
|
||||||
return ReleaseInfo(
|
info = ReleaseInfo(
|
||||||
version=tag,
|
version=tag,
|
||||||
asset_url=asset_url,
|
asset_url=asset_url,
|
||||||
notes=data.get('body', ''),
|
notes=data.get('body', ''),
|
||||||
published_at=data.get('published_at', ''),
|
published_at=data.get('published_at', ''),
|
||||||
)
|
)
|
||||||
|
return info, None
|
||||||
|
|
||||||
|
|
||||||
def download_update(asset_url: str, dest_dir: Optional[Path] = None,
|
def download_update(asset_url: str, dest_dir: Optional[Path] = None,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user