v2.4.0: Phase 2 — meal time, past records, goals, CSV import, crash report

- Meal time dialog (right-click lunch/dinner button to enter actual times)
- Calendar right-click context: add/edit/delete past records
- Monthly goal settings + progress widget (overtime cap, avg daily)
- CSV import (our standard format) with conflict policy
- Global crash handler with Gitea Issues auto-report

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
KINDNICK 2026-04-30 18:38:38 +09:00
parent d3a4efc173
commit 9ebf4ad961
14 changed files with 1059 additions and 7 deletions

View File

@ -4,6 +4,28 @@ 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.4.0] — 2026-04-30
### Added — Phase 2 (5종)
- **점심/저녁 실제 시간 입력** — 점심/저녁 버튼 우클릭 → 시작·종료 시각 입력 다이얼로그
- 자동 60분 대신 정확한 분 단위 기록 (`break_records.break_type='lunch'/'dinner'`)
- **캘린더 우클릭 → 과거 일자 추가/편집/삭제**
- 비어있는 날짜 우클릭: "기록 추가" — 출/퇴근/점심/메모 입력
- 기록 있는 날짜 우클릭: "편집"/"삭제"
- **월간 목표 설정 + 진행률**
- 설정 → 월 연장근무 상한 (시간/분) + 일 평균 근무 목표 (시간)
- 통계 → 월간 탭에 진행률 게이지 (60%/100% 임계 시 색상 변경)
- 0=비활성 (비활성 시 위젯 자체 숨김)
- **CSV 가져오기** — 표준 포맷 `date,clock_in,clock_out,lunch_minutes,memo`
- 충돌 정책: 덮어쓰기/건너뛰기/취소
- **자동 Crash Report (Gitea Issues)**
- 전역 예외 후킹 → crash_log 저장 + 사용자에게 다이얼로그
- "복사" / "Gitea에 보고" (PAT 옵션) — issue 자동 생성
### Settings (신규 4개)
- `goal_overtime_max_monthly`, `goal_avg_hours_daily`
- `gitea_feedback_token`, `gitea_feedback_enabled`
## [2.3.3] — 2026-04-30 ## [2.3.3] — 2026-04-30
### Fixed ### Fixed

View File

@ -652,6 +652,9 @@ class Database:
'discord_notif_clock_in': 'true', 'discord_notif_clock_in': 'true',
'discord_notif_clock_out': 'true', 'discord_notif_clock_out': 'true',
'discord_notif_health': 'true', 'discord_notif_health': 'true',
# v2.4.0
'goal_overtime_max_monthly': '0', # 0=비활성, >0=분 단위 상한
'goal_avg_hours_daily': '0', # 0=비활성
} }
conn = self.get_connection() conn = self.get_connection()
@ -1265,21 +1268,71 @@ class Database:
# ===== 외출 관련 메서드 ===== # ===== 외출 관련 메서드 =====
def add_break_record(self, work_record_id: int, date: str, break_out: str, reason: str = None) -> int: def add_break_record(self, work_record_id: int, date: str, break_out: str,
"""외출 기록 추가""" reason: str = None, break_type: str = 'break') -> int:
"""외출 기록 추가. break_type: 'break'(외출) / 'lunch' / 'dinner'."""
conn = self.get_connection() conn = self.get_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute('''
INSERT INTO break_records (work_record_id, date, break_out, reason) INSERT INTO break_records (work_record_id, date, break_out, reason, break_type)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
''', (work_record_id, date, break_out, reason)) ''', (work_record_id, date, break_out, reason, break_type))
record_id = cursor.lastrowid record_id = cursor.lastrowid
conn.commit() conn.commit()
conn.close() conn.close()
return record_id return record_id
def add_meal_record(self, date: str, start_time: str, end_time: str,
meal_type: str = 'lunch') -> int:
"""식사 시간 기록 (시작·종료 둘 다 알 때).
Args:
date: 'YYYY-MM-DD'
start_time, end_time: 'HH:MM:SS'
meal_type: 'lunch' or 'dinner'
Returns:
break_record id
"""
from datetime import datetime as _dt
# 분 계산
start_dt = _dt.strptime(start_time, '%H:%M:%S')
end_dt = _dt.strptime(end_time, '%H:%M:%S')
if end_dt < start_dt:
from datetime import timedelta as _td
end_dt += _td(days=1)
total_min = int((end_dt - start_dt).total_seconds() / 60)
rec = self.get_today_record() if date == _dt.now().date().isoformat() else None
wid = rec['id'] if rec else None
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute('''
INSERT INTO break_records (work_record_id, date, break_out, break_in,
total_minutes, break_type)
VALUES (?, ?, ?, ?, ?, ?)
''', (wid, date, start_time, end_time, total_min, meal_type))
rid = cursor.lastrowid
conn.commit()
conn.close()
return rid
def get_meal_minutes_today(self, meal_type: str = 'lunch') -> int:
"""오늘의 식사 시간 합계 (분). 수동 입력된 경우만."""
from datetime import date as _date
today = _date.today().isoformat()
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT COALESCE(SUM(total_minutes), 0) FROM break_records
WHERE date = ? AND break_type = ? AND total_minutes IS NOT NULL
''', (today, meal_type))
result = cursor.fetchone()[0] or 0
conn.close()
return int(result)
def update_break_return(self, break_id: int, break_in: str): def update_break_return(self, break_id: int, break_in: str):
"""외출 복귀 시간 업데이트""" """외출 복귀 시간 업데이트"""
conn = self.get_connection() conn = self.get_connection()

View File

@ -44,6 +44,10 @@ DB_PATH_OVERRIDE = 'db_path_override'
# 백업 # 백업
LAST_BACKUP_DATE = 'last_backup_date' LAST_BACKUP_DATE = 'last_backup_date'
# Crash Report (Gitea Issues 통합 — 옵션)
GITEA_FEEDBACK_TOKEN = 'gitea_feedback_token' # PAT (저장소 issue 쓰기 권한)
GITEA_FEEDBACK_ENABLED = 'gitea_feedback_enabled'
# === v2.3.0 신규 === # === v2.3.0 신규 ===
# 온보딩 # 온보딩
ONBOARDING_COMPLETED = 'onboarding_completed' ONBOARDING_COMPLETED = 'onboarding_completed'
@ -57,6 +61,10 @@ OVERTIME_RATE = 'overtime_rate' # 1.5
HEALTH_BREAK_ENABLED = 'health_break_enabled' HEALTH_BREAK_ENABLED = 'health_break_enabled'
HEALTH_BREAK_HOURS = 'health_break_hours' # 기본 4 HEALTH_BREAK_HOURS = 'health_break_hours' # 기본 4
# 목표
GOAL_OVERTIME_MAX_MONTHLY = 'goal_overtime_max_monthly' # 월 연장근무 상한 (분)
GOAL_AVG_HOURS_DAILY = 'goal_avg_hours_daily' # 일평균 목표 (시간, float)
# Discord 웹훅 # Discord 웹훅
DISCORD_WEBHOOK_URL = 'discord_webhook_url' DISCORD_WEBHOOK_URL = 'discord_webhook_url'
DISCORD_NOTIF_CLOCK_IN = 'discord_notif_clock_in' DISCORD_NOTIF_CLOCK_IN = 'discord_notif_clock_in'

View File

@ -4,4 +4,4 @@
릴리스 값을 올린 git tag push. 릴리스 값을 올린 git tag push.
CHANGELOG.md의 최상단 항목과 일치시킬 . CHANGELOG.md의 최상단 항목과 일치시킬 .
""" """
__version__ = '2.3.3' __version__ = '2.4.0'

View File

@ -128,6 +128,15 @@ def main():
from utils.debug_log import dlog from utils.debug_log import dlog
dlog(f"backup failed: {e}") dlog(f"backup failed: {e}")
# 전역 예외 후킹 (crash report)
try:
from utils.crash_handler import install_global_handler
from core.version import __version__
install_global_handler(db, app_version=__version__)
except Exception as e:
from utils.debug_log import dlog
dlog(f"crash handler install failed: {e}")
# 첫 실행 온보딩 (강제) — ONBOARDING_COMPLETED=true 가 아니면 표시 # 첫 실행 온보딩 (강제) — ONBOARDING_COMPLETED=true 가 아니면 표시
try: try:
from ui.onboarding_view import maybe_show_onboarding from ui.onboarding_view import maybe_show_onboarding

View File

@ -47,6 +47,9 @@ class CalendarView(QDialog):
self.calendar.setMinimumHeight(280) self.calendar.setMinimumHeight(280)
self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader) self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader)
self.calendar.clicked.connect(self.date_selected) self.calendar.clicked.connect(self.date_selected)
# 우클릭 컨텍스트 메뉴 (과거 일자 수동 추가)
self.calendar.setContextMenuPolicy(Qt.CustomContextMenu)
self.calendar.customContextMenuRequested.connect(self._show_date_context)
layout.addWidget(self.calendar, 1) layout.addWidget(self.calendar, 1)
# 범례 # 범례
@ -152,6 +155,107 @@ class CalendarView(QDialog):
self.calendar.setDateTextFormat(qdate, fmt) self.calendar.setDateTextFormat(qdate, fmt)
def _show_date_context(self, pos):
"""캘린더 우클릭 메뉴 — 과거 일자 추가/편집/삭제."""
from PyQt5.QtWidgets import QMenu
qdate = self.calendar.selectedDate()
date_str = qdate.toString('yyyy-MM-dd')
existing = self.db.get_work_record(date_str)
menu = QMenu(self)
if existing:
edit_action = menu.addAction(f"✏️ {date_str} 편집")
delete_action = menu.addAction(f"🗑️ {date_str} 삭제")
else:
add_action = menu.addAction(f" {date_str} 기록 추가")
action = menu.exec_(self.calendar.mapToGlobal(pos))
if action is None:
return
if existing and action.text().startswith("✏️"):
self._open_edit_dialog(date_str)
elif existing and action.text().startswith("🗑️"):
self._delete_record(date_str)
elif not existing and action.text().startswith(""):
self._add_past_record(date_str)
def _add_past_record(self, date_str: str):
"""과거 일자 수동 추가."""
from ui.past_record_dialog import PastRecordDialog
dialog = PastRecordDialog(self, date_str)
if dialog.exec_() != QDialog.Accepted:
return
data = dialog.get_data()
if not data:
return
try:
wid = self.db.add_work_record(date_str, data['clock_in'], is_manual=True)
if data.get('clock_out'):
# 총 시간/연장근무 계산
from datetime import datetime as _dt
ci = _dt.strptime(f"{date_str} {data['clock_in']}", '%Y-%m-%d %H:%M:%S')
co = _dt.strptime(f"{date_str} {data['clock_out']}", '%Y-%m-%d %H:%M:%S')
from core.time_calculator import TimeCalculator
wm = self.db.get_work_minutes()
lunch = self.db.get_setting_int('lunch_duration_minutes', 60)
calc = TimeCalculator(work_minutes=wm, lunch_duration_minutes=lunch)
total = (co - ci).total_seconds() / 3600
ot_actual, ot_earned = calc.calculate_overtime(
ci, co,
include_lunch=data.get('lunch', False),
include_dinner=data.get('dinner', False),
)
self.db.update_clock_out(date_str, data['clock_out'], total, ot_actual, ot_earned)
if data.get('lunch'):
self.db.update_lunch_break(date_str, True)
if data.get('dinner'):
self.db.update_dinner_break(date_str, True)
if ot_earned > 0:
self.db.add_overtime_earned(wid, ot_earned, date_str)
self._refresh_calendar()
QMessageBox.information(self, "추가 완료", f"{date_str} 기록이 추가되었습니다.")
except Exception as e:
QMessageBox.critical(self, "오류", f"기록 추가 실패: {e}")
def _open_edit_dialog(self, date_str: str):
"""기존 일자 편집 — date_selected로 우회 (이미 EditTimeDialog 있음)."""
from PyQt5.QtCore import QDate
y, m, d = date_str.split('-')
self.calendar.setSelectedDate(QDate(int(y), int(m), int(d)))
self.date_selected(self.calendar.selectedDate())
# 사용자가 화면 하단에 표시된 "✏️ 시간 수정" 버튼 클릭하면 편집
def _delete_record(self, date_str: str):
reply = QMessageBox.question(
self, "삭제 확인",
f"{date_str} 기록을 정말 삭제하시겠습니까?\n(연장근무 적립 내역도 함께 삭제됩니다)",
QMessageBox.Yes | QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
conn = self.db.get_connection()
cursor = conn.cursor()
try:
cursor.execute("DELETE FROM overtime_bank WHERE date = ?", (date_str,))
cursor.execute("DELETE FROM break_records WHERE date = ?", (date_str,))
cursor.execute("DELETE FROM work_records WHERE date = ?", (date_str,))
conn.commit()
self._refresh_calendar()
QMessageBox.information(self, "삭제 완료", f"{date_str} 기록 삭제됨")
except Exception as e:
conn.rollback()
QMessageBox.critical(self, "오류", str(e))
finally:
conn.close()
def _refresh_calendar(self):
"""캘린더 마킹 갱신."""
if hasattr(self, 'load_calendar_data'):
self.load_calendar_data()
elif hasattr(self, 'load_records'):
self.load_records()
def date_selected(self, qdate): def date_selected(self, qdate):
"""날짜 선택 시""" """날짜 선택 시"""
selected_date = qdate.toPyDate() selected_date = qdate.toPyDate()

100
ui/goal_widget.py Normal file
View File

@ -0,0 +1,100 @@
"""
목표 진행률 위젯.
연장근무 상한 + 일평균 목표를 stats_view 또는 메인에 표시.
설정값이 0이면 비활성 (위젯 자체 hide).
"""
from __future__ import annotations
from datetime import datetime, date
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar
from PyQt5.QtCore import Qt
class GoalWidget(QWidget):
"""월간 목표 진행률 표시."""
def __init__(self, db, parent=None):
super().__init__(parent)
self.db = db
layout = QVBoxLayout()
layout.setContentsMargins(8, 6, 8, 6)
layout.setSpacing(4)
title = QLabel("🎯 이번 달 목표")
title.setStyleSheet("font-weight: bold;")
layout.addWidget(title)
# 연장근무 상한
ot_row = QHBoxLayout()
self.ot_label = QLabel("연장근무:")
self.ot_label.setFixedWidth(100)
self.ot_bar = QProgressBar()
self.ot_bar.setTextVisible(True)
self.ot_bar.setFixedHeight(18)
ot_row.addWidget(self.ot_label)
ot_row.addWidget(self.ot_bar, 1)
layout.addLayout(ot_row)
# 일평균
avg_row = QHBoxLayout()
self.avg_label = QLabel("일평균:")
self.avg_label.setFixedWidth(100)
self.avg_bar = QProgressBar()
self.avg_bar.setTextVisible(True)
self.avg_bar.setFixedHeight(18)
avg_row.addWidget(self.avg_label)
avg_row.addWidget(self.avg_bar, 1)
layout.addLayout(avg_row)
self.setLayout(layout)
def refresh(self):
"""현재 설정값과 이번 달 통계로 진행률 갱신. 0=비활성 시 row 숨김."""
try:
ot_target = int(self.db.get_setting('goal_overtime_max_monthly', '0') or 0)
avg_target = float(self.db.get_setting('goal_avg_hours_daily', '0') or 0)
except (ValueError, TypeError):
ot_target, avg_target = 0, 0.0
# 둘 다 비활성이면 위젯 자체 숨김
if ot_target <= 0 and avg_target <= 0:
self.setVisible(False)
return
self.setVisible(True)
now = datetime.now()
stats = self.db.get_monthly_stats(now.year, now.month)
ot_total = (stats.get('total_overtime_minutes') or 0)
total_h = stats.get('total_hours') or 0
work_days = stats.get('work_days') or 1
# 연장근무 상한 (낮을수록 좋음)
if ot_target > 0:
self.ot_label.setVisible(True)
self.ot_bar.setVisible(True)
self.ot_bar.setMaximum(ot_target)
self.ot_bar.setValue(min(ot_total, ot_target))
ratio = ot_total / ot_target if ot_target else 0
ot_h, ot_m = ot_total // 60, ot_total % 60
tg_h, tg_m = ot_target // 60, ot_target % 60
self.ot_bar.setFormat(f"{ot_h}h {ot_m}m / {tg_h}h {tg_m}m")
color = '#4caf50' if ratio < 0.6 else ('#ff9800' if ratio < 1.0 else '#f44336')
self.ot_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
else:
self.ot_label.setVisible(False)
self.ot_bar.setVisible(False)
# 일평균 (목표 시간보다 적으면 좋음)
if avg_target > 0:
self.avg_label.setVisible(True)
self.avg_bar.setVisible(True)
avg = total_h / work_days if work_days else 0
self.avg_bar.setMaximum(int(avg_target * 100))
self.avg_bar.setValue(int(min(avg, avg_target) * 100))
self.avg_bar.setFormat(f"{avg:.1f}h / {avg_target:.1f}h")
ratio = avg / avg_target if avg_target else 0
color = '#4caf50' if ratio < 0.9 else ('#ff9800' if ratio < 1.1 else '#f44336')
self.avg_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
else:
self.avg_label.setVisible(False)
self.avg_bar.setVisible(False)

View File

@ -7,7 +7,7 @@ from datetime import datetime, timedelta
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QLabel, QPushButton, QProgressBar, QHBoxLayout, QLabel, QPushButton, QProgressBar,
QMessageBox, QGroupBox, QGridLayout, QSystemTrayIcon, QMessageBox, QGroupBox, QGridLayout, QSystemTrayIcon,
QShortcut) QShortcut, QDialog)
from PyQt5.QtCore import QTimer, Qt, pyqtSignal, QLockFile, QDir from PyQt5.QtCore import QTimer, Qt, pyqtSignal, QLockFile, QDir
from PyQt5.QtGui import QFont, QPalette, QColor, QKeySequence from PyQt5.QtGui import QFont, QPalette, QColor, QKeySequence
@ -245,10 +245,20 @@ class MainWindow(QMainWindow):
self.lunch_button = QPushButton(tr('btn.lunch_add')) self.lunch_button = QPushButton(tr('btn.lunch_add'))
self.lunch_button.setCheckable(True) self.lunch_button.setCheckable(True)
self.lunch_button.clicked.connect(self.toggle_lunch_break) self.lunch_button.clicked.connect(self.toggle_lunch_break)
self.lunch_button.setContextMenuPolicy(Qt.CustomContextMenu)
self.lunch_button.customContextMenuRequested.connect(
lambda pos: self._show_meal_context('lunch', self.lunch_button, pos)
)
self.lunch_button.setToolTip("좌클릭: 토글 / 우클릭: 실제 시간 입력")
self.dinner_button = QPushButton(tr('btn.dinner_add')) self.dinner_button = QPushButton(tr('btn.dinner_add'))
self.dinner_button.setCheckable(True) self.dinner_button.setCheckable(True)
self.dinner_button.clicked.connect(self.toggle_dinner_break) self.dinner_button.clicked.connect(self.toggle_dinner_break)
self.dinner_button.setContextMenuPolicy(Qt.CustomContextMenu)
self.dinner_button.customContextMenuRequested.connect(
lambda pos: self._show_meal_context('dinner', self.dinner_button, pos)
)
self.dinner_button.setToolTip("좌클릭: 토글 / 우클릭: 실제 시간 입력")
meal_button_layout.addWidget(self.lunch_button) meal_button_layout.addWidget(self.lunch_button)
meal_button_layout.addWidget(self.dinner_button) meal_button_layout.addWidget(self.dinner_button)
@ -1641,6 +1651,47 @@ class MainWindow(QMainWindow):
dialog = HelpView(self) dialog = HelpView(self)
dialog.exec_() dialog.exec_()
def _show_meal_context(self, meal_type: str, button, pos):
"""점심/저녁 버튼 우클릭 → 실제 시간 입력 메뉴."""
from PyQt5.QtWidgets import QMenu
from ui.meal_time_dialog import MealTimeDialog
menu = QMenu(self)
title = "점심" if meal_type == 'lunch' else "저녁"
edit_action = menu.addAction(f"{title} 실제 시간 입력...")
global_pos = button.mapToGlobal(pos)
action = menu.exec_(global_pos)
if action != edit_action:
return
if not self.is_clocked_in:
QMessageBox.warning(self, "출근 필요", "출근 후에만 식사 시간을 기록할 수 있습니다.")
return
default_min = (self.time_calc.lunch_duration_minutes
if meal_type == 'lunch'
else self.time_calc.dinner_duration_minutes)
dialog = MealTimeDialog(self, meal_type=meal_type, default_minutes=default_min)
if dialog.exec_() != QDialog.Accepted:
return
start, end, minutes = dialog.get_times()
if minutes <= 0:
QMessageBox.warning(self, "입력 오류", "종료 시간이 시작보다 빠릅니다.")
return
today = datetime.now().date().isoformat()
self.db.add_meal_record(today, start, end, meal_type=meal_type)
# 자동 토글 ON
if meal_type == 'lunch':
self.lunch_break_enabled = True
self.lunch_button.setChecked(True)
self.update_lunch_status()
self.db.update_lunch_break(today, True)
self.auto_lunch_applied_today = True
else:
self.dinner_break_enabled = True
self.dinner_button.setChecked(True)
self.update_dinner_status()
self.db.update_dinner_break(today, True)
QMessageBox.information(self, "기록 완료",
f"{title} {minutes}분 기록되었습니다.\n({start} ~ {end})")
def show_onboarding(self): def show_onboarding(self):
"""온보딩 위저드 다시 보기.""" """온보딩 위저드 다시 보기."""
from ui.onboarding_view import OnboardingWizard from ui.onboarding_view import OnboardingWizard

108
ui/meal_time_dialog.py Normal file
View File

@ -0,0 +1,108 @@
"""
점심/저녁 실제 시간 입력 다이얼로그.
기본 60 자동 차감 모드와 별개로, 사용자가 정확한 시작/종료 시각을
입력하면 값을 break_records.break_type='lunch'/'dinner' 저장.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QTimeEdit, QMessageBox)
from PyQt5.QtCore import QTime, Qt
from ui.styles import apply_dark_titlebar
class MealTimeDialog(QDialog):
"""점심/저녁 실제 시작·종료 시간 입력."""
def __init__(self, parent=None, meal_type: str = 'lunch', default_minutes: int = 60):
super().__init__(parent)
self.meal_type = meal_type
title_kr = '점심' if meal_type == 'lunch' else '저녁'
self.setWindowTitle(f"{title_kr} 시간 입력")
self.setModal(True)
self.setFixedSize(360, 220)
layout = QVBoxLayout()
layout.setSpacing(10)
layout.setContentsMargins(20, 16, 20, 16)
info = QLabel(f"{title_kr} 시작·종료 시각을 입력하세요.\n자동 적용된 {default_minutes}분 대신 정확한 시간으로 기록됩니다.")
info.setWordWrap(True)
info.setStyleSheet("color: #888; padding-bottom: 6px;")
layout.addWidget(info)
# 시작
start_row = QHBoxLayout()
start_row.addWidget(QLabel("시작:"))
self.start_edit = QTimeEdit()
self.start_edit.setDisplayFormat("HH:mm")
# 기본값: 점심=12:00, 저녁=18:00
default_start = QTime(12, 0) if meal_type == 'lunch' else QTime(18, 0)
self.start_edit.setTime(default_start)
start_row.addWidget(self.start_edit)
start_row.addStretch()
layout.addLayout(start_row)
# 종료
end_row = QHBoxLayout()
end_row.addWidget(QLabel("종료:"))
self.end_edit = QTimeEdit()
self.end_edit.setDisplayFormat("HH:mm")
default_end = QTime(13, 0) if meal_type == 'lunch' else QTime(19, 0)
self.end_edit.setTime(default_end)
end_row.addWidget(self.end_edit)
end_row.addStretch()
layout.addLayout(end_row)
# 미리보기 라벨
self.preview = QLabel("")
self.preview.setStyleSheet("color: #4caf50; font-weight: bold; padding-top: 6px;")
layout.addWidget(self.preview)
self._update_preview()
self.start_edit.timeChanged.connect(self._update_preview)
self.end_edit.timeChanged.connect(self._update_preview)
# 버튼
btn_row = QHBoxLayout()
btn_row.addStretch()
ok_btn = QPushButton("저장")
ok_btn.setObjectName("btn_primary")
ok_btn.clicked.connect(self.accept)
cancel_btn = QPushButton("취소")
cancel_btn.clicked.connect(self.reject)
btn_row.addWidget(ok_btn)
btn_row.addWidget(cancel_btn)
layout.addLayout(btn_row)
self.setLayout(layout)
apply_dark_titlebar(self)
def _update_preview(self):
s = self.start_edit.time()
e = self.end_edit.time()
start_dt = datetime.combine(datetime.today(), s.toPyTime())
end_dt = datetime.combine(datetime.today(), e.toPyTime())
if end_dt < start_dt:
end_dt += timedelta(days=1)
minutes = int((end_dt - start_dt).total_seconds() / 60)
if minutes <= 0:
self.preview.setText("⚠️ 시작이 종료보다 늦습니다")
self.preview.setStyleSheet("color: #f44336;")
else:
self.preview.setText(f"{minutes}")
self.preview.setStyleSheet("color: #4caf50; font-weight: bold;")
def get_times(self) -> tuple[str, str, int]:
"""('HH:MM:SS', 'HH:MM:SS', total_minutes) 반환."""
s = self.start_edit.time().toPyTime()
e = self.end_edit.time().toPyTime()
start_str = f"{s.hour:02d}:{s.minute:02d}:00"
end_str = f"{e.hour:02d}:{e.minute:02d}:00"
s_dt = datetime.combine(datetime.today(), s)
e_dt = datetime.combine(datetime.today(), e)
if e_dt < s_dt:
e_dt += timedelta(days=1)
minutes = int((e_dt - s_dt).total_seconds() / 60)
return start_str, end_str, minutes

111
ui/past_record_dialog.py Normal file
View File

@ -0,0 +1,111 @@
"""
과거 일자 수동 추가 다이얼로그.
캘린더 우클릭 "기록 추가"에서 호출. /퇴근 시각 + 점심/저녁 + 메모 입력.
"""
from __future__ import annotations
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QTimeEdit, QCheckBox, QLineEdit,
QMessageBox)
from PyQt5.QtCore import QTime, Qt
from ui.styles import apply_dark_titlebar
class PastRecordDialog(QDialog):
"""과거 일자 근무 기록 입력."""
def __init__(self, parent=None, date_str: str = ''):
super().__init__(parent)
self.date_str = date_str
self.setWindowTitle(f"기록 추가 — {date_str}")
self.setModal(True)
self.setFixedSize(380, 320)
layout = QVBoxLayout()
layout.setSpacing(8)
layout.setContentsMargins(20, 16, 20, 16)
info = QLabel(f"📅 {date_str} 근무 기록을 입력하세요.")
info.setStyleSheet("font-weight: bold; padding-bottom: 6px;")
layout.addWidget(info)
# 출근
ci_row = QHBoxLayout()
ci_row.addWidget(QLabel("출근:"))
self.clock_in_edit = QTimeEdit()
self.clock_in_edit.setDisplayFormat("HH:mm")
self.clock_in_edit.setTime(QTime(9, 0))
ci_row.addWidget(self.clock_in_edit)
ci_row.addStretch()
layout.addLayout(ci_row)
# 퇴근
co_row = QHBoxLayout()
co_row.addWidget(QLabel("퇴근:"))
self.clock_out_check = QCheckBox("입력")
self.clock_out_check.setChecked(True)
self.clock_out_edit = QTimeEdit()
self.clock_out_edit.setDisplayFormat("HH:mm")
self.clock_out_edit.setTime(QTime(18, 0))
self.clock_out_check.toggled.connect(self.clock_out_edit.setEnabled)
co_row.addWidget(self.clock_out_check)
co_row.addWidget(self.clock_out_edit)
co_row.addStretch()
layout.addLayout(co_row)
# 점심/저녁
meal_row = QHBoxLayout()
self.lunch_check = QCheckBox("🍱 점심시간 포함")
self.lunch_check.setChecked(True)
self.dinner_check = QCheckBox("🍽 저녁시간 포함")
meal_row.addWidget(self.lunch_check)
meal_row.addWidget(self.dinner_check)
meal_row.addStretch()
layout.addLayout(meal_row)
# 메모
layout.addWidget(QLabel("메모 (선택):"))
self.memo_edit = QLineEdit()
self.memo_edit.setPlaceholderText("예: 재택근무 / 외근 / 휴가")
layout.addWidget(self.memo_edit)
layout.addStretch()
# 버튼
btn_row = QHBoxLayout()
btn_row.addStretch()
ok_btn = QPushButton("저장")
ok_btn.setObjectName("btn_primary")
ok_btn.clicked.connect(self._validate_and_accept)
cancel_btn = QPushButton("취소")
cancel_btn.clicked.connect(self.reject)
btn_row.addWidget(ok_btn)
btn_row.addWidget(cancel_btn)
layout.addLayout(btn_row)
self.setLayout(layout)
apply_dark_titlebar(self)
def _validate_and_accept(self):
if self.clock_out_check.isChecked():
ci = self.clock_in_edit.time()
co = self.clock_out_edit.time()
if co <= ci:
QMessageBox.warning(self, "입력 오류",
"퇴근 시간이 출근 시간보다 빠르거나 같습니다.")
return
self.accept()
def get_data(self) -> dict:
ci = self.clock_in_edit.time().toPyTime()
data = {
'clock_in': f"{ci.hour:02d}:{ci.minute:02d}:00",
'lunch': self.lunch_check.isChecked(),
'dinner': self.dinner_check.isChecked(),
'memo': self.memo_edit.text().strip(),
}
if self.clock_out_check.isChecked():
co = self.clock_out_edit.time().toPyTime()
data['clock_out'] = f"{co.hour:02d}:{co.minute:02d}:00"
return data

View File

@ -22,6 +22,8 @@ from core.settings_keys import (
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,
GOAL_OVERTIME_MAX_MONTHLY, GOAL_AVG_HOURS_DAILY,
GITEA_FEEDBACK_TOKEN, GITEA_FEEDBACK_ENABLED,
) )
from utils.csv_exporter import CSVExporter from utils.csv_exporter import CSVExporter
from ui.leave_view import AddLeaveDialog from ui.leave_view import AddLeaveDialog
@ -86,6 +88,10 @@ class SettingsView(QDialog):
leave_group = self.create_leave_group() leave_group = self.create_leave_group()
layout.addWidget(leave_group) layout.addWidget(leave_group)
# 목표 설정 그룹
goal_group = self.create_goal_group()
layout.addWidget(goal_group)
# 공휴일 설정 # 공휴일 설정
holiday_group = self.create_holiday_group() holiday_group = self.create_holiday_group()
layout.addWidget(holiday_group) layout.addWidget(holiday_group)
@ -398,6 +404,51 @@ class SettingsView(QDialog):
group.setLayout(layout) group.setLayout(layout)
return group return group
def create_goal_group(self) -> QGroupBox:
"""월간 목표 설정 그룹 (0=비활성)."""
group = QGroupBox("🎯 월간 목표 (0=비활성)")
layout = QVBoxLayout()
layout.setSpacing(6)
# 연장근무 상한
ot_row = QHBoxLayout()
ot_label = QLabel("월 연장근무 상한:")
ot_label.setFixedWidth(150)
self.goal_ot_h = QSpinBox()
self.goal_ot_h.setRange(0, 100)
self.goal_ot_h.setSuffix(" 시간")
self.goal_ot_h.setFixedWidth(100)
self.goal_ot_m = QSpinBox()
self.goal_ot_m.setRange(0, 59)
self.goal_ot_m.setSingleStep(30)
self.goal_ot_m.setSuffix("")
self.goal_ot_m.setFixedWidth(90)
ot_row.addWidget(ot_label)
ot_row.addWidget(self.goal_ot_h)
ot_row.addWidget(self.goal_ot_m)
ot_row.addStretch()
layout.addLayout(ot_row)
# 일평균 목표
avg_row = QHBoxLayout()
avg_label = QLabel("일 평균 근무 목표:")
avg_label.setFixedWidth(150)
self.goal_avg = QDoubleSpinBox() if False else QSpinBox() # int*10 방식
self.goal_avg.setRange(0, 24)
self.goal_avg.setSuffix(" 시간")
self.goal_avg.setFixedWidth(100)
avg_row.addWidget(avg_label)
avg_row.addWidget(self.goal_avg)
avg_row.addStretch()
layout.addLayout(avg_row)
note = QLabel("※ 통계 → 월간 탭에서 진행률 확인")
note.setObjectName("note_text")
layout.addWidget(note)
group.setLayout(layout)
return group
def create_leave_group(self) -> QGroupBox: def create_leave_group(self) -> QGroupBox:
"""휴가 설정 그룹""" """휴가 설정 그룹"""
group = QGroupBox(tr('group.leave')) group = QGroupBox(tr('group.leave'))
@ -690,6 +741,19 @@ class SettingsView(QDialog):
layout.addLayout(export_layout) layout.addLayout(export_layout)
# CSV 가져오기
import_layout = QHBoxLayout()
import_btn = QPushButton("📥 CSV 가져오기")
import_btn.setObjectName("btn_small")
import_btn.setToolTip("date,clock_in,clock_out,lunch_minutes,memo 헤더 포맷")
import_btn.clicked.connect(self._import_csv)
import_layout.addWidget(import_btn)
import_label = QLabel("우리 표준 포맷 (헤더: date,clock_in,clock_out,lunch_minutes,memo)")
import_label.setObjectName("note_text")
import_layout.addWidget(import_label)
import_layout.addStretch()
layout.addLayout(import_layout)
# DB 경로 설정 (클라우드 동기화 가능) # DB 경로 설정 (클라우드 동기화 가능)
db_path_layout = QHBoxLayout() db_path_layout = QHBoxLayout()
db_path_label = QLabel("DB 경로:") db_path_label = QLabel("DB 경로:")
@ -711,6 +775,22 @@ class SettingsView(QDialog):
self.auto_break_check.setToolTip("PC가 잠기면 외출 시작, 풀리면 복귀를 자동 처리합니다.") self.auto_break_check.setToolTip("PC가 잠기면 외출 시작, 풀리면 복귀를 자동 처리합니다.")
layout.addWidget(self.auto_break_check) layout.addWidget(self.auto_break_check)
# Gitea 피드백 토큰 (옵션, crash 자동 보고용)
feedback_layout = QHBoxLayout()
feedback_label = QLabel("Gitea 피드백:")
feedback_label.setFixedWidth(80)
self.gitea_token_edit = QLineEdit()
self.gitea_token_edit.setEchoMode(QLineEdit.Password)
self.gitea_token_edit.setPlaceholderText("PAT (issue 쓰기 권한, 옵션)")
feedback_layout.addWidget(feedback_label)
feedback_layout.addWidget(self.gitea_token_edit, 1)
layout.addLayout(feedback_layout)
self.gitea_feedback_enabled_check = QCheckBox(
"오류 발생 시 'Gitea에 보고' 버튼 활성화"
)
layout.addWidget(self.gitea_feedback_enabled_check)
# 첫 잠금 해제 = 출근 (PC를 안 끄는 사용자용) # 첫 잠금 해제 = 출근 (PC를 안 끄는 사용자용)
self.clock_in_unlock_check = QCheckBox("첫 잠금 해제 시각을 출근시간으로 사용") self.clock_in_unlock_check = QCheckBox("첫 잠금 해제 시각을 출근시간으로 사용")
self.clock_in_unlock_check.setToolTip( self.clock_in_unlock_check.setToolTip(
@ -808,6 +888,22 @@ class SettingsView(QDialog):
if hasattr(self, 'clock_in_unlock_check'): if hasattr(self, 'clock_in_unlock_check'):
self.clock_in_unlock_check.setChecked(settings.get(CLOCK_IN_ON_UNLOCK, False)) self.clock_in_unlock_check.setChecked(settings.get(CLOCK_IN_ON_UNLOCK, False))
# 목표
if hasattr(self, 'goal_ot_h'):
ot_min = int(settings.get(GOAL_OVERTIME_MAX_MONTHLY, 0) or 0)
self.goal_ot_h.setValue(ot_min // 60)
self.goal_ot_m.setValue(ot_min % 60)
if hasattr(self, 'goal_avg'):
self.goal_avg.setValue(int(float(settings.get(GOAL_AVG_HOURS_DAILY, 0) or 0)))
# Gitea 피드백
if hasattr(self, 'gitea_token_edit'):
self.gitea_token_edit.setText(self.db.get_setting(GITEA_FEEDBACK_TOKEN, '') or '')
if hasattr(self, 'gitea_feedback_enabled_check'):
self.gitea_feedback_enabled_check.setChecked(
settings.get(GITEA_FEEDBACK_ENABLED, False)
)
# 알림 # 알림
self.clock_out_notification_check.setChecked(settings.get(NOTIF_CLOCK_OUT, True)) self.clock_out_notification_check.setChecked(settings.get(NOTIF_CLOCK_OUT, True))
self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True)) self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True))
@ -862,6 +958,49 @@ class SettingsView(QDialog):
# 남은 연차 계산 # 남은 연차 계산
self.update_remaining_leave() self.update_remaining_leave()
def _import_csv(self):
"""CSV 파일에서 근무 기록 일괄 가져오기."""
path, _ = QFileDialog.getOpenFileName(
self, "CSV 가져오기",
os.path.expanduser("~"),
"CSV files (*.csv);;All files (*.*)",
)
if not path:
return
try:
from utils.csv_importer import parse_csv, import_records
rows = parse_csv(path)
except (FileNotFoundError, ValueError) as e:
QMessageBox.critical(self, "파싱 실패", str(e))
return
if not rows:
QMessageBox.information(self, "빈 파일", "유효한 행이 없습니다.")
return
reply = QMessageBox.question(
self,
"충돌 처리",
f"{len(rows)}건의 행을 가져오겠습니다.\n\n"
"기존 일자와 충돌하면 어떻게 처리할까요?\n"
"Yes = 덮어쓰기\nNo = 건너뛰기\nCancel = 취소",
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
)
if reply == QMessageBox.Cancel:
return
policy = 'overwrite' if reply == QMessageBox.Yes else 'skip'
try:
added, updated, skipped = import_records(self.db, rows, on_conflict=policy)
except Exception as e:
QMessageBox.critical(self, "가져오기 실패", str(e))
return
QMessageBox.information(
self, "완료",
f"가져오기 결과:\n• 추가: {added}\n• 갱신: {updated}\n• 건너뜀: {skipped}"
)
def _check_updates(self): def _check_updates(self):
"""설정 창에서 업데이트 확인 트리거 → 부모 윈도우로 위임.""" """설정 창에서 업데이트 확인 트리거 → 부모 윈도우로 위임."""
if self.parent_window and hasattr(self.parent_window, 'check_for_updates'): if self.parent_window and hasattr(self.parent_window, 'check_for_updates'):
@ -911,6 +1050,14 @@ class SettingsView(QDialog):
settings[AUTO_BREAK_ON_LOCK] = self.auto_break_check.isChecked() settings[AUTO_BREAK_ON_LOCK] = self.auto_break_check.isChecked()
if hasattr(self, 'clock_in_unlock_check'): if hasattr(self, 'clock_in_unlock_check'):
settings[CLOCK_IN_ON_UNLOCK] = self.clock_in_unlock_check.isChecked() settings[CLOCK_IN_ON_UNLOCK] = self.clock_in_unlock_check.isChecked()
if hasattr(self, 'goal_ot_h'):
settings[GOAL_OVERTIME_MAX_MONTHLY] = self.goal_ot_h.value() * 60 + self.goal_ot_m.value()
if hasattr(self, 'goal_avg'):
settings[GOAL_AVG_HOURS_DAILY] = self.goal_avg.value()
if hasattr(self, 'gitea_token_edit'):
self.db.set_setting(GITEA_FEEDBACK_TOKEN, self.gitea_token_edit.text().strip())
if hasattr(self, 'gitea_feedback_enabled_check'):
settings[GITEA_FEEDBACK_ENABLED] = self.gitea_feedback_enabled_check.isChecked()
if hasattr(self, 'language_combo'): if hasattr(self, 'language_combo'):
settings[LANGUAGE] = self.language_combo.currentData() settings[LANGUAGE] = self.language_combo.currentData()

View File

@ -131,6 +131,11 @@ class StatsView(QDialog):
layout.addWidget(summary_group) layout.addWidget(summary_group)
layout.addWidget(self.salary_label) layout.addWidget(self.salary_label)
# 목표 진행률
from ui.goal_widget import GoalWidget
self.goal_widget = GoalWidget(self.db)
layout.addWidget(self.goal_widget)
# 월간 차트 # 월간 차트
from ui.chart_widget import make_chart_widget from ui.chart_widget import make_chart_widget
self.monthly_chart = make_chart_widget(widget) self.monthly_chart = make_chart_widget(widget)
@ -215,6 +220,10 @@ class StatsView(QDialog):
# 추정 급여 (옵션 활성 시) # 추정 급여 (옵션 활성 시)
self._update_salary_estimate(monthly_stats.get('records', [])) self._update_salary_estimate(monthly_stats.get('records', []))
# 목표 진행률
if hasattr(self, 'goal_widget'):
self.goal_widget.refresh()
# 패턴 분석 # 패턴 분석
self.analyze_patterns(monthly_stats.get('records', [])) self.analyze_patterns(monthly_stats.get('records', []))

173
utils/crash_handler.py Normal file
View File

@ -0,0 +1,173 @@
"""
전역 예외 후킹 + Gitea Issues 자동 보고.
sys.excepthook을 등록해 처리되지 않은 예외를 가로채:
1. crash_log 테이블에 저장
2. 사용자에게 다이얼로그로 알림 + "Gitea에 보고" / "복사" 옵션
"""
from __future__ import annotations
import json
import sys
import traceback
import urllib.request
import urllib.error
from datetime import datetime
from typing import Optional
# 자체 호스팅 Gitea (updater_client와 동일)
GITEA_API = 'https://kindnick-git.duckdns.org/api/v1/repos/kindnick/Clock_out_Time_Calculator'
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/2.4'
def install_global_handler(db, app_version: str = 'unknown') -> None:
"""sys.excepthook 등록. db는 crash_log 저장용."""
original = sys.excepthook
def hook(exc_type, exc_value, exc_tb):
if exc_type is KeyboardInterrupt:
original(exc_type, exc_value, exc_tb)
return
try:
tb_str = ''.join(traceback.format_exception(exc_type, exc_value, exc_tb))
_log_crash(db, exc_type.__name__, str(exc_value), tb_str, app_version)
_show_dialog(db, exc_type.__name__, str(exc_value), tb_str, app_version)
except Exception:
pass # 후킹 자체 실패는 silent
original(exc_type, exc_value, exc_tb)
sys.excepthook = hook
def _log_crash(db, exc_type: str, message: str, tb: str, version: str) -> None:
"""crash_log 테이블에 기록."""
try:
conn = db.get_connection()
cursor = conn.cursor()
# 테이블 자동 생성 (마이그레이션 누락 대비)
cursor.execute('''
CREATE TABLE IF NOT EXISTS crash_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
exception_type TEXT,
message TEXT,
traceback TEXT,
app_version TEXT,
reported BOOLEAN DEFAULT 0
)
''')
cursor.execute('''
INSERT INTO crash_log (exception_type, message, traceback, app_version)
VALUES (?, ?, ?, ?)
''', (exc_type, message, tb, version))
conn.commit()
conn.close()
except Exception:
pass
def _show_dialog(db, exc_type: str, message: str, tb: str, version: str) -> None:
"""크래시 알림 + Gitea 보고/복사 버튼."""
try:
from PyQt5.QtWidgets import (QApplication, QMessageBox, QDialog,
QVBoxLayout, QLabel, QTextEdit,
QHBoxLayout, QPushButton, QLineEdit)
except ImportError:
return
app = QApplication.instance()
if app is None:
return
dlg = QDialog()
dlg.setWindowTitle("⚠️ 오류 발생")
dlg.setMinimumSize(560, 420)
layout = QVBoxLayout()
title = QLabel(f"<b>{exc_type}</b>: {message[:200]}")
title.setWordWrap(True)
layout.addWidget(title)
layout.addWidget(QLabel("무엇을 하다가 발생했나요? (선택)"))
user_note = QLineEdit()
user_note.setPlaceholderText("예: 출근 버튼 누른 직후")
layout.addWidget(user_note)
layout.addWidget(QLabel("기술 정보 (자동 보고에 포함):"))
tb_view = QTextEdit()
tb_view.setReadOnly(True)
tb_view.setFont(__import__('PyQt5.QtGui', fromlist=['QFont']).QFont('Consolas', 9))
tb_view.setText(tb[-3000:]) # 너무 긴 traceback 제한
layout.addWidget(tb_view, 1)
btn_row = QHBoxLayout()
cancel_btn = QPushButton("닫기")
copy_btn = QPushButton("📋 복사")
report_btn = QPushButton("📤 Gitea에 보고")
has_token = bool(db.get_setting('gitea_feedback_token', '') or '')
enabled = db.get_setting('gitea_feedback_enabled', 'false').lower() == 'true'
if not (has_token and enabled):
report_btn.setEnabled(False)
report_btn.setToolTip("설정 → 데이터 관리 → Gitea 피드백 토큰 입력 후 활성화 필요")
def do_copy():
clipboard = QApplication.clipboard()
text = (
f"## {exc_type}\n\n{message}\n\n"
f"**Version**: {version}\n**Note**: {user_note.text()}\n\n"
f"```\n{tb}\n```"
)
clipboard.setText(text)
copy_btn.setText("✓ 복사됨")
def do_report():
token = db.get_setting('gitea_feedback_token', '') or ''
if not token:
QMessageBox.warning(dlg, "토큰 없음", "Gitea PAT를 설정에서 먼저 입력하세요.")
return
title_str = f"[Auto] {exc_type}: {message[:80]}"
body = (
f"**Version**: `{version}`\n"
f"**Time**: {datetime.now().isoformat(timespec='seconds')}\n"
f"**User note**: {user_note.text() or '(none)'}\n\n"
f"### Traceback\n```\n{tb[-3000:]}\n```"
)
ok = _send_to_gitea(token, title_str, body)
if ok:
QMessageBox.information(dlg, "보고 완료", "Gitea Issues에 등록되었습니다.")
report_btn.setText("✓ 보고됨")
report_btn.setEnabled(False)
else:
QMessageBox.warning(dlg, "보고 실패", "네트워크 또는 토큰 권한 문제일 수 있습니다.")
cancel_btn.clicked.connect(dlg.reject)
copy_btn.clicked.connect(do_copy)
report_btn.clicked.connect(do_report)
btn_row.addWidget(cancel_btn)
btn_row.addStretch()
btn_row.addWidget(copy_btn)
btn_row.addWidget(report_btn)
layout.addLayout(btn_row)
dlg.setLayout(layout)
dlg.exec_()
def _send_to_gitea(token: str, title: str, body: str) -> bool:
"""Gitea Issues API로 issue 생성."""
payload = json.dumps({'title': title, 'body': body}).encode('utf-8')
req = urllib.request.Request(
f"{GITEA_API}/issues",
data=payload,
headers={
'Authorization': f'token {token}',
'Content-Type': 'application/json',
'User-Agent': USER_AGENT,
},
method='POST',
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return 200 <= resp.status < 300
except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError):
return False

157
utils/csv_importer.py Normal file
View File

@ -0,0 +1,157 @@
"""
CSV 가져오기 우리 표준 포맷.
표준 포맷:
date,clock_in,clock_out,lunch_minutes,memo
2026-04-01,09:00:00,18:30:00,60,"메모"
- 헤더 필수
- date: YYYY-MM-DD
- clock_in/out: HH:MM:SS 또는 HH:MM
- lunch_minutes: 정수 (0이면 점심 미포함)
- memo: 선택 (따옴표 가능)
기존 일자와 충돌 import 호출자가 'overwrite'/'skip' 정책 결정.
"""
from __future__ import annotations
import csv
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Iterator, Tuple
def parse_csv(path: str) -> List[Dict]:
"""CSV 파일을 dict 리스트로 파싱. 검증 실패 시 ValueError."""
rows = []
p = Path(path)
if not p.exists():
raise FileNotFoundError(f"파일 없음: {path}")
with open(p, 'r', encoding='utf-8-sig', newline='') as f:
reader = csv.DictReader(f)
required = {'date', 'clock_in'}
if not required.issubset(reader.fieldnames or []):
raise ValueError(
f"헤더에 필수 필드 누락: {required - set(reader.fieldnames or [])}\n"
f"필수 헤더: date,clock_in,clock_out,lunch_minutes,memo"
)
for i, row in enumerate(reader, start=2): # 데이터 시작 줄 번호 (1=헤더)
try:
clean = _normalize_row(row)
rows.append(clean)
except ValueError as e:
raise ValueError(f"{i}: {e}")
return rows
def _normalize_row(row: Dict) -> Dict:
"""단일 행 검증 + 정규화."""
date_str = (row.get('date') or '').strip()
if not date_str:
raise ValueError("date 비어있음")
try:
datetime.strptime(date_str, '%Y-%m-%d')
except ValueError:
raise ValueError(f"date 형식 오류: '{date_str}' (YYYY-MM-DD 필요)")
ci = _normalize_time(row.get('clock_in', '').strip(), 'clock_in')
co_raw = (row.get('clock_out') or '').strip()
co = _normalize_time(co_raw, 'clock_out') if co_raw else None
lunch = 0
lm = (row.get('lunch_minutes') or '').strip()
if lm:
try:
lunch = int(lm)
if lunch < 0:
raise ValueError
except ValueError:
raise ValueError(f"lunch_minutes는 0 이상 정수여야 함: '{lm}'")
memo = (row.get('memo') or '').strip()
return {
'date': date_str,
'clock_in': ci,
'clock_out': co,
'lunch_minutes': lunch,
'memo': memo,
}
def _normalize_time(s: str, field_name: str) -> str:
"""'HH:MM' 또는 'HH:MM:SS''HH:MM:SS'."""
if not s:
raise ValueError(f"{field_name} 비어있음")
parts = s.split(':')
if len(parts) == 2:
s = f"{s}:00"
elif len(parts) != 3:
raise ValueError(f"{field_name} 형식 오류: '{s}' (HH:MM[:SS] 필요)")
try:
datetime.strptime(s, '%H:%M:%S')
except ValueError:
raise ValueError(f"{field_name} 시간 파싱 실패: '{s}'")
return s
def import_records(db, rows: List[Dict], on_conflict: str = 'skip') -> Tuple[int, int, int]:
"""파싱된 rows를 DB에 일괄 입력.
Args:
db: Database 인스턴스
rows: parse_csv 결과
on_conflict: 'skip' | 'overwrite'
Returns:
(added, updated, skipped)
"""
if on_conflict not in ('skip', 'overwrite'):
raise ValueError("on_conflict는 'skip' 또는 'overwrite'")
added = updated = skipped = 0
from core.time_calculator import TimeCalculator
work_minutes = db.get_work_minutes()
lunch_default = db.get_setting_int('lunch_duration_minutes', 60)
for row in rows:
existing = db.get_work_record(row['date'])
if existing and on_conflict == 'skip':
skipped += 1
continue
if existing and on_conflict == 'overwrite':
# 기존 레코드 삭제 후 재추가 (단순화)
conn = db.get_connection()
cursor = conn.cursor()
try:
cursor.execute("DELETE FROM overtime_bank WHERE date = ?", (row['date'],))
cursor.execute("DELETE FROM break_records WHERE date = ?", (row['date'],))
cursor.execute("DELETE FROM work_records WHERE date = ?", (row['date'],))
conn.commit()
finally:
conn.close()
updated += 1
else:
added += 1
wid = db.add_work_record(row['date'], row['clock_in'], is_manual=True)
if row.get('clock_out'):
ci_dt = datetime.strptime(f"{row['date']} {row['clock_in']}", '%Y-%m-%d %H:%M:%S')
co_dt = datetime.strptime(f"{row['date']} {row['clock_out']}", '%Y-%m-%d %H:%M:%S')
calc = TimeCalculator(work_minutes=work_minutes,
lunch_duration_minutes=row.get('lunch_minutes') or lunch_default)
include_lunch = (row.get('lunch_minutes') or 0) > 0
total = (co_dt - ci_dt).total_seconds() / 3600
ot_actual, ot_earned = calc.calculate_overtime(
ci_dt, co_dt, include_lunch=include_lunch
)
db.update_clock_out(row['date'], row['clock_out'], total, ot_actual, ot_earned)
if include_lunch:
db.update_lunch_break(row['date'], True)
if ot_earned > 0:
db.add_overtime_earned(wid, ot_earned, row['date'])
return added, updated, skipped