Enhance settings and notification features in AppController and SchedulerManager. Added default values for crawling and notification settings, updated database query methods to support related filtering, and improved UI elements in settings dialog for better user experience. Updated API contract documentation to reflect new settings structure.
This commit is contained in:
parent
2e9bf283ef
commit
cef29aecd8
|
|
@ -169,6 +169,28 @@ class AppController:
|
|||
try:
|
||||
with open(self.settings_file, 'r', encoding='utf-8') as f:
|
||||
self.settings = json.load(f)
|
||||
|
||||
# 누락된 기본 설정 보완
|
||||
self.settings.setdefault('crawling', {})
|
||||
self.settings['crawling'].setdefault('interval_minutes', 10)
|
||||
self.settings['crawling'].setdefault('max_pages', 2)
|
||||
self.settings['crawling'].setdefault('target_depts', ['차량'])
|
||||
default_stations = self.settings.get('master_data', {}).get('stations_L1', [])
|
||||
default_crawl_keywords = ['1호선'] + [station for station in default_stations if station]
|
||||
self.settings['crawling'].setdefault('keywords', default_crawl_keywords if default_crawl_keywords else ['1호선'])
|
||||
self.settings['crawling'].setdefault('recheck_hours', 3)
|
||||
self.settings['crawling'].setdefault('headless_mode', True)
|
||||
|
||||
self.settings.setdefault('noti', {})
|
||||
self.settings['noti'].setdefault('sound', True)
|
||||
self.settings['noti'].setdefault('db_check_interval_minutes', 3)
|
||||
self.settings['noti'].setdefault('unchecked_check_interval_minutes', 10)
|
||||
self.settings['noti'].setdefault('unchecked_delay_enabled', True)
|
||||
self.settings['noti'].setdefault('use_related_filter', True)
|
||||
|
||||
# 과거 알림 키워드 설정 정리 (현재는 수집 설정 키워드/부서를 기준으로 알림 판단)
|
||||
self.settings['noti'].pop('use_keywords', None)
|
||||
self.settings['noti'].pop('keywords', None)
|
||||
self.logger.info("설정 파일 로드 완료")
|
||||
except FileNotFoundError:
|
||||
# 기본 설정 생성
|
||||
|
|
@ -178,14 +200,16 @@ class AppController:
|
|||
"interval_minutes": 10,
|
||||
"max_pages": 2,
|
||||
"target_depts": ["차량"],
|
||||
"keywords": ["지연", "불만"],
|
||||
"keywords": ["1호선"],
|
||||
"recheck_hours": 3,
|
||||
"headless_mode": True
|
||||
},
|
||||
"noti": {
|
||||
"use_keywords": True,
|
||||
"keywords": ["긴급", "사고"],
|
||||
"sound": True
|
||||
"sound": True,
|
||||
"db_check_interval_minutes": 3,
|
||||
"unchecked_check_interval_minutes": 10,
|
||||
"unchecked_delay_enabled": True,
|
||||
"use_related_filter": True
|
||||
},
|
||||
"report": {
|
||||
"output_path": str(get_data_dir() / "reports")
|
||||
|
|
|
|||
|
|
@ -152,13 +152,17 @@ class SchedulerManager:
|
|||
crawl_interval = self.settings.get('crawling', {}).get('interval_minutes', 10)
|
||||
schedule.every(crawl_interval).minutes.do(self.run_crawling_cycle)
|
||||
|
||||
# DB 체크 주기 (고정: 5분)
|
||||
schedule.every(5).minutes.do(self.run_db_check_cycle)
|
||||
# DB 체크 주기 (설정값, 기본: 5분)
|
||||
noti_interval = self.settings.get('noti', {}).get('db_check_interval_minutes', 5)
|
||||
schedule.every(noti_interval).minutes.do(self.run_db_check_cycle)
|
||||
|
||||
# 미확인 글 체크 주기 (고정: 30분)
|
||||
schedule.every(30).minutes.do(self.run_unchecked_check_cycle)
|
||||
# 미확인 글 체크 주기 (설정값, 기본: 30분)
|
||||
unchecked_interval = self.settings.get('noti', {}).get('unchecked_check_interval_minutes', 30)
|
||||
schedule.every(unchecked_interval).minutes.do(self.run_unchecked_check_cycle)
|
||||
|
||||
self.logger.info(f"스케줄 업데이트됨: 크롤링 {crawl_interval}분 / 신규 알림 5분 / 미확인 체크 30분")
|
||||
self.logger.info(
|
||||
f"스케줄 업데이트됨: 크롤링 {crawl_interval}분 / 신규 알림 {noti_interval}분 / 미확인 체크 {unchecked_interval}분"
|
||||
)
|
||||
|
||||
def _scheduler_loop(self):
|
||||
"""
|
||||
|
|
@ -255,11 +259,8 @@ class SchedulerManager:
|
|||
if self.is_related_callback:
|
||||
post_obj.is_related = self.is_related_callback(post_obj.title, post_obj.department)
|
||||
|
||||
# DB 저장 (Pydantic v2 호환)
|
||||
try:
|
||||
save_data = post_obj.model_dump()
|
||||
except AttributeError:
|
||||
save_data = post_obj.dict() # Pydantic v1 Fallback
|
||||
# DB 저장 (Pydantic v2)
|
||||
save_data = post_obj.model_dump()
|
||||
|
||||
try:
|
||||
self.db.upsert_post(save_data)
|
||||
|
|
@ -306,26 +307,38 @@ class SchedulerManager:
|
|||
self.logger.info("신규 DB 데이터 확인 중...")
|
||||
|
||||
try:
|
||||
# 1. 마지막 체크 이후 수집된 관심글 조회
|
||||
raw_posts = self.db.get_new_posts_since(self.last_check_time)
|
||||
# 1. 마지막 체크 이후 수집된 신규글 조회
|
||||
noti_settings = self.settings.get('noti', {})
|
||||
use_related_filter = noti_settings.get('use_related_filter', True)
|
||||
raw_posts = self.db.get_new_posts_since(
|
||||
self.last_check_time,
|
||||
related_only=use_related_filter
|
||||
)
|
||||
self.last_check_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
self._save_last_check_time() # 영속화
|
||||
|
||||
if not raw_posts:
|
||||
mode_text = "관심글" if use_related_filter else "전체글"
|
||||
self.logger.debug(f"신규 알림 대상 없음: 마지막 체크 이후 {mode_text} 0건")
|
||||
return
|
||||
|
||||
# sqlite3.Row 객체를 dict로 변환
|
||||
new_posts = [dict(row) for row in raw_posts]
|
||||
|
||||
# 2. 알림 설정 로드
|
||||
noti_settings = self.settings.get('noti', {})
|
||||
use_sound = noti_settings.get('sound', True)
|
||||
|
||||
# 3. 중복 알림 방지 필터링
|
||||
valid_posts = self._filter_duplicate_notifications(new_posts)
|
||||
|
||||
if not valid_posts:
|
||||
self.logger.debug("신규 알림 대상 없음: 중복 필터링 후 0건")
|
||||
return
|
||||
|
||||
self.logger.info(
|
||||
f"신규 알림 발송: {len(valid_posts)}건 / 필터모드={'관심글' if use_related_filter else '전체글'} "
|
||||
f"(대상 ID: {[p.get('id') for p in valid_posts]})"
|
||||
)
|
||||
|
||||
# 4. 소리 재생 (설정 시)
|
||||
if use_sound:
|
||||
|
|
@ -410,40 +423,62 @@ class SchedulerManager:
|
|||
미확인 글 체크 사이클 실행
|
||||
|
||||
확인하지 않은 관심글을 주기적으로 체크하여 알림을 발생시킵니다.
|
||||
30분마다 실행되어 사용자가 놓친 글을 다시 알립니다.
|
||||
설정값(`unchecked_check_interval_minutes`)마다 실행됩니다.
|
||||
"""
|
||||
try:
|
||||
# 1. 확인하지 않은 관심글 조회
|
||||
unchecked_posts = self.db.get_unchecked_related_posts()
|
||||
noti_settings = self.settings.get('noti', {})
|
||||
use_related_filter = noti_settings.get('use_related_filter', True)
|
||||
use_delay_filter = noti_settings.get('unchecked_delay_enabled', True)
|
||||
unchecked_interval = noti_settings.get('unchecked_check_interval_minutes', 30)
|
||||
self.logger.info(
|
||||
f"미확인 글 DB 확인 중... (주기 {unchecked_interval}분 / 대상 {'관심글' if use_related_filter else '전체글'} "
|
||||
f"/ 30분 지연조건 {'ON' if use_delay_filter else 'OFF'})"
|
||||
)
|
||||
|
||||
# 1. 확인하지 않은 게시글 조회
|
||||
unchecked_posts = self.db.get_unchecked_related_posts(related_only=use_related_filter)
|
||||
|
||||
if not unchecked_posts:
|
||||
mode_text = "관심글" if use_related_filter else "전체글"
|
||||
self.logger.debug(f"미확인 알림 대상 없음: {mode_text} 미확인 0건")
|
||||
return
|
||||
|
||||
# sqlite3.Row 객체를 dict로 변환
|
||||
posts = [dict(row) for row in unchecked_posts]
|
||||
|
||||
# 2. 30분 이상 지난 글만 필터링 (즉시 알림과 구분)
|
||||
# 2. 30분 경과 조건(ON/OFF)에 따라 필터링
|
||||
from datetime import datetime, timedelta
|
||||
now = datetime.now()
|
||||
valid_posts = []
|
||||
|
||||
for post in posts:
|
||||
created_at = post.get('created_at', '')
|
||||
if created_at:
|
||||
try:
|
||||
post_time = datetime.strptime(created_at, "%Y-%m-%d %H:%M:%S")
|
||||
# 30분 이상 지난 글만 알림
|
||||
if now - post_time >= timedelta(minutes=30):
|
||||
|
||||
if use_delay_filter:
|
||||
for post in posts:
|
||||
created_at = post.get('created_at', '')
|
||||
if created_at:
|
||||
try:
|
||||
post_time = datetime.strptime(created_at, "%Y-%m-%d %H:%M:%S")
|
||||
# 30분 이상 지난 글만 알림
|
||||
if now - post_time >= timedelta(minutes=30):
|
||||
valid_posts.append(post)
|
||||
except Exception:
|
||||
# 날짜 파싱 실패 시 포함
|
||||
valid_posts.append(post)
|
||||
except:
|
||||
# 날짜 파싱 실패 시 포함
|
||||
else:
|
||||
# 생성 시간이 없으면 포함
|
||||
valid_posts.append(post)
|
||||
else:
|
||||
# 지연 조건 OFF: 미확인 글 전체 알림
|
||||
valid_posts = posts
|
||||
|
||||
if not valid_posts:
|
||||
self.logger.debug("미확인 알림 대상 없음: 조건 필터링 후 0건")
|
||||
return
|
||||
|
||||
self.logger.info(
|
||||
f"미확인 알림 발송: 원본 {len(posts)}건 중 {len(valid_posts)}건 (대상 ID: {[p.get('id') for p in valid_posts]})"
|
||||
)
|
||||
|
||||
# 3. 알림 설정 로드
|
||||
noti_settings = self.settings.get('noti', {})
|
||||
use_sound = noti_settings.get('sound', True)
|
||||
|
||||
# 4. 소리 재생 (설정 시) - 미확인 알림은 부드러운 알림
|
||||
|
|
@ -466,11 +501,16 @@ class SchedulerManager:
|
|||
Args:
|
||||
posts: 미확인 게시글 리스트
|
||||
"""
|
||||
if not self.notify_callback:
|
||||
return
|
||||
|
||||
if len(posts) == 1:
|
||||
# 단건 알림
|
||||
post = posts[0]
|
||||
title = f"⚠️ 미확인 VOC [{post['department']}]"
|
||||
msg = f"{post['title']}\n\n(30분 이상 미확인)"
|
||||
use_delay_filter = self.settings.get('noti', {}).get('unchecked_delay_enabled', True)
|
||||
suffix = "(30분 이상 미확인)" if use_delay_filter else "(미확인 글)"
|
||||
msg = f"{post['title']}\n\n{suffix}"
|
||||
self.notify_callback(title, msg, post['id'])
|
||||
else:
|
||||
# 다건 알림
|
||||
|
|
@ -484,6 +524,9 @@ class SchedulerManager:
|
|||
lines.append(f"... 외 {len(posts) - 5}건")
|
||||
|
||||
combined_msg = "\n".join(lines)
|
||||
self.logger.debug(
|
||||
f"미확인 다건 알림 내용: {[p.get('title', '') for p in posts[:5]]}"
|
||||
)
|
||||
self.notify_callback(summary_title, combined_msg, None)
|
||||
|
||||
def _show_notifications(self, posts: list):
|
||||
|
|
@ -495,6 +538,9 @@ class SchedulerManager:
|
|||
Args:
|
||||
posts: 알림 대상 게시글 리스트
|
||||
"""
|
||||
if not self.notify_callback:
|
||||
return
|
||||
|
||||
if len(posts) == 1:
|
||||
# 단건 알림
|
||||
post = posts[0]
|
||||
|
|
@ -512,4 +558,3 @@ class SchedulerManager:
|
|||
|
||||
combined_msg = "\n".join(lines)
|
||||
self.notify_callback(summary_title, combined_msg, None)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,47 @@
|
|||
"차량"
|
||||
],
|
||||
"keywords": [
|
||||
"지연",
|
||||
"불만"
|
||||
"1호선",
|
||||
"다대포해수욕장",
|
||||
"다대포항",
|
||||
"낫개",
|
||||
"신장림",
|
||||
"장림",
|
||||
"동매",
|
||||
"신평",
|
||||
"하단",
|
||||
"당리",
|
||||
"사하",
|
||||
"괴정",
|
||||
"대티",
|
||||
"서대신",
|
||||
"동대신",
|
||||
"토성",
|
||||
"자갈치",
|
||||
"남포",
|
||||
"중앙",
|
||||
"부산역",
|
||||
"초량",
|
||||
"부산진",
|
||||
"좌천",
|
||||
"범일",
|
||||
"범내골",
|
||||
"서면",
|
||||
"부전",
|
||||
"양정",
|
||||
"시청",
|
||||
"연산",
|
||||
"교대",
|
||||
"동래",
|
||||
"명륜",
|
||||
"온천장",
|
||||
"부산대",
|
||||
"장전",
|
||||
"구서",
|
||||
"두실",
|
||||
"남산",
|
||||
"범어사",
|
||||
"노포"
|
||||
],
|
||||
"recheck_hours": 3,
|
||||
"headless_mode": true
|
||||
|
|
@ -98,5 +137,12 @@
|
|||
"train_set": "27",
|
||||
"car_str": "1",
|
||||
"reporter_tel": "200-5115"
|
||||
},
|
||||
"noti": {
|
||||
"sound": true,
|
||||
"use_related_filter": true,
|
||||
"db_check_interval_minutes": 3,
|
||||
"unchecked_check_interval_minutes": 10,
|
||||
"unchecked_delay_enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ Pydantic을 사용하여 데이터 유효성을 입력 시점에 엄격하게
|
|||
최종 수정: 2026-02-17
|
||||
버전: 2.0 (데이터 검증 강화)
|
||||
"""
|
||||
from pydantic import BaseModel, Field, validator, root_validator
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator, ValidationInfo
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
|
@ -92,9 +92,8 @@ class VOCPost(BaseModel):
|
|||
)
|
||||
|
||||
department: str = Field(
|
||||
...,
|
||||
default="",
|
||||
description="담당 부서 (예: 차량, 시설, 역무)",
|
||||
min_length=1,
|
||||
max_length=50
|
||||
)
|
||||
|
||||
|
|
@ -200,7 +199,8 @@ class VOCPost(BaseModel):
|
|||
# Validators (데이터 유효성 검사)
|
||||
# ========================================================================
|
||||
|
||||
@validator('id')
|
||||
@field_validator('id')
|
||||
@classmethod
|
||||
def validate_id(cls, v):
|
||||
"""
|
||||
ID 검증: 비어있지 않고, 공백만 있지 않은지 확인
|
||||
|
|
@ -218,119 +218,165 @@ class VOCPost(BaseModel):
|
|||
raise ValueError("ID는 비어있을 수 없습니다.")
|
||||
return v.strip()
|
||||
|
||||
@validator('title', 'writer', 'department', 'status')
|
||||
def validate_required_strings(cls, v, field):
|
||||
@field_validator('title', 'writer', 'status')
|
||||
@classmethod
|
||||
def validate_required_strings(cls, v, info: ValidationInfo):
|
||||
"""
|
||||
필수 문자열 필드 검증: 비어있지 않은지 확인
|
||||
|
||||
|
||||
department는 비어있을 수 있으므로 제외합니다.
|
||||
|
||||
Args:
|
||||
v: 검증할 값
|
||||
field: 필드 정보
|
||||
|
||||
info: 검증 정보
|
||||
|
||||
Returns:
|
||||
str: 검증된 값 (앞뒤 공백 제거)
|
||||
|
||||
|
||||
Raises:
|
||||
ValueError: 값이 비어있거나 공백만 있는 경우
|
||||
"""
|
||||
if not v or not v.strip():
|
||||
raise ValueError(f"{field.name}은(는) 비어있을 수 없습니다.")
|
||||
raise ValueError(f"{info.field_name}은(는) 비어있을 수 없습니다.")
|
||||
return v.strip()
|
||||
|
||||
@validator('is_public', 'is_related')
|
||||
def validate_binary_int(cls, v, field):
|
||||
|
||||
@field_validator('department')
|
||||
@classmethod
|
||||
def validate_department(cls, v):
|
||||
"""
|
||||
이진 정수 필드 검증: 0 또는 1만 허용
|
||||
|
||||
부서 필드 검증: 비어있을 수 있으며, 공백 제거만 수행
|
||||
|
||||
웹사이트에서 부서 정보가 비어있는 경우가 있어
|
||||
빈 문자열을 허용합니다.
|
||||
|
||||
Args:
|
||||
v: 검증할 값
|
||||
field: 필드 정보
|
||||
|
||||
|
||||
Returns:
|
||||
str: 공백이 제거된 값 (빈 문자열 가능)
|
||||
"""
|
||||
if v is None:
|
||||
return ""
|
||||
return v.strip()
|
||||
|
||||
@field_validator('is_public', 'is_related')
|
||||
@classmethod
|
||||
def validate_binary_int(cls, v, info: ValidationInfo):
|
||||
"""
|
||||
이진 정수 필드 검증: 0 또는 1만 허용
|
||||
|
||||
크롤링 데이터에서 문자열 "1"이나 "0"이 들어올 수 있어
|
||||
정수로 변환 후 검증합니다.
|
||||
|
||||
Args:
|
||||
v: 검증할 값
|
||||
info: 검증 정보
|
||||
|
||||
Returns:
|
||||
int: 검증된 값 (0 또는 1)
|
||||
|
||||
|
||||
Raises:
|
||||
ValueError: 0 또는 1이 아닌 경우
|
||||
"""
|
||||
# 문자열인 경우 정수로 변환
|
||||
if isinstance(v, str):
|
||||
if v not in ("0", "1"):
|
||||
raise ValueError(f"{info.field_name}은(는) 0 또는 1만 가능합니다. (입력값: {v})")
|
||||
return int(v)
|
||||
|
||||
if v not in (0, 1):
|
||||
raise ValueError(f"{field.name}은(는) 0 또는 1만 가능합니다. (입력값: {v})")
|
||||
raise ValueError(f"{info.field_name}은(는) 0 또는 1만 가능합니다. (입력값: {v})")
|
||||
return v
|
||||
|
||||
@validator('date')
|
||||
@field_validator('date')
|
||||
@classmethod
|
||||
def validate_date_format(cls, v):
|
||||
"""
|
||||
날짜 형식 검증: YYYY-MM-DD HH:MM:SS 형식 확인 (선택)
|
||||
|
||||
날짜 형식 검증: YYYY-MM-DD HH:MM:SS 또는 YYYY-MM-DD HH:MM 형식 확인
|
||||
|
||||
빈 문자열은 허용합니다 (초기 상태).
|
||||
날짜가 입력된 경우에만 형식을 검증합니다.
|
||||
|
||||
HH:MM 형식(초 없음)이 들어오면 HH:MM:00으로 변환합니다.
|
||||
|
||||
Args:
|
||||
v: 검증할 날짜 문자열
|
||||
|
||||
|
||||
Returns:
|
||||
str: 검증된 날짜 문자열
|
||||
|
||||
str: 검증된 날짜 문자열 (YYYY-MM-DD HH:MM:SS 형식)
|
||||
|
||||
Raises:
|
||||
ValueError: 날짜 형식이 잘못된 경우
|
||||
"""
|
||||
if not v: # 빈 문자열은 허용
|
||||
return v
|
||||
|
||||
|
||||
# YYYY-MM-DD HH:MM:SS 형식 검증
|
||||
pattern = r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'
|
||||
if not re.match(pattern, v):
|
||||
pattern_full = r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'
|
||||
# YYYY-MM-DD HH:MM 형식 검증 (초 없음)
|
||||
pattern_short = r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$'
|
||||
|
||||
if re.match(pattern_full, v):
|
||||
# 완전한 형식 - 그대로 검증
|
||||
try:
|
||||
datetime.strptime(v, "%Y-%m-%d %H:%M:%S")
|
||||
return v
|
||||
except ValueError as e:
|
||||
raise ValueError(f"유효하지 않은 날짜입니다: {v} ({e})")
|
||||
|
||||
elif re.match(pattern_short, v):
|
||||
# 초 없는 형식 - :00 추가
|
||||
v_full = f"{v}:00"
|
||||
try:
|
||||
datetime.strptime(v_full, "%Y-%m-%d %H:%M:%S")
|
||||
return v_full
|
||||
except ValueError as e:
|
||||
raise ValueError(f"유효하지 않은 날짜입니다: {v} ({e})")
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
f"날짜 형식이 잘못되었습니다. "
|
||||
f"'YYYY-MM-DD HH:MM:SS' 형식이어야 합니다. (입력값: {v})"
|
||||
f"'YYYY-MM-DD HH:MM:SS' 또는 'YYYY-MM-DD HH:MM' 형식이어야 합니다. (입력값: {v})"
|
||||
)
|
||||
|
||||
# 실제 날짜 유효성 검증 (예: 2월 30일 방지)
|
||||
try:
|
||||
datetime.strptime(v, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError as e:
|
||||
raise ValueError(f"유효하지 않은 날짜입니다: {v} ({e})")
|
||||
|
||||
return v
|
||||
|
||||
@root_validator
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def validate_attachment(cls, values):
|
||||
"""
|
||||
첨부파일 필드 정규화: "없음" → None 변환
|
||||
|
||||
|
||||
크롤링 결과에서 "없음"이 들어오는 경우가 있어,
|
||||
이를 None으로 통일합니다.
|
||||
|
||||
|
||||
Args:
|
||||
values: 전체 필드 값 딕셔너리
|
||||
|
||||
|
||||
Returns:
|
||||
dict: 정규화된 필드 값
|
||||
"""
|
||||
attachment = values.get('attachment')
|
||||
if attachment and attachment.strip() in ("없음", "N/A", "-"):
|
||||
values['attachment'] = None
|
||||
if isinstance(values, dict):
|
||||
attachment = values.get('attachment')
|
||||
if attachment and isinstance(attachment, str) and attachment.strip() in ("없음", "N/A", "-"):
|
||||
values['attachment'] = None
|
||||
return values
|
||||
|
||||
# ========================================================================
|
||||
# Config (설정)
|
||||
# ========================================================================
|
||||
|
||||
class Config:
|
||||
"""Pydantic 모델 설정"""
|
||||
|
||||
|
||||
# Pydantic v2 모델 설정
|
||||
model_config = {
|
||||
# datetime을 ISO 형식 문자열로 변환
|
||||
json_encoders = {
|
||||
"json_encoders": {
|
||||
datetime: lambda v: v.isoformat() if v else None
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
# 추가 필드 허용 안 함 (엄격 모드)
|
||||
extra = 'forbid'
|
||||
|
||||
# 필드 별칭 허용
|
||||
allow_population_by_field_name = True
|
||||
|
||||
# 스키마 예시
|
||||
schema_extra = {
|
||||
"extra": 'forbid',
|
||||
|
||||
# 필드 별칭 허용 (v2: populate_by_name)
|
||||
"populate_by_name": True,
|
||||
|
||||
# 스키마 예시 (v2: json_schema_extra)
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"id": "12345",
|
||||
"title": "1호선 서면역 소음 발생",
|
||||
|
|
@ -345,4 +391,4 @@ class VOCPost(BaseModel):
|
|||
"attachment": None
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
"""
|
||||
자동 업데이트 모듈
|
||||
|
||||
이 패키지는 VOC 모니터링 시스템의 자동 업데이트 기능을 제공합니다.
|
||||
|
||||
구성 요소:
|
||||
- __version__.py: 버전 정보
|
||||
- update_manager.py: 메인 프로그램용 업데이트 관리자
|
||||
- updater_gui.py: updater.exe용 GUI
|
||||
|
||||
사용 예시:
|
||||
from app.updater import UpdateManager, VERSION
|
||||
|
||||
updater = UpdateManager(
|
||||
supabase_url="https://xxx.supabase.co",
|
||||
supabase_key="xxx"
|
||||
)
|
||||
|
||||
version_info = updater.check_for_updates()
|
||||
if version_info:
|
||||
updater.prepare_update(version_info)
|
||||
updater.launch_updater()
|
||||
|
||||
작성자: KH.Choi
|
||||
최종 수정: 2026-02-18
|
||||
"""
|
||||
|
||||
from .__version__ import APP_NAME, PROGRAM_ID, VERSION
|
||||
from .update_manager import (
|
||||
ConfigError,
|
||||
NetworkError,
|
||||
UpdateConfig,
|
||||
UpdateError,
|
||||
UpdateManager,
|
||||
VersionInfo,
|
||||
compare_versions,
|
||||
create_update_manager_from_settings,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# 버전 정보
|
||||
"VERSION",
|
||||
"PROGRAM_ID",
|
||||
"APP_NAME",
|
||||
# 클래스
|
||||
"UpdateManager",
|
||||
"VersionInfo",
|
||||
"UpdateConfig",
|
||||
# 예외
|
||||
"UpdateError",
|
||||
"NetworkError",
|
||||
"ConfigError",
|
||||
# 함수
|
||||
"compare_versions",
|
||||
"create_update_manager_from_settings",
|
||||
]
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
"""
|
||||
버전 정보
|
||||
|
||||
이 파일은 프로그램의 현재 버전을 정의합니다.
|
||||
업데이트 시 이 파일의 VERSION과 비교하여 확인합니다.
|
||||
|
||||
작성자: KH.Choi
|
||||
최종 수정: 2026-02-18
|
||||
"""
|
||||
|
||||
# 현재 버전 (semver 형식)
|
||||
VERSION = "3.2.0"
|
||||
|
||||
# 프로그램 식별자 (Supabase program_version 테이블의 program_id)
|
||||
PROGRAM_ID = "voc_monitor"
|
||||
|
||||
# 프로그램 이름 (UI 표시용)
|
||||
APP_NAME = "VOC 모니터링"
|
||||
|
||||
# 업데이트 체크 주기 (시간)
|
||||
UPDATE_CHECK_INTERVAL_HOURS = 1
|
||||
|
|
@ -0,0 +1,424 @@
|
|||
"""
|
||||
업데이트 관리자
|
||||
|
||||
메인 프로그램에서 사용하는 업데이트 관리 모듈입니다.
|
||||
Supabase에서 버전 정보를 조회하고 업데이트를 준비합니다.
|
||||
|
||||
작성자: KH.Choi
|
||||
최종 수정: 2026-02-18
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .__version__ import APP_NAME, PROGRAM_ID, VERSION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 데이터 클래스
|
||||
# ============================================================================
|
||||
|
||||
@dataclass
|
||||
class VersionInfo:
|
||||
"""
|
||||
Supabase에서 조회한 버전 정보
|
||||
|
||||
Attributes:
|
||||
version: 버전 번호
|
||||
is_stable: 안정판 여부
|
||||
release_note: 배포 노트
|
||||
download_url: 다운로드 URL
|
||||
min_required_version: 최소 요구 버전
|
||||
"""
|
||||
version: str
|
||||
is_stable: bool
|
||||
release_note: Optional[str] = None
|
||||
download_url: Optional[str] = None
|
||||
min_required_version: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateConfig:
|
||||
"""
|
||||
updater.exe로 전달되는 구성 정보
|
||||
|
||||
Attributes:
|
||||
download_url: 다운로드 URL (zip 파일)
|
||||
target_path: 설치 대상 경로
|
||||
version: 업데이트할 버전
|
||||
restart_exe: 업데이트 후 실행할 실행파일명
|
||||
"""
|
||||
download_url: str
|
||||
target_path: str
|
||||
version: str
|
||||
restart_exe: str = "voc_noti.exe"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 예외 클래스
|
||||
# ============================================================================
|
||||
|
||||
class UpdateError(Exception):
|
||||
"""업데이트 관련 기본 예외"""
|
||||
pass
|
||||
|
||||
|
||||
class NetworkError(UpdateError):
|
||||
"""네트워크 연결 실패"""
|
||||
pass
|
||||
|
||||
|
||||
class ConfigError(UpdateError):
|
||||
"""설정 파일 오류"""
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 버전 비교 유틸리티
|
||||
# ============================================================================
|
||||
|
||||
def compare_versions(v1: str, v2: str) -> int:
|
||||
"""
|
||||
버전 비교
|
||||
|
||||
Args:
|
||||
v1: 첫 번째 버전 (예: "3.2.0")
|
||||
v2: 두 번째 버전 (예: "3.1.0")
|
||||
|
||||
Returns:
|
||||
-1: v1 < v2
|
||||
0: v1 == v2
|
||||
1: v1 > v2
|
||||
"""
|
||||
try:
|
||||
parts1 = [int(x) for x in v1.split('.')]
|
||||
parts2 = [int(x) for x in v2.split('.')]
|
||||
|
||||
# 길이 맞추기
|
||||
max_len = max(len(parts1), len(parts2))
|
||||
parts1.extend([0] * (max_len - len(parts1)))
|
||||
parts2.extend([0] * (max_len - len(parts2)))
|
||||
|
||||
for p1, p2 in zip(parts1, parts2):
|
||||
if p1 < p2:
|
||||
return -1
|
||||
elif p1 > p2:
|
||||
return 1
|
||||
return 0
|
||||
except (ValueError, AttributeError):
|
||||
logger.warning(f"버전 비교 실패: {v1} vs {v2}")
|
||||
return 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# UpdateManager 클래스
|
||||
# ============================================================================
|
||||
|
||||
class UpdateManager:
|
||||
"""
|
||||
자동 업데이트 관리자
|
||||
|
||||
Supabase에서 버전 정보를 조회하고 업데이트를 처리합니다.
|
||||
|
||||
Attributes:
|
||||
program_id: 프로그램 식별자
|
||||
current_version: 현재 버전
|
||||
supabase_url: Supabase 프로젝트 URL
|
||||
supabase_key: Supabase API 키
|
||||
check_interval: 업데이트 체크 간격 (시간)
|
||||
|
||||
사용 예시:
|
||||
updater = UpdateManager(
|
||||
supabase_url="https://xxx.supabase.co",
|
||||
supabase_key="xxx"
|
||||
)
|
||||
|
||||
# 업데이트 확인
|
||||
version_info = updater.check_for_updates()
|
||||
if version_info:
|
||||
# 업데이트 준비 및 실행
|
||||
updater.prepare_update(version_info)
|
||||
updater.launch_updater()
|
||||
"""
|
||||
|
||||
CONFIG_FILENAME = "voc_updater_config.json"
|
||||
UPDATER_EXE_NAME = "updater.exe"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
supabase_url: str,
|
||||
supabase_key: str,
|
||||
program_id: str = PROGRAM_ID,
|
||||
current_version: str = VERSION,
|
||||
check_interval: int = 1
|
||||
):
|
||||
"""
|
||||
UpdateManager 초기화
|
||||
|
||||
Args:
|
||||
supabase_url: Supabase 프로젝트 URL
|
||||
supabase_key: Supabase API 키 (anon key)
|
||||
program_id: 프로그램 식별자
|
||||
current_version: 현재 버전
|
||||
check_interval: 업데이트 체크 간격 (시간)
|
||||
"""
|
||||
self.program_id = program_id
|
||||
self.current_version = current_version
|
||||
self.supabase_url = supabase_url.rstrip('/')
|
||||
self.supabase_key = supabase_key
|
||||
self.check_interval = check_interval
|
||||
|
||||
self._latest_version: Optional[VersionInfo] = None
|
||||
self._stop_flag = threading.Event()
|
||||
|
||||
@property
|
||||
def config_path(self) -> Path:
|
||||
"""업데이트 설정 파일 경로"""
|
||||
return Path(tempfile.gettempdir()) / self.CONFIG_FILENAME
|
||||
|
||||
@property
|
||||
def temp_updater_path(self) -> Path:
|
||||
"""임시 updater.exe 경로"""
|
||||
return Path(tempfile.gettempdir()) / self.UPDATER_EXE_NAME
|
||||
|
||||
def check_for_updates(self) -> Optional[VersionInfo]:
|
||||
"""
|
||||
Supabase에서 최신 버전 확인
|
||||
|
||||
Returns:
|
||||
VersionInfo: 업데이트 가능한 경우 버전 정보
|
||||
None: 업데이트 없음
|
||||
"""
|
||||
try:
|
||||
# Supabase REST API 호출
|
||||
url = f"{self.supabase_url}/rest/v1/program_version"
|
||||
headers = {
|
||||
"apikey": self.supabase_key,
|
||||
"Authorization": f"Bearer {self.supabase_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
params = {
|
||||
"select": "*",
|
||||
"program_id": f"eq.{self.program_id}",
|
||||
"is_stable": "eq.true",
|
||||
"order": "created_at.desc",
|
||||
"limit": "1"
|
||||
}
|
||||
|
||||
logger.info("업데이트 확인 중...")
|
||||
response = requests.get(url, headers=headers, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
if not data:
|
||||
logger.info("등록된 버전 정보가 없습니다.")
|
||||
return None
|
||||
|
||||
latest = data[0]
|
||||
version_info = VersionInfo(
|
||||
version=latest.get("version", ""),
|
||||
is_stable=latest.get("is_stable", True),
|
||||
release_note=latest.get("release_note"),
|
||||
download_url=latest.get("download_url"),
|
||||
min_required_version=latest.get("min_required_version")
|
||||
)
|
||||
|
||||
self._latest_version = version_info
|
||||
|
||||
# 버전 비교
|
||||
if compare_versions(self.current_version, version_info.version) < 0:
|
||||
logger.info(f"새 버전 발견: {version_info.version}")
|
||||
return version_info
|
||||
else:
|
||||
logger.info(f"최신 버전 사용 중: {self.current_version}")
|
||||
return None
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning("업데이트 서버 연결 시간 초과")
|
||||
raise NetworkError("서버 연결 시간 초과")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"업데이트 확인 실패: {e}")
|
||||
raise NetworkError(f"네트워크 오류: {e}")
|
||||
except (KeyError, json.JSONDecodeError) as e:
|
||||
logger.error(f"버전 정보 파싱 오류: {e}")
|
||||
return None
|
||||
|
||||
def is_update_available(self) -> bool:
|
||||
"""
|
||||
업데이트 필요 여부 확인 (캐시된 버전 정보 사용)
|
||||
|
||||
Returns:
|
||||
bool: 업데이트 가능 여부
|
||||
"""
|
||||
if self._latest_version is None:
|
||||
return False
|
||||
return compare_versions(self.current_version, self._latest_version.version) < 0
|
||||
|
||||
def prepare_update(self, version_info: VersionInfo) -> tuple[bool, str]:
|
||||
"""
|
||||
업데이트 준비
|
||||
|
||||
config.json을 생성하고 updater.exe를 임시 폴더로 복사합니다.
|
||||
|
||||
Args:
|
||||
version_info: 업데이트할 버전 정보
|
||||
|
||||
Returns:
|
||||
tuple[bool, str]: (성공 여부, 메시지)
|
||||
"""
|
||||
try:
|
||||
# 다운로드 URL 확인
|
||||
if not version_info.download_url:
|
||||
return False, "다운로드 URL이 없습니다."
|
||||
|
||||
# 설치 경로 확인 (실행 파일 위치)
|
||||
exe_path = Path(__file__).parent.parent.parent # app/updater -> app -> project_root
|
||||
if getattr(os, 'frozen', False):
|
||||
# cx_freeze로 패킹된 경우
|
||||
exe_path = Path(sys.executable).parent
|
||||
|
||||
# config.json 생성
|
||||
config = UpdateConfig(
|
||||
download_url=version_info.download_url,
|
||||
target_path=str(exe_path),
|
||||
version=version_info.version,
|
||||
restart_exe="voc_noti.exe"
|
||||
)
|
||||
|
||||
with open(self.config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump({
|
||||
"download_url": config.download_url,
|
||||
"target_path": config.target_path,
|
||||
"version": config.version,
|
||||
"restart_exe": config.restart_exe
|
||||
}, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"업데이트 설정 저장: {self.config_path}")
|
||||
|
||||
# updater.exe 복사
|
||||
updater_src = exe_path / self.UPDATER_EXE_NAME
|
||||
if updater_src.exists():
|
||||
shutil.copy2(updater_src, self.temp_updater_path)
|
||||
logger.info(f"updater.exe 복사 완료: {self.temp_updater_path}")
|
||||
else:
|
||||
logger.warning(f"updater.exe를 찾을 수 없습니다: {updater_src}")
|
||||
# 개발 환경에서는 updater.exe가 없을 수 있음
|
||||
|
||||
return True, "업데이트 준비 완료"
|
||||
|
||||
except PermissionError as e:
|
||||
logger.error(f"파일 권한 오류: {e}")
|
||||
return False, f"파일 권한 오류: {e}"
|
||||
except Exception as e:
|
||||
logger.error(f"업데이트 준비 실패: {e}")
|
||||
return False, str(e)
|
||||
|
||||
def launch_updater(self) -> bool:
|
||||
"""
|
||||
updater.exe 실행
|
||||
|
||||
Returns:
|
||||
bool: 실행 성공 여부
|
||||
"""
|
||||
try:
|
||||
if not self.temp_updater_path.exists():
|
||||
logger.error(f"updater.exe가 없습니다: {self.temp_updater_path}")
|
||||
return False
|
||||
|
||||
# updater.exe 실행
|
||||
subprocess.Popen(
|
||||
[str(self.temp_updater_path)],
|
||||
cwd=str(self.temp_updater_path.parent),
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
)
|
||||
|
||||
logger.info("updater.exe 실행 완료")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"updater.exe 실행 실패: {e}")
|
||||
return False
|
||||
|
||||
def start_background_check(
|
||||
self,
|
||||
on_update_available: Optional[Callable[[VersionInfo], None]] = None,
|
||||
on_error: Optional[Callable[[Exception], None]] = None
|
||||
) -> None:
|
||||
"""
|
||||
백그라운드 업데이트 체크 시작
|
||||
|
||||
Args:
|
||||
on_update_available: 업데이트 발견 시 콜백
|
||||
on_error: 에러 발생 시 콜백
|
||||
"""
|
||||
def _check_loop():
|
||||
while not self._stop_flag.is_set():
|
||||
try:
|
||||
version_info = self.check_for_updates()
|
||||
if version_info and on_update_available:
|
||||
on_update_available(version_info)
|
||||
except Exception as e:
|
||||
logger.warning(f"백그라운드 업데이트 체크 오류: {e}")
|
||||
if on_error:
|
||||
on_error(e)
|
||||
|
||||
# 다음 체크까지 대기
|
||||
self._stop_flag.wait(self.check_interval * 3600)
|
||||
|
||||
thread = threading.Thread(target=_check_loop, daemon=True)
|
||||
thread.start()
|
||||
logger.info(f"백그라운드 업데이트 체크 시작 (주기: {self.check_interval}시간)")
|
||||
|
||||
def stop_background_check(self) -> None:
|
||||
"""백그라운드 업데이트 체크 중지"""
|
||||
self._stop_flag.set()
|
||||
logger.info("백그라운드 업데이트 체크 중지")
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""임시 파일 정리"""
|
||||
try:
|
||||
if self.config_path.exists():
|
||||
self.config_path.unlink()
|
||||
if self.temp_updater_path.exists():
|
||||
self.temp_updater_path.unlink()
|
||||
logger.info("업데이트 임시 파일 정리 완료")
|
||||
except Exception as e:
|
||||
logger.warning(f"임시 파일 정리 실패: {e}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 편의 함수
|
||||
# ============================================================================
|
||||
|
||||
def create_update_manager_from_settings(settings: dict) -> UpdateManager:
|
||||
"""
|
||||
settings.json에서 UpdateManager 생성
|
||||
|
||||
Args:
|
||||
settings: settings.json에서 로드한 딕셔너리
|
||||
|
||||
Returns:
|
||||
UpdateManager 인스턴스
|
||||
"""
|
||||
update_settings = settings.get('update', {})
|
||||
|
||||
return UpdateManager(
|
||||
supabase_url=update_settings.get('supabase_url', ''),
|
||||
supabase_key=update_settings.get('supabase_key', ''),
|
||||
program_id=PROGRAM_ID,
|
||||
current_version=VERSION,
|
||||
check_interval=update_settings.get('check_interval_hours', 1)
|
||||
)
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# 업데이트 로그
|
||||
|
||||
## v3.2.0 (2026-02-18)
|
||||
- 자동 업데이트 시스템 구현
|
||||
- UpdateManager: Supabase 기반 버전 체크
|
||||
- UpdaterGUI: 별도 실행파일용 업데이트 GUI
|
||||
- updater.exe 분리 아키텍처 적용
|
||||
- updatelog.md 위치 변경 (docs/ → app/updater/)
|
||||
|
||||
## v3.1.0 (2026-02-18)
|
||||
- 크롤링 필터링 개선 (AND/OR 모드 지원)
|
||||
- Z-order 문제 해결 (설정/통계 다이얼로그)
|
||||
- 알람 시스템 개선 (미확인 글 알림, 영속화)
|
||||
|
||||
## v3.0.0 (2026-02-17)
|
||||
- Controller 리팩토링
|
||||
- Manager 패턴 적용 (ReportManager, FileManager, UIManager)
|
||||
- SchedulerManager 분리
|
||||
|
|
@ -0,0 +1,455 @@
|
|||
"""
|
||||
업데이터 GUI
|
||||
|
||||
updater.exe용 최소 GUI 모듈입니다.
|
||||
CustomTkinter 기반으로 다운로드 진행률과 상태를 표시합니다.
|
||||
|
||||
이 파일은 별도의 exe로 패킹되어 메인 프로그램과 함께 배포됩니다.
|
||||
|
||||
작성자: KH.Choi
|
||||
최종 수정: 2026-02-18
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import customtkinter as ctk
|
||||
import requests
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# CustomTkinter 설정
|
||||
ctk.set_appearance_mode("System")
|
||||
ctk.set_default_color_theme("blue")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 데이터 클래스
|
||||
# ============================================================================
|
||||
|
||||
@dataclass
|
||||
class UpdateConfig:
|
||||
"""
|
||||
업데이트 구성 정보
|
||||
|
||||
updater.exe가 읽는 config.json의 구조입니다.
|
||||
"""
|
||||
download_url: str
|
||||
target_path: str
|
||||
version: str
|
||||
restart_exe: str = "voc_noti.exe"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 예외 클래스
|
||||
# ============================================================================
|
||||
|
||||
class UpdaterError(Exception):
|
||||
"""업데이터 기본 예외"""
|
||||
pass
|
||||
|
||||
|
||||
class DownloadError(UpdaterError):
|
||||
"""다운로드 실패"""
|
||||
pass
|
||||
|
||||
|
||||
class ExtractError(UpdaterError):
|
||||
"""압축 해제 실패"""
|
||||
pass
|
||||
|
||||
|
||||
class ConfigError(UpdaterError):
|
||||
"""설정 파일 오류"""
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# UpdaterGUI 클래스
|
||||
# ============================================================================
|
||||
|
||||
class UpdaterGUI(ctk.CTk):
|
||||
"""
|
||||
업데이터 GUI 창
|
||||
|
||||
CustomTkinter 기반 최소 GUI로 다음 기능을 제공합니다:
|
||||
- 다운로드 진행률 표시 (Progress Bar)
|
||||
- 상태 메시지 표시
|
||||
- 성공/실패 결과 표시
|
||||
|
||||
실행 흐름:
|
||||
1. config.json 로드
|
||||
2. zip 다운로드
|
||||
3. 압축 해제 및 파일 교체
|
||||
4. 메인 프로그램 재실행
|
||||
"""
|
||||
|
||||
CONFIG_FILENAME = "voc_updater_config.json"
|
||||
|
||||
def __init__(self):
|
||||
"""UpdaterGUI 초기화"""
|
||||
super().__init__()
|
||||
|
||||
self.title("VOC 모니터링 업데이터")
|
||||
self.geometry("450x280")
|
||||
self.resizable(False, False)
|
||||
|
||||
# 창을 화면 중앙에 배치
|
||||
self._center_window()
|
||||
|
||||
# 변수
|
||||
self.config: Optional[UpdateConfig] = None
|
||||
self.download_path: Optional[Path] = None
|
||||
|
||||
# UI 구성
|
||||
self._setup_ui()
|
||||
|
||||
# 업데이트 시작
|
||||
self.after(100, self._start_update)
|
||||
|
||||
def _center_window(self):
|
||||
"""창을 화면 중앙에 배치"""
|
||||
self.update_idletasks()
|
||||
width = 450
|
||||
height = 280
|
||||
x = (self.winfo_screenwidth() // 2) - (width // 2)
|
||||
y = (self.winfo_screenheight() // 2) - (height // 2)
|
||||
self.geometry(f'{width}x{height}+{x}+{y}')
|
||||
|
||||
def _setup_ui(self):
|
||||
"""UI 구성"""
|
||||
# 메인 프레임
|
||||
self.main_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
self.main_frame.pack(fill="both", expand=True, padx=20, pady=20)
|
||||
|
||||
# 아이콘/로고 라벨
|
||||
self.icon_label = ctk.CTkLabel(
|
||||
self.main_frame,
|
||||
text="🔄",
|
||||
font=ctk.CTkFont(size=48)
|
||||
)
|
||||
self.icon_label.pack(pady=(0, 10))
|
||||
|
||||
# 상태 라벨
|
||||
self.status_label = ctk.CTkLabel(
|
||||
self.main_frame,
|
||||
text="업데이트 준비 중...",
|
||||
font=ctk.CTkFont(size=14, weight="bold")
|
||||
)
|
||||
self.status_label.pack(pady=(0, 10))
|
||||
|
||||
# 진행률 바
|
||||
self.progress_bar = ctk.CTkProgressBar(
|
||||
self.main_frame,
|
||||
width=400,
|
||||
height=20
|
||||
)
|
||||
self.progress_bar.set(0)
|
||||
self.progress_bar.pack(pady=(0, 10))
|
||||
|
||||
# 세부 정보 라벨
|
||||
self.detail_label = ctk.CTkLabel(
|
||||
self.main_frame,
|
||||
text="",
|
||||
font=ctk.CTkFont(size=11),
|
||||
text_color="gray"
|
||||
)
|
||||
self.detail_label.pack(pady=(0, 10))
|
||||
|
||||
# 버튼 프레임 (초기에는 숨김)
|
||||
self.button_frame = ctk.CTkFrame(self.main_frame, fg_color="transparent")
|
||||
|
||||
self.close_button = ctk.CTkButton(
|
||||
self.button_frame,
|
||||
text="닫기",
|
||||
width=100,
|
||||
command=self._on_close
|
||||
)
|
||||
self.close_button.pack(side="left", padx=5)
|
||||
|
||||
self.retry_button = ctk.CTkButton(
|
||||
self.button_frame,
|
||||
text="재시도",
|
||||
width=100,
|
||||
command=self._start_update
|
||||
)
|
||||
self.retry_button.pack(side="left", padx=5)
|
||||
|
||||
def _update_status(self, status: str, detail: str = ""):
|
||||
"""상태 업데이트"""
|
||||
self.status_label.configure(text=status)
|
||||
self.detail_label.configure(text=detail)
|
||||
self.update_idletasks()
|
||||
|
||||
def _update_progress(self, value: float, detail: str = ""):
|
||||
"""진행률 업데이트"""
|
||||
self.progress_bar.set(value)
|
||||
if detail:
|
||||
self.detail_label.configure(text=detail)
|
||||
self.update_idletasks()
|
||||
|
||||
def _show_buttons(self, show_retry: bool = False):
|
||||
"""버튼 표시"""
|
||||
self.retry_button.pack_forget()
|
||||
if show_retry:
|
||||
self.retry_button.pack(side="left", padx=5)
|
||||
self.button_frame.pack(pady=10)
|
||||
|
||||
def _load_config(self) -> UpdateConfig:
|
||||
"""
|
||||
config.json 로드
|
||||
|
||||
Returns:
|
||||
UpdateConfig: 업데이트 구성 정보
|
||||
|
||||
Raises:
|
||||
ConfigError: 설정 파일을 찾을 수 없거나 파싱 실패
|
||||
"""
|
||||
config_path = Path(tempfile.gettempdir()) / self.CONFIG_FILENAME
|
||||
|
||||
if not config_path.exists():
|
||||
raise ConfigError(f"설정 파일을 찾을 수 없습니다: {config_path}")
|
||||
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
config = UpdateConfig(
|
||||
download_url=data.get('download_url', ''),
|
||||
target_path=data.get('target_path', ''),
|
||||
version=data.get('version', ''),
|
||||
restart_exe=data.get('restart_exe', 'voc_noti.exe')
|
||||
)
|
||||
|
||||
logger.info(f"설정 로드 완료: 버전 {config.version}")
|
||||
return config
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
raise ConfigError(f"설정 파일 파싱 실패: {e}")
|
||||
except KeyError as e:
|
||||
raise ConfigError(f"필수 설정 누락: {e}")
|
||||
|
||||
def _download_zip(self) -> Path:
|
||||
"""
|
||||
zip 파일 다운로드
|
||||
|
||||
Returns:
|
||||
Path: 다운로드된 zip 파일 경로
|
||||
|
||||
Raises:
|
||||
DownloadError: 다운로드 실패
|
||||
"""
|
||||
if not self.config:
|
||||
raise DownloadError("설정이 로드되지 않았습니다.")
|
||||
|
||||
url = self.config.download_url
|
||||
self._update_status("다운로드 중...", "연결 중...")
|
||||
|
||||
try:
|
||||
# 파일명 추출
|
||||
parsed = urlparse(url)
|
||||
filename = os.path.basename(parsed.path)
|
||||
if not filename.endswith('.zip'):
|
||||
filename = f"voc_noti_{self.config.version}.zip"
|
||||
|
||||
self.download_path = Path(tempfile.gettempdir()) / filename
|
||||
|
||||
# 다운로드 (스트리밍)
|
||||
response = requests.get(url, stream=True, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
downloaded = 0
|
||||
chunk_size = 8192
|
||||
|
||||
with open(self.download_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=chunk_size):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
|
||||
if total_size > 0:
|
||||
progress = downloaded / total_size
|
||||
self._update_progress(
|
||||
progress * 0.7, # 전체의 70%
|
||||
f"{downloaded / 1024 / 1024:.1f} MB / {total_size / 1024 / 1024:.1f} MB"
|
||||
)
|
||||
|
||||
logger.info(f"다운로드 완료: {self.download_path}")
|
||||
return self.download_path
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise DownloadError(f"다운로드 실패: {e}")
|
||||
|
||||
def _extract_and_replace(self, zip_path: Path) -> tuple[bool, str]:
|
||||
"""
|
||||
압축 해제 및 파일 교체
|
||||
|
||||
Args:
|
||||
zip_path: zip 파일 경로
|
||||
|
||||
Returns:
|
||||
tuple[bool, str]: (성공 여부, 메시지)
|
||||
"""
|
||||
if not self.config:
|
||||
return False, "설정이 로드되지 않았습니다."
|
||||
|
||||
target_path = Path(self.config.target_path)
|
||||
backup_path: Optional[Path] = None
|
||||
|
||||
self._update_status("설치 중...", "파일 교체 중...")
|
||||
self._update_progress(0.75)
|
||||
|
||||
try:
|
||||
# 백업 폴더 생성
|
||||
backup_path = target_path.parent / f"{target_path.name}_backup_{self.config.version}"
|
||||
if backup_path.exists():
|
||||
shutil.rmtree(backup_path)
|
||||
|
||||
# 기존 파일 백업
|
||||
if target_path.exists():
|
||||
shutil.copytree(target_path, backup_path)
|
||||
logger.info(f"백업 생성: {backup_path}")
|
||||
|
||||
# 압축 해제
|
||||
self._update_progress(0.8, "압축 해제 중...")
|
||||
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(target_path.parent)
|
||||
|
||||
self._update_progress(0.95, "완료 중...")
|
||||
logger.info(f"압축 해제 완료: {target_path}")
|
||||
|
||||
# 임시 파일 정리
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
|
||||
return True, "설치 완료"
|
||||
|
||||
except zipfile.BadZipFile as e:
|
||||
return False, f"손상된 zip 파일: {e}"
|
||||
except PermissionError as e:
|
||||
return False, f"파일 권한 오류: {e}"
|
||||
except Exception as e:
|
||||
# 롤백
|
||||
if backup_path and backup_path.exists():
|
||||
logger.info("롤백 중...")
|
||||
if target_path.exists():
|
||||
shutil.rmtree(target_path)
|
||||
shutil.move(str(backup_path), str(target_path))
|
||||
return False, f"설치 실패: {e}"
|
||||
|
||||
def _restart_main_app(self) -> bool:
|
||||
"""
|
||||
메인 앱 재실행
|
||||
|
||||
Returns:
|
||||
bool: 실행 성공 여부
|
||||
"""
|
||||
if not self.config:
|
||||
return False
|
||||
|
||||
target_path = Path(self.config.target_path)
|
||||
exe_path = target_path / self.config.restart_exe
|
||||
|
||||
if not exe_path.exists():
|
||||
logger.warning(f"실행 파일을 찾을 수 없습니다: {exe_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[str(exe_path)],
|
||||
cwd=str(target_path),
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
)
|
||||
logger.info(f"메인 앱 실행: {exe_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"메인 앱 실행 실패: {e}")
|
||||
return False
|
||||
|
||||
def _start_update(self):
|
||||
"""업데이트 시작"""
|
||||
try:
|
||||
# 설정 로드
|
||||
self.config = self._load_config()
|
||||
self._update_status(
|
||||
f"버전 {self.config.version}으로 업데이트",
|
||||
"준비 중..."
|
||||
)
|
||||
|
||||
# 다운로드
|
||||
zip_path = self._download_zip()
|
||||
|
||||
# 설치
|
||||
success, message = self._extract_and_replace(zip_path)
|
||||
|
||||
if success:
|
||||
self._update_progress(1.0)
|
||||
self._update_status("업데이트 완료! ✓", message)
|
||||
|
||||
# 메인 앱 재실행
|
||||
self._restart_main_app()
|
||||
|
||||
# 3초 후 종료
|
||||
self.after(3000, self._on_close)
|
||||
else:
|
||||
self._update_status("업데이트 실패 ✗", message)
|
||||
self._show_buttons(show_retry=True)
|
||||
|
||||
except ConfigError as e:
|
||||
self._update_status("설정 오류 ✗", str(e))
|
||||
self._show_buttons(show_retry=False)
|
||||
except DownloadError as e:
|
||||
self._update_status("다운로드 실패 ✗", str(e))
|
||||
self._show_buttons(show_retry=True)
|
||||
except Exception as e:
|
||||
logger.exception("알 수 없는 오류")
|
||||
self._update_status("알 수 없는 오류 ✗", str(e))
|
||||
self._show_buttons(show_retry=True)
|
||||
|
||||
def _on_close(self):
|
||||
"""창 닫기"""
|
||||
# 임시 파일 정리
|
||||
if self.download_path and self.download_path.exists():
|
||||
try:
|
||||
self.download_path.unlink()
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
# config.json 정리
|
||||
config_path = Path(tempfile.gettempdir()) / self.CONFIG_FILENAME
|
||||
if config_path.exists():
|
||||
try:
|
||||
config_path.unlink()
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
self.destroy()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 메인 실행
|
||||
# ============================================================================
|
||||
|
||||
def main():
|
||||
"""updater.exe 진입점"""
|
||||
app = UpdaterGUI()
|
||||
app.mainloop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -210,27 +210,41 @@ class VOCDatabase:
|
|||
cur.execute("UPDATE posts SET checked_at = CURRENT_TIMESTAMP WHERE id = ?", (voc_id,))
|
||||
self.conn.commit()
|
||||
|
||||
def get_new_posts_since(self, timestamp_str):
|
||||
"""특정 시점 이후에 수집/업데이트된 관심 게시글 조회"""
|
||||
def get_new_posts_since(self, timestamp_str, related_only=True):
|
||||
"""특정 시점 이후에 수집/업데이트된 신규 게시글 조회"""
|
||||
cur = self.conn.cursor()
|
||||
# created_at 또는 updated_at 기준으로 조회
|
||||
cur.execute("""
|
||||
SELECT * FROM posts
|
||||
WHERE is_related = 1
|
||||
AND (created_at > ? OR updated_at > ?)
|
||||
ORDER BY created_at DESC
|
||||
""", (timestamp_str, timestamp_str))
|
||||
if related_only:
|
||||
cur.execute("""
|
||||
SELECT * FROM posts
|
||||
WHERE is_related = 1
|
||||
AND (created_at > ? OR updated_at > ?)
|
||||
ORDER BY created_at DESC
|
||||
""", (timestamp_str, timestamp_str))
|
||||
else:
|
||||
cur.execute("""
|
||||
SELECT * FROM posts
|
||||
WHERE (created_at > ? OR updated_at > ?)
|
||||
ORDER BY created_at DESC
|
||||
""", (timestamp_str, timestamp_str))
|
||||
return cur.fetchall()
|
||||
|
||||
def get_unchecked_related_posts(self):
|
||||
"""확인하지 않은 관심글 조회 (미확인 알림용)"""
|
||||
def get_unchecked_related_posts(self, related_only=True):
|
||||
"""확인하지 않은 게시글 조회 (미확인 알림용)"""
|
||||
cur = self.conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT * FROM posts
|
||||
WHERE is_related = 1
|
||||
AND checked_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
""")
|
||||
if related_only:
|
||||
cur.execute("""
|
||||
SELECT * FROM posts
|
||||
WHERE is_related = 1
|
||||
AND checked_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
""")
|
||||
else:
|
||||
cur.execute("""
|
||||
SELECT * FROM posts
|
||||
WHERE checked_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
""")
|
||||
return cur.fetchall()
|
||||
|
||||
def get_posts_needing_detail(self, recheck_hours=3):
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import customtkinter as ctk
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, date
|
||||
from view.theme import theme_manager
|
||||
import calendar
|
||||
import holidays
|
||||
from typing import Callable, Optional
|
||||
|
||||
try:
|
||||
import holidays # type: ignore
|
||||
except ImportError:
|
||||
holidays = None
|
||||
|
||||
|
||||
class ModernCalendarPopup(ctk.CTkToplevel):
|
||||
|
|
@ -16,13 +21,13 @@ class ModernCalendarPopup(ctk.CTkToplevel):
|
|||
- 년/월 선택 드롭다운
|
||||
"""
|
||||
|
||||
def __init__(self, parent, start_date, end_date, callback):
|
||||
def __init__(self, parent, start_date: date, end_date: date, callback: Optional[Callable]):
|
||||
super().__init__(parent)
|
||||
|
||||
self.callback = callback
|
||||
self.start_date = start_date
|
||||
self.end_date = end_date
|
||||
self.temp_start = None
|
||||
self.start_date: date = start_date
|
||||
self.end_date: date = end_date
|
||||
self.temp_start: Optional[date] = None
|
||||
self.selecting_start = True
|
||||
|
||||
# 현재 표시 중인 년/월
|
||||
|
|
@ -30,13 +35,14 @@ class ModernCalendarPopup(ctk.CTkToplevel):
|
|||
self.current_month = datetime.now().month
|
||||
|
||||
# 한국 공휴일
|
||||
self.kr_holidays = holidays.KR(years=range(2020, 2030))
|
||||
self.kr_holidays = holidays.KR(years=range(2020, 2030)) if holidays else set()
|
||||
|
||||
# 팝업 설정
|
||||
self.title("기간 선택")
|
||||
self.geometry("420x600") # 높이를 늘려서 6주 캘린더 수용
|
||||
self.resizable(False, False)
|
||||
self.attributes('-topmost', True)
|
||||
self.configure(fg_color=self._get_palette()["popup_bg"])
|
||||
|
||||
# ESC 키로 닫기
|
||||
self.bind("<Escape>", lambda e: self.destroy())
|
||||
|
|
@ -47,6 +53,49 @@ class ModernCalendarPopup(ctk.CTkToplevel):
|
|||
# UI 구성
|
||||
self._setup_ui()
|
||||
self._update_calendar()
|
||||
|
||||
def _get_palette(self) -> dict:
|
||||
"""현재 테마에 맞는 캘린더 색상 팔레트 반환"""
|
||||
is_dark = ctk.get_appearance_mode() == "Dark"
|
||||
|
||||
if is_dark:
|
||||
return {
|
||||
"popup_bg": "#1f1f1f",
|
||||
"main_bg": "#1f1f1f",
|
||||
"day_bg": "#2b2b2b",
|
||||
"day_hover": "#3a3a3a",
|
||||
"normal_text": "#f1f1f1",
|
||||
"week_text": "#b8b8b8",
|
||||
"range_text": "#7db4ff",
|
||||
"today_bg": "#3d3314",
|
||||
"today_hover": "#52431a",
|
||||
"today_border": "#f4b400",
|
||||
"today_border_hover": "#f39c12",
|
||||
"range_edge_bg": "#1f538d",
|
||||
"range_mid_bg": "#2c3e50",
|
||||
"range_mid_hover": "#34495e",
|
||||
"neutral_btn": "#3a3a3a",
|
||||
"neutral_btn_hover": "#4a4a4a",
|
||||
}
|
||||
|
||||
return {
|
||||
"popup_bg": "#f7f7f7",
|
||||
"main_bg": "#f7f7f7",
|
||||
"day_bg": "#ffffff",
|
||||
"day_hover": "#e9ecef",
|
||||
"normal_text": "#222222",
|
||||
"week_text": "#5f6368",
|
||||
"range_text": "#1f538d",
|
||||
"today_bg": "#fff3cd",
|
||||
"today_hover": "#ffe69c",
|
||||
"today_border": "#ffc107",
|
||||
"today_border_hover": "#ff9800",
|
||||
"range_edge_bg": "#1f538d",
|
||||
"range_mid_bg": "#d6eaf8",
|
||||
"range_mid_hover": "#aed6f1",
|
||||
"neutral_btn": "#e5e7eb",
|
||||
"neutral_btn_hover": "#d1d5db",
|
||||
}
|
||||
|
||||
def _position_popup(self, parent):
|
||||
"""팝업을 부모 위젯 아래에 위치"""
|
||||
|
|
@ -69,7 +118,9 @@ class ModernCalendarPopup(ctk.CTkToplevel):
|
|||
|
||||
def _setup_ui(self):
|
||||
"""UI 구성"""
|
||||
main_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
palette = self._get_palette()
|
||||
|
||||
main_frame = ctk.CTkFrame(self, fg_color=palette["main_bg"])
|
||||
main_frame.pack(fill="both", expand=True, padx=20, pady=20)
|
||||
|
||||
# === 상단: 년/월 선택 ===
|
||||
|
|
@ -142,7 +193,7 @@ class ModernCalendarPopup(ctk.CTkToplevel):
|
|||
main_frame,
|
||||
text=f"{self.start_date.strftime('%Y-%m-%d')} ~ {self.end_date.strftime('%Y-%m-%d')}",
|
||||
font=theme_manager.get_font(12),
|
||||
text_color=("#1f538d", "#3498db"),
|
||||
text_color=palette["range_text"],
|
||||
height=30
|
||||
)
|
||||
self.range_label.pack(pady=(0, 10))
|
||||
|
|
@ -154,7 +205,7 @@ class ModernCalendarPopup(ctk.CTkToplevel):
|
|||
|
||||
# 요일 헤더 (일요일부터 시작)
|
||||
weekdays = ["일", "월", "화", "수", "목", "금", "토"]
|
||||
colors = ["#e74c3c", "gray", "gray", "gray", "gray", "gray", "#3498db"]
|
||||
colors = ["#e74c3c", palette["week_text"], palette["week_text"], palette["week_text"], palette["week_text"], palette["week_text"], "#3498db"]
|
||||
|
||||
for i, (day, color) in enumerate(zip(weekdays, colors)):
|
||||
lbl = ctk.CTkLabel(
|
||||
|
|
@ -189,8 +240,8 @@ class ModernCalendarPopup(ctk.CTkToplevel):
|
|||
height=32,
|
||||
command=lambda d=days: self._quick_select(d),
|
||||
font=theme_manager.get_font(12),
|
||||
fg_color=("gray85", "gray30"),
|
||||
hover_color=("gray75", "gray40"),
|
||||
fg_color=palette["neutral_btn"],
|
||||
hover_color=palette["neutral_btn_hover"],
|
||||
corner_radius=6
|
||||
)
|
||||
btn.pack(side="left", padx=3, expand=True)
|
||||
|
|
@ -217,14 +268,16 @@ class ModernCalendarPopup(ctk.CTkToplevel):
|
|||
height=38,
|
||||
command=self.destroy,
|
||||
font=theme_manager.get_font(13),
|
||||
fg_color=("gray70", "gray40"),
|
||||
hover_color=("gray60", "gray50"),
|
||||
fg_color=palette["neutral_btn"],
|
||||
hover_color=palette["neutral_btn_hover"],
|
||||
corner_radius=8
|
||||
)
|
||||
cancel_btn.pack(side="left", padx=5, expand=True, fill="x")
|
||||
|
||||
def _update_calendar(self):
|
||||
"""캘린더 날짜 업데이트"""
|
||||
palette = self._get_palette()
|
||||
|
||||
# 기존 버튼 제거
|
||||
for btn in self.day_buttons:
|
||||
btn.destroy()
|
||||
|
|
@ -257,7 +310,7 @@ class ModernCalendarPopup(ctk.CTkToplevel):
|
|||
elif weekday == 6: # 토요일
|
||||
text_color = "#3498db"
|
||||
else:
|
||||
text_color = ("gray20", "white")
|
||||
text_color = palette["normal_text"]
|
||||
|
||||
# 공휴일 체크
|
||||
is_holiday = current_date in self.kr_holidays
|
||||
|
|
@ -265,13 +318,13 @@ class ModernCalendarPopup(ctk.CTkToplevel):
|
|||
text_color = "#e74c3c"
|
||||
|
||||
# 배경색 설정
|
||||
fg_color = ("white", "gray25")
|
||||
hover_color = ("gray90", "gray30")
|
||||
fg_color = palette["day_bg"]
|
||||
hover_color = palette["day_hover"]
|
||||
|
||||
# 오늘 날짜 강조
|
||||
if current_date == today:
|
||||
fg_color = ("#fff3cd", "#856404")
|
||||
hover_color = ("#ffe69c", "#9c7506")
|
||||
fg_color = palette["today_bg"]
|
||||
hover_color = palette["today_hover"]
|
||||
|
||||
# 범위 내 날짜 강조
|
||||
if self.start_date <= current_date <= self.end_date:
|
||||
|
|
@ -280,8 +333,8 @@ class ModernCalendarPopup(ctk.CTkToplevel):
|
|||
text_color = "white"
|
||||
hover_color = ("#174a7a", "#1f6fa8")
|
||||
else:
|
||||
fg_color = ("#d6eaf8", "#34495e")
|
||||
hover_color = ("#aed6f1", "#2c3e50")
|
||||
fg_color = palette["range_mid_bg"]
|
||||
hover_color = palette["range_mid_hover"]
|
||||
|
||||
# 날짜 버튼 생성
|
||||
btn = ctk.CTkButton(
|
||||
|
|
@ -296,7 +349,7 @@ class ModernCalendarPopup(ctk.CTkToplevel):
|
|||
hover_color=hover_color,
|
||||
corner_radius=6,
|
||||
border_width=1 if current_date == today else 0,
|
||||
border_color=("#ffc107", "#ff9800")
|
||||
border_color=(palette["today_border"], palette["today_border_hover"])
|
||||
)
|
||||
btn.grid(row=row, column=col, padx=2, pady=2)
|
||||
self.day_buttons.append(btn)
|
||||
|
|
@ -316,6 +369,9 @@ class ModernCalendarPopup(ctk.CTkToplevel):
|
|||
self.end_date = selected_date
|
||||
self.selecting_start = False
|
||||
else:
|
||||
if self.temp_start is None:
|
||||
self.temp_start = selected_date
|
||||
|
||||
if selected_date < self.temp_start:
|
||||
self.start_date = selected_date
|
||||
self.end_date = self.temp_start
|
||||
|
|
@ -399,11 +455,11 @@ class DateRangeSelector(ctk.CTkFrame):
|
|||
super().__init__(parent, **kwargs)
|
||||
|
||||
self.on_change_callback = on_change_callback
|
||||
self.popup = None
|
||||
self.popup: Optional[ModernCalendarPopup] = None
|
||||
|
||||
# 기본값: 오늘부터 일주일 전
|
||||
self.end_date = datetime.now().date()
|
||||
self.start_date = self.end_date - timedelta(days=7)
|
||||
self.end_date: date = datetime.now().date()
|
||||
self.start_date: date = self.end_date - timedelta(days=7)
|
||||
|
||||
# UI 구성
|
||||
self._setup_ui()
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ class NotificationDialog(ctk.CTkToplevel):
|
|||
self.resizable(False, False)
|
||||
|
||||
# UI 구성
|
||||
self.width = 400
|
||||
self.width = 460
|
||||
self.height = 200
|
||||
self._center_window()
|
||||
|
||||
|
|
@ -46,12 +46,22 @@ class NotificationDialog(ctk.CTkToplevel):
|
|||
)
|
||||
self.btn_close.pack(side="right", padx=5)
|
||||
|
||||
self.btn_list = ctk.CTkButton(
|
||||
self.btn_frame, text="전체내역 보기", width=120,
|
||||
command=self._on_open_list
|
||||
)
|
||||
self.btn_list.pack(side="right", padx=5)
|
||||
|
||||
self.btn_view = ctk.CTkButton(
|
||||
self.btn_frame, text="상세보기", width=100,
|
||||
command=self._on_view
|
||||
)
|
||||
self.btn_view.pack(side="right", padx=5)
|
||||
|
||||
# 단건 알림이 아닐 경우 상세보기 비활성화
|
||||
if not self.voc_id:
|
||||
self.btn_view.configure(state="disabled")
|
||||
|
||||
# 포커스 강제
|
||||
self.lift()
|
||||
self.focus_force()
|
||||
|
|
@ -61,6 +71,10 @@ class NotificationDialog(ctk.CTkToplevel):
|
|||
self.controller.open_list_view(focus_id=self.voc_id)
|
||||
self.destroy()
|
||||
|
||||
def _on_open_list(self):
|
||||
self.controller.open_list_view()
|
||||
self.destroy()
|
||||
|
||||
def _center_window(self):
|
||||
self.update_idletasks()
|
||||
x = (self.winfo_screenwidth() // 2) - (self.width // 2)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,70 @@
|
|||
import customtkinter as ctk
|
||||
from tkinter import filedialog
|
||||
from view.theme import theme_manager
|
||||
from view.components.chips import ChipGroup
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class HoverTooltip:
|
||||
"""마우스 오버 시 표시되는 간단한 툴팁"""
|
||||
|
||||
def __init__(self, widget, text: str, delay_ms: int = 300):
|
||||
self.widget = widget
|
||||
self.text = text
|
||||
self.delay_ms = delay_ms
|
||||
self._after_id = None
|
||||
self._tooltip = None
|
||||
|
||||
self.widget.bind("<Enter>", self._on_enter)
|
||||
self.widget.bind("<Leave>", self._on_leave)
|
||||
self.widget.bind("<ButtonPress>", self._on_leave)
|
||||
|
||||
def _on_enter(self, _event=None):
|
||||
self._schedule_show()
|
||||
|
||||
def _on_leave(self, _event=None):
|
||||
self._cancel_show()
|
||||
self._hide()
|
||||
|
||||
def _schedule_show(self):
|
||||
self._cancel_show()
|
||||
self._after_id = self.widget.after(self.delay_ms, self._show)
|
||||
|
||||
def _cancel_show(self):
|
||||
if self._after_id is not None:
|
||||
self.widget.after_cancel(self._after_id)
|
||||
self._after_id = None
|
||||
|
||||
def _show(self):
|
||||
if self._tooltip is not None:
|
||||
return
|
||||
|
||||
x = self.widget.winfo_pointerx() + 12
|
||||
y = self.widget.winfo_pointery() + 12
|
||||
|
||||
self._tooltip = ctk.CTkToplevel(self.widget)
|
||||
self._tooltip.overrideredirect(True)
|
||||
self._tooltip.attributes("-topmost", True)
|
||||
self._tooltip.geometry(f"+{x}+{y}")
|
||||
|
||||
label = ctk.CTkLabel(
|
||||
self._tooltip,
|
||||
text=self.text,
|
||||
justify="left",
|
||||
wraplength=320,
|
||||
corner_radius=8,
|
||||
fg_color=("#f2f2f2", "#2b2b2b"),
|
||||
text_color=("#111111", "#f0f0f0"),
|
||||
padx=10,
|
||||
pady=8,
|
||||
font=theme_manager.get_font(11),
|
||||
)
|
||||
label.pack()
|
||||
|
||||
def _hide(self):
|
||||
if self._tooltip is not None:
|
||||
self._tooltip.destroy()
|
||||
self._tooltip = None
|
||||
|
||||
class SettingsDialog(ctk.CTkToplevel):
|
||||
def __init__(self, controller, parent=None):
|
||||
super().__init__(master=parent)
|
||||
|
|
@ -52,11 +113,56 @@ class SettingsDialog(ctk.CTkToplevel):
|
|||
self.combo_interval = ctk.CTkComboBox(self.tab_crawl, values=["3", "5", "10", "15", "30"])
|
||||
self.combo_interval.pack(pady=5)
|
||||
self.combo_interval.set(str(self.controller.settings['crawling']['interval_minutes']))
|
||||
|
||||
ctk.CTkLabel(self.tab_crawl, text="신규 알림 주기 (분)", font=font).pack(pady=5)
|
||||
self.combo_noti_interval = ctk.CTkComboBox(self.tab_crawl, values=["1", "3", "5", "10", "15", "30"])
|
||||
self.combo_noti_interval.pack(pady=5)
|
||||
self.combo_noti_interval.set(
|
||||
str(self.controller.settings.get('noti', {}).get('db_check_interval_minutes', 5))
|
||||
)
|
||||
|
||||
ctk.CTkLabel(self.tab_crawl, text="미확인 알림 주기 (분)", font=font).pack(pady=5)
|
||||
self.combo_unchecked_interval = ctk.CTkComboBox(self.tab_crawl, values=["5", "10", "15", "30", "60"])
|
||||
self.combo_unchecked_interval.pack(pady=5)
|
||||
self.combo_unchecked_interval.set(
|
||||
str(self.controller.settings.get('noti', {}).get('unchecked_check_interval_minutes', 30))
|
||||
)
|
||||
|
||||
self.unchecked_delay_var = ctk.BooleanVar(
|
||||
value=self.controller.settings.get('noti', {}).get('unchecked_delay_enabled', True)
|
||||
)
|
||||
unchecked_delay_frame = ctk.CTkFrame(self.tab_crawl, fg_color="transparent")
|
||||
unchecked_delay_frame.pack(pady=8, anchor="w", padx=20)
|
||||
|
||||
self.check_unchecked_delay = ctk.CTkSwitch(
|
||||
unchecked_delay_frame,
|
||||
text="미확인 글 30분 경과 후에만 알림",
|
||||
variable=self.unchecked_delay_var
|
||||
)
|
||||
self.check_unchecked_delay.pack(side="left")
|
||||
|
||||
self.unchecked_delay_help = ctk.CTkLabel(
|
||||
unchecked_delay_frame,
|
||||
text="?",
|
||||
width=20,
|
||||
height=20,
|
||||
corner_radius=10,
|
||||
fg_color=("#d9d9d9", "#3a3a3a"),
|
||||
text_color=("#222222", "#f0f0f0"),
|
||||
font=theme_manager.get_font(11, "bold"),
|
||||
cursor="hand2"
|
||||
)
|
||||
self.unchecked_delay_help.pack(side="left", padx=(8, 0))
|
||||
self.unchecked_delay_tooltip = HoverTooltip(
|
||||
self.unchecked_delay_help,
|
||||
"ON: 생성 후 30분이 지난 미확인 글만 알림합니다.\n"
|
||||
"OFF: 생성 시간과 무관하게 미확인 글 전체를 알림합니다."
|
||||
)
|
||||
|
||||
ctk.CTkLabel(self.tab_crawl, text="알림 키워드 (콤마로 구분)", font=font).pack(pady=5)
|
||||
self.entry_kw = ctk.CTkEntry(self.tab_crawl, width=300)
|
||||
self.entry_kw.pack(pady=5)
|
||||
self.entry_kw.insert(0, ",".join(self.controller.settings['crawling']['keywords']))
|
||||
ctk.CTkLabel(self.tab_crawl, text="관심 키워드 (콤마로 구분)", font=font).pack(pady=5)
|
||||
self.entry_crawl_kw = ctk.CTkEntry(self.tab_crawl, width=300)
|
||||
self.entry_crawl_kw.pack(pady=5)
|
||||
self.entry_crawl_kw.insert(0, ",".join(self.controller.settings['crawling']['keywords']))
|
||||
|
||||
ctk.CTkLabel(self.tab_crawl, text="관심 부서 (콤마로 구분)", font=font).pack(pady=5)
|
||||
self.entry_dept = ctk.CTkEntry(self.tab_crawl, width=300)
|
||||
|
|
@ -77,7 +183,6 @@ class SettingsDialog(ctk.CTkToplevel):
|
|||
ctk.CTkButton(self.tab_theme, text="테마 적용 및 저장", command=self.save_all).pack(pady=20)
|
||||
|
||||
def _init_system_tab(self):
|
||||
font = theme_manager.get_font(12)
|
||||
font_bold = theme_manager.get_font(13, "bold")
|
||||
|
||||
# === 로그 관리 섹션 ===
|
||||
|
|
@ -159,7 +264,6 @@ class SettingsDialog(ctk.CTkToplevel):
|
|||
self.controller.save_settings()
|
||||
|
||||
def _init_notification_tab(self):
|
||||
font_label = theme_manager.get_font(13, "bold")
|
||||
|
||||
# 1. 알림 소리 설정
|
||||
self.sound_var = ctk.BooleanVar(value=self.controller.settings.get('noti', {}).get('sound', True))
|
||||
|
|
@ -168,88 +272,80 @@ class SettingsDialog(ctk.CTkToplevel):
|
|||
|
||||
ctk.CTkLabel(self.tab_noti, text="", height=1, fg_color="gray").pack(fill="x", padx=20, pady=10)
|
||||
|
||||
# 2. 키워드 필터링 토글
|
||||
self.use_kw_var = ctk.BooleanVar(value=self.controller.settings.get('noti', {}).get('use_keywords', True))
|
||||
self.sw_kw = ctk.CTkSwitch(
|
||||
self.tab_noti,
|
||||
text="특정 키워드 포함 시에만 알림",
|
||||
variable=self.use_kw_var,
|
||||
command=self._toggle_keyword_ui
|
||||
# 2. 관심 조건 필터링 토글
|
||||
self.use_related_filter_var = ctk.BooleanVar(
|
||||
value=self.controller.settings.get('noti', {}).get('use_related_filter', True)
|
||||
)
|
||||
related_filter_frame = ctk.CTkFrame(self.tab_noti, fg_color="transparent")
|
||||
related_filter_frame.pack(pady=10, anchor="w", padx=20)
|
||||
|
||||
self.sw_kw = ctk.CTkSwitch(
|
||||
related_filter_frame,
|
||||
text="관심 조건(키워드/부서) 일치 글만 알림",
|
||||
variable=self.use_related_filter_var
|
||||
)
|
||||
self.sw_kw.pack(side="left")
|
||||
|
||||
self.related_filter_help = ctk.CTkLabel(
|
||||
related_filter_frame,
|
||||
text="?",
|
||||
width=20,
|
||||
height=20,
|
||||
corner_radius=10,
|
||||
fg_color=("#d9d9d9", "#3a3a3a"),
|
||||
text_color=("#222222", "#f0f0f0"),
|
||||
font=theme_manager.get_font(11, "bold"),
|
||||
cursor="hand2"
|
||||
)
|
||||
self.related_filter_help.pack(side="left", padx=(8, 0))
|
||||
self.related_filter_tooltip = HoverTooltip(
|
||||
self.related_filter_help,
|
||||
"ON: 수집 설정의 키워드/관심부서 조건(OR)을 만족한 글만 신규/미확인 알림합니다.\n"
|
||||
"OFF: 조건과 무관하게 신규/미확인 전체 글을 알림합니다."
|
||||
)
|
||||
self.sw_kw.pack(pady=10, anchor="w", padx=20)
|
||||
|
||||
# 툴팁 대용 가이드 문구
|
||||
# 가이드 문구
|
||||
self.lbl_guide = ctk.CTkLabel(
|
||||
self.tab_noti,
|
||||
text="※ 체크 해제 시 모든 신규 VOC에 대해 알림이 발생합니다.",
|
||||
text=(
|
||||
"※ ON: 수집 설정의 키워드/관심부서 조건(OR)을 만족한 글만 알림\n"
|
||||
"※ OFF: 조건과 무관하게 신규/미확인 전체 글에 대해 알림"
|
||||
),
|
||||
font=theme_manager.get_font(11),
|
||||
text_color="gray"
|
||||
)
|
||||
self.lbl_guide.pack(anchor="w", padx=45)
|
||||
|
||||
# 3. 키워드 입력 영역
|
||||
self.kw_frame = ctk.CTkFrame(self.tab_noti, fg_color="transparent")
|
||||
self.kw_frame.pack(fill="x", padx=20, pady=10)
|
||||
|
||||
self.entry_kw = ctk.CTkEntry(self.kw_frame, placeholder_text="알림 키워드 입력 후 Enter", width=250)
|
||||
self.entry_kw.pack(side="left", padx=(0, 10))
|
||||
self.entry_kw.bind("<Return>", lambda e: self._add_keyword_chip())
|
||||
|
||||
ctk.CTkButton(self.kw_frame, text="추가", width=60, command=self._add_keyword_chip).pack(side="left")
|
||||
|
||||
# 4. 칩이 표시될 영역
|
||||
self.kw_list = self.controller.settings.get('noti', {}).get('keywords', [])
|
||||
self.chip_group = ChipGroup(self.tab_noti)
|
||||
self.chip_group.pack(fill="x", padx=20, pady=5)
|
||||
|
||||
self._refresh_keyword_chips()
|
||||
self._toggle_keyword_ui() # 초기 상태 반영
|
||||
|
||||
def _toggle_keyword_ui(self):
|
||||
state = "normal" if self.use_kw_var.get() else "disabled"
|
||||
self.entry_kw.configure(state=state)
|
||||
# 키워드 필터링을 안 할 경우 가이드 문구 강조 가능
|
||||
|
||||
def _add_keyword_chip(self):
|
||||
new_kw = self.entry_kw.get().strip()
|
||||
if new_kw and new_kw not in self.kw_list:
|
||||
self.kw_list.append(new_kw)
|
||||
self._refresh_keyword_chips()
|
||||
self.entry_kw.delete(0, "end")
|
||||
|
||||
def _remove_keyword_chip(self, val):
|
||||
if val in self.kw_list:
|
||||
self.kw_list.remove(val)
|
||||
self._refresh_keyword_chips()
|
||||
|
||||
def _refresh_keyword_chips(self):
|
||||
self.chip_group.clear()
|
||||
for kw in self.kw_list:
|
||||
self.chip_group.add_chip(kw, self._remove_keyword_chip)
|
||||
|
||||
def save_all(self):
|
||||
self.controller.settings['login']['id'] = self.entry_id.get()
|
||||
self.controller.settings['login']['pw'] = self.entry_pw.get()
|
||||
self.controller.settings['crawling']['interval_minutes'] = int(self.combo_interval.get())
|
||||
self.controller.settings['crawling']['keywords'] = [k.strip() for k in self.entry_kw.get().split(',') if k.strip()]
|
||||
self.controller.settings['crawling']['keywords'] = [k.strip() for k in self.entry_crawl_kw.get().split(',') if k.strip()]
|
||||
self.controller.settings['crawling']['target_depts'] = [k.strip() for k in self.entry_dept.get().split(',') if k.strip()]
|
||||
|
||||
if 'noti' not in self.controller.settings:
|
||||
self.controller.settings['noti'] = {}
|
||||
self.controller.settings['noti']['db_check_interval_minutes'] = int(self.combo_noti_interval.get())
|
||||
self.controller.settings['noti']['unchecked_check_interval_minutes'] = int(self.combo_unchecked_interval.get())
|
||||
self.controller.settings['noti']['unchecked_delay_enabled'] = self.unchecked_delay_var.get()
|
||||
|
||||
# 테마 저장 및 적용
|
||||
selected_theme = self.combo_theme.get()
|
||||
self.controller.settings['theme'] = selected_theme
|
||||
theme_manager.set_theme(selected_theme)
|
||||
|
||||
self.controller.save_settings()
|
||||
self.controller.update_config_runtime()
|
||||
|
||||
if 'noti' not in self.controller.settings: self.controller.settings['noti'] = {}
|
||||
self.controller.settings['noti']['sound'] = self.sound_var.get()
|
||||
self.controller.settings['noti']['use_keywords'] = self.use_kw_var.get()
|
||||
self.controller.settings['noti']['keywords'] = self.kw_list
|
||||
self.controller.settings['noti']['use_related_filter'] = self.use_related_filter_var.get()
|
||||
# 과거 알림 키워드 설정 정리 (현재는 수집 설정 키워드만 사용)
|
||||
self.controller.settings['noti'].pop('use_keywords', None)
|
||||
self.controller.settings['noti'].pop('keywords', None)
|
||||
|
||||
# 보고서 경로 저장 (이미 _select_report_folder에서 저장되지만 일관성 유지)
|
||||
if 'report' not in self.controller.settings:
|
||||
self.controller.settings['report'] = {}
|
||||
self.controller.settings['report']['output_path'] = self.report_path_label.cget("text")
|
||||
|
||||
self.controller.save_settings()
|
||||
self.controller.update_config_runtime()
|
||||
|
||||
self.destroy()
|
||||
|
|
|
|||
|
|
@ -305,7 +305,11 @@ except TrainInfoNotFoundError as e:
|
|||
"retry_delay": 2
|
||||
},
|
||||
"noti": {
|
||||
"sound": true
|
||||
"sound": true,
|
||||
"db_check_interval_minutes": 3,
|
||||
"unchecked_check_interval_minutes": 10,
|
||||
"unchecked_delay_enabled": true,
|
||||
"use_related_filter": true
|
||||
},
|
||||
"report": {
|
||||
"output_path": "D:/Reports"
|
||||
|
|
@ -333,12 +337,20 @@ except TrainInfoNotFoundError as e:
|
|||
| `crawling.interval_minutes` | int | 10 | 크롤링 주기 (분) |
|
||||
| `crawling.max_pages` | int | 2 | 최대 수집 페이지 수 |
|
||||
| `crawling.target_depts` | list | ["차량"] | 관심 부서 목록 |
|
||||
| `crawling.keywords` | list | ["지연", "불만"] | 관심 키워드 목록 |
|
||||
| `crawling.keywords` | list | ["1호선", "1호선 역사명..."] | 관심 키워드 목록 (관심글 판정 기준) |
|
||||
| `crawling.filter_mode` | str | "OR" | 필터링 모드 ("AND" 또는 "OR") |
|
||||
| `crawling.recheck_hours` | int | 3 | 상세 재수집 주기 (시간) |
|
||||
| `crawling.headless_mode` | bool | true | 헤드리스 모드 여부 |
|
||||
| `crawling.max_retries` | int | 3 | 실패 시 최대 재시도 횟수 |
|
||||
| `crawling.retry_delay` | int | 2 | 재시도 간격 (초) |
|
||||
| `noti.db_check_interval_minutes` | int | 3 | 신규 알림 DB 체크 주기 (분) |
|
||||
| `noti.unchecked_check_interval_minutes` | int | 10 | 미확인 글 재알림 주기 (분) |
|
||||
| `noti.unchecked_delay_enabled` | bool | true | 미확인 글 30분 경과 조건 사용 여부 |
|
||||
| `noti.use_related_filter` | bool | true | 관심 조건(키워드/부서) 기반 알림만 사용할지 여부 |
|
||||
|
||||
**알림 설정 분류 원칙:**
|
||||
- `crawling.keywords`, `crawling.target_depts`: 관심글 판정 기준 (중복 없이 단일 기준)
|
||||
- `noti.*`: 알림 주기/조건 토글/사운드 등 알림 동작 제어
|
||||
|
||||
**filter_mode 설명:**
|
||||
- `"OR"`: 키워드 **또는** 부서가 매칭되면 관심글로 표시 (기본값)
|
||||
|
|
@ -414,10 +426,129 @@ CREATE TABLE voc_posts (
|
|||
|
||||
---
|
||||
|
||||
## 8. 변경 이력
|
||||
## 8. 자동 업데이트 모듈 (Updater Module) ⭐ 신규
|
||||
|
||||
### 8.1 모듈 구조
|
||||
|
||||
```
|
||||
app/updater/
|
||||
├── __init__.py # 모듈 초기화
|
||||
├── __version__.py # 버전 정보
|
||||
├── update_manager.py # 메인 프로그램용 업데이트 관리자
|
||||
├── updater_gui.py # updater.exe용 GUI
|
||||
└── updatelog.md # 업데이트 로그
|
||||
```
|
||||
|
||||
### 8.2 데이터 모델
|
||||
|
||||
#### UpdateConfig
|
||||
**위치**: `app/updater/update_manager.py`
|
||||
|
||||
```python
|
||||
class UpdateConfig(BaseModel):
|
||||
"""
|
||||
업데이트 구성 모델
|
||||
|
||||
updater.exe로 전달되는 구성 정보
|
||||
|
||||
Attributes:
|
||||
download_url (str): 다운로드 URL (zip 파일)
|
||||
target_path (str): 설치 대상 경로
|
||||
version (str): 업데이트할 버전
|
||||
restart_exe (str): 업데이트 후 실행할 실행파일명
|
||||
"""
|
||||
download_url: str = Field(..., description="다운로드 URL")
|
||||
target_path: str = Field(..., description="설치 대상 경로")
|
||||
version: str = Field(..., description="업데이트 버전")
|
||||
restart_exe: str = Field(default="voc_noti.exe", description="재시작 실행파일")
|
||||
```
|
||||
|
||||
#### VersionInfo
|
||||
**위치**: `app/updater/update_manager.py`
|
||||
|
||||
```python
|
||||
class VersionInfo(BaseModel):
|
||||
"""
|
||||
Supabase에서 조회한 버전 정보
|
||||
|
||||
Attributes:
|
||||
version (str): 버전 번호
|
||||
is_stable (bool): 안정판 여부
|
||||
release_note (str): 배포 노트
|
||||
download_url (str): 다운로드 URL
|
||||
min_required_version (str): 최소 요구 버전
|
||||
"""
|
||||
version: str
|
||||
is_stable: bool
|
||||
release_note: Optional[str] = None
|
||||
download_url: str
|
||||
min_required_version: Optional[str] = None
|
||||
```
|
||||
|
||||
### 8.3 인터페이스 정의
|
||||
|
||||
#### UpdateManager (메인 프로그램용)
|
||||
|
||||
| 메서드 | 입력 | 출력 | 설명 |
|
||||
|--------|------|------|------|
|
||||
| `check_for_updates()` | 없음 | `Optional[VersionInfo]` | Supabase에서 최신 버전 조회 |
|
||||
| `is_update_available()` | 없음 | `bool` | 업데이트 필요 여부 |
|
||||
| `prepare_update(version_info)` | `VersionInfo` | `tuple[bool, str]` | 업데이트 준비 (config.json 생성) |
|
||||
| `launch_updater()` | 없음 | `bool` | updater.exe 실행 |
|
||||
| `start_background_check(callback)` | `Callable` | `None` | 백그라운드 업데이트 체크 시작 |
|
||||
|
||||
#### UpdaterGUI (updater.exe용)
|
||||
|
||||
| 메서드 | 입력 | 출력 | 설명 |
|
||||
|--------|------|------|------|
|
||||
| `load_config()` | 없음 | `UpdateConfig` | config.json 로드 |
|
||||
| `download_zip(url, progress_callback)` | `str, Callable` | `tuple[bool, str]` | zip 다운로드 |
|
||||
| `extract_and_replace(zip_path, target_path)` | `str, str` | `tuple[bool, str]` | 압축 해제 및 파일 교체 |
|
||||
| `restart_main_app(exe_name)` | `str` | `bool` | 메인 앱 재실행 |
|
||||
| `show_error(message)` | `str` | `None` | 에러 다이얼로그 표시 |
|
||||
|
||||
### 8.4 통신 프로토콜
|
||||
|
||||
**config.json 형식** (`%TEMP%/voc_updater_config.json`):
|
||||
```json
|
||||
{
|
||||
"download_url": "https://supabase.../voc_noti_3.2.0.zip",
|
||||
"target_path": "C:/Program Files/voc_noti",
|
||||
"version": "3.2.0",
|
||||
"restart_exe": "voc_noti.exe"
|
||||
}
|
||||
```
|
||||
|
||||
### 8.5 Supabase API
|
||||
|
||||
**테이블**: `program_version`
|
||||
|
||||
**조회 쿼리**:
|
||||
```sql
|
||||
SELECT * FROM program_version
|
||||
WHERE program_id = 'voc_monitor'
|
||||
AND is_stable = true
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
```
|
||||
|
||||
### 8.6 예외 처리
|
||||
|
||||
| 예외 | 상황 | 처리 |
|
||||
|------|------|------|
|
||||
| `NetworkError` | Supabase 연결 실패 | 로그 기록, 다음 체크까지 대기 |
|
||||
| `DownloadError` | 다운로드 실패 | 사용자 알림, 재시도 옵션 |
|
||||
| `ExtractError` | 압축 해제 실패 | 롤백, 에러 로그 |
|
||||
| `ConfigError` | config.json 읽기 실패 | 에러 다이얼로그, 종료 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 변경 이력
|
||||
|
||||
| 날짜 | 버전 | 변경 내용 |
|
||||
|------|------|-----------|
|
||||
| 2026-02-18 | 3.3 | **알림 설정 확장** - `noti.db_check_interval_minutes`, `noti.unchecked_check_interval_minutes`, `noti.unchecked_delay_enabled` 규격 추가 |
|
||||
| 2026-02-18 | 3.2 | **자동 업데이트 모듈 명세 추가** - UpdateManager, UpdaterGUI 인터페이스 정의, config.json 프로토콜 |
|
||||
| 2026-02-18 | 3.1 | **크롤링 시스템 개선** - ScraperService 하드코딩 제거 (`target_depts`, `keywords` settings.json 연동), 필터링 로직 고도화 (AND/OR 모드, `_check_filter_match` 메서드), 예외처리 강화 |
|
||||
| 2026-02-18 | 3.0 | **Controller 리팩토링 완료** - Manager 분리 (ReportManager, FileManager, UIManager), 알람 시스템 개선 (키워드 필터링 제거, 미확인 글 알림, 중복 방지, 영속화) |
|
||||
| 2026-02-17 | 2.0 | 전면 재작성 (에러 처리 체계 추가) |
|
||||
|
|
@ -428,4 +559,4 @@ CREATE TABLE voc_posts (
|
|||
|
||||
작성자: KH.Choi
|
||||
최종 수정: 2026-02-18
|
||||
버전: 3.0
|
||||
버전: 3.3
|
||||
|
|
|
|||
|
|
@ -36,6 +36,26 @@
|
|||
- [x] 중복 알림 방지 메커니즘 추가 (notified_post_ids 세트)
|
||||
- **완료일**: 2026-02-18
|
||||
|
||||
### [완료] 알림 주기 설정화 및 미확인 조건 토글 ✅
|
||||
- **목표**: 신규/미확인 알림 주기를 설정에서 제어하고, 미확인 30분 경과 조건을 ON/OFF로 제어
|
||||
- **관련 파일**:
|
||||
- `app/view/dialogs/settings_dialog.py`
|
||||
- `app/controllers/scheduler_manager.py`
|
||||
- `app/controllers/controller.py`
|
||||
- `app/data/settings.json`
|
||||
- **완료 내역**:
|
||||
- [x] 신규 알림 주기 설정 추가 (`noti.db_check_interval_minutes`, 기본 3분)
|
||||
- [x] 미확인 알림 주기 설정 추가 (`noti.unchecked_check_interval_minutes`, 기본 10분)
|
||||
- [x] 미확인 30분 경과 조건 토글 추가 (`noti.unchecked_delay_enabled`)
|
||||
- [x] 관심 조건 알림 토글 추가 (`noti.use_related_filter`)
|
||||
- [x] 설정 토글 설명용 `?` 툴팁 추가 (관심 조건 토글, 30분 지연 토글)
|
||||
- [x] 설정 역할 분리 정리: 알림 탭의 중복 키워드 UI 제거, 키워드/부서는 수집 설정으로 일원화
|
||||
- [x] 설정 UI 입력 위젯 충돌 버그 수정 (`entry_kw` 분리)
|
||||
- [x] 알림 팝업에 `전체내역 보기` 버튼 추가 (신규/미확인 공통), 다건 알림에서도 목록 즉시 열기 지원
|
||||
- [x] 다건 미확인 알림 목록 표시 로직 동작 확인
|
||||
- [x] 수집 키워드 기본값을 `1호선 + 1호선 전체 역사`로 반영, 관심 부서 기본값 `차량` 유지
|
||||
- **완료일**: 2026-02-18
|
||||
|
||||
---
|
||||
|
||||
## 🟡 우선순위: 보통 (In Progress / Pending)
|
||||
|
|
@ -197,4 +217,4 @@
|
|||
---
|
||||
|
||||
작성자: KH.Choi
|
||||
최종 수정: 2026-02-18
|
||||
최종 수정: 2026-02-18
|
||||
|
|
|
|||
|
|
@ -51,9 +51,11 @@
|
|||
|
||||
### 3.2 알림 사이클 (v2.1 개선)
|
||||
|
||||
#### 3.2.1 신규 알림 사이클 (5분마다)
|
||||
#### 3.2.1 신규 알림 사이클 (기본 3분, 설정 가능)
|
||||
```
|
||||
1. 마지막 체크 이후 신규 데이터 조회 (get_new_posts_since)
|
||||
- 설정 `noti.use_related_filter = true`: 관심글(is_related=1)만 조회
|
||||
- 설정 `noti.use_related_filter = false`: 신규글 전체 조회
|
||||
2. 중복 알림 필터링 (notified_post_ids 체크)
|
||||
3. 모든 관심글(is_related=1)에 대해 알림 ⭐ 변경: 키워드 필터링 제거
|
||||
4. 소리 재생 (설정 시)
|
||||
|
|
@ -61,10 +63,14 @@
|
|||
6. last_check_time 영속화 저장 ⭐ 신규
|
||||
```
|
||||
|
||||
#### 3.2.2 미확인 알림 사이클 (30분마다) ⭐ 신규
|
||||
#### 3.2.2 미확인 알림 사이클 (기본 10분, 설정 가능) ⭐ 신규
|
||||
```
|
||||
1. 확인하지 않은 관심글 조회 (get_unchecked_related_posts)
|
||||
2. 30분 이상 지난 글 필터링
|
||||
- 설정 `noti.use_related_filter = true`: 관심글(is_related=1)의 미확인만 조회
|
||||
- 설정 `noti.use_related_filter = false`: 전체 미확인글 조회
|
||||
2. 30분 경과 조건 ON/OFF에 따라 필터링
|
||||
- ON: 30분 이상 지난 글만 알림
|
||||
- OFF: 미확인 글 전체 알림
|
||||
3. 미확인 알림 발송 (⚠️ 표시)
|
||||
4. 사용자가 확인할 때까지 반복 알림
|
||||
```
|
||||
|
|
@ -74,6 +80,9 @@
|
|||
- **중복 방지**: notified_post_ids 세트로 동일 글 중복 알림 방지
|
||||
- **영속화**: last_check_time을 scheduler_state.json에 저장하여 프로그램 재시작 시에도 유지
|
||||
- **미확인 체크**: 사용자가 확인하지 않은 글에 대해 주기적 재알림
|
||||
- **주기 설정화**: 신규 알림 주기 및 미확인 알림 주기를 설정 UI에서 변경 가능
|
||||
- **미확인 조건 토글**: "30분 경과 후 알림" 옵션 ON/OFF 지원
|
||||
- **관심조건 토글**: "관심 조건(키워드/부서)만 알림" 옵션 ON/OFF 지원
|
||||
|
||||
### 3.3 보고서 생성 프로세스
|
||||
```
|
||||
|
|
@ -587,4 +596,4 @@ updater = AutoUpdater(
|
|||
|
||||
작성자: KH.Choi
|
||||
최종 수정: 2026-02-18
|
||||
버전: 3.2
|
||||
버전: 3.3
|
||||
|
|
|
|||
|
|
@ -131,22 +131,32 @@
|
|||
- **Docker**: 컨테이너화
|
||||
- **CI/CD**: 자동 빌드 및 배포
|
||||
|
||||
### 🎯 자동 업데이트 시스템 [대기] ⭐ 신규 제안
|
||||
### 🎯 자동 업데이트 시스템 [완료] ✅
|
||||
**요청일**: 2026-02-18
|
||||
**완료일**: 2026-02-18
|
||||
|
||||
**목표**: 프로그램의 자동 업데이트 기능 구현
|
||||
|
||||
**주요 기능**:
|
||||
- [ ] Supabase 기반 원격 버전 관리
|
||||
- [ ] 1시간마다 자동 업데이트 체크
|
||||
- [ ] 업데이트 알림 및 자동 설치
|
||||
- [ ] 멀티프로젝트 지원 (재사용 가능한 모듈 설계)
|
||||
**완료된 기능**:
|
||||
- [x] Supabase 기반 원격 버전 관리
|
||||
- [x] 1시간마다 자동 업데이트 체크
|
||||
- [x] 업데이트 알림 및 자동 설치
|
||||
- [x] 멀티프로젝트 지원 (재사용 가능한 모듈 설계)
|
||||
- [x] updater.exe 분리 아키텍처 (별도 패킹)
|
||||
- [x] CustomTkinter 기반 최소 GUI
|
||||
|
||||
**상세 내용**: `docs/project_spec.md` 참조
|
||||
**구현 파일**:
|
||||
- `app/updater/__init__.py`: 모듈 초기화
|
||||
- `app/updater/__version__.py`: 버전 정보
|
||||
- `app/updater/update_manager.py`: 메인 프로그램용 업데이트 관리자
|
||||
- `app/updater/updater_gui.py`: updater.exe용 GUI
|
||||
- `app/updater/updatelog.md`: 업데이트 로그
|
||||
|
||||
**상세 내용**: `docs/project_spec.md` 10장 참조
|
||||
|
||||
---
|
||||
|
||||
**참고**: 이 기능은 다른 프로젝트에서도 재사용 가능하도록 별도 모듈로 설계합니다.
|
||||
**참고**: 이 기능은 다른 프로젝트에서도 재사용 가능하도록 별도 모듈로 설계되었습니다.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -183,12 +193,21 @@
|
|||
- 미확인 글 주기적 알림 기능 추가 (30분마다)
|
||||
- last_check_time 영속화
|
||||
- 중복 알림 방지 메커니즘 추가
|
||||
- 신규/미확인 알림 주기 설정화
|
||||
- 미확인 30분 경과 조건 ON/OFF 토글 추가
|
||||
|
||||
### v2.5 (2026-03-31 목표)
|
||||
- 통계 대시보드
|
||||
- 엑셀 낭<EFBFBD>내기
|
||||
- 엑셀 내보내기
|
||||
- 성능 최적화
|
||||
|
||||
### v3.2 (2026-02-18) ✅ 완료
|
||||
- 자동 업데이트 시스템 구현
|
||||
- UpdateManager: Supabase 기반 버전 체크
|
||||
- UpdaterGUI: 별도 실행파일용 업데이트 GUI
|
||||
- updater.exe 분리 아키텍처 적용
|
||||
- updatelog.md 위치 변경 (docs/ → app/updater/)
|
||||
|
||||
### v3.0 (2026-06-30 목표)
|
||||
- 다중 호선 지원
|
||||
- 웹 UI 베타
|
||||
|
|
@ -197,4 +216,4 @@
|
|||
---
|
||||
|
||||
작성자: KH.Choi
|
||||
최종 수정: 2026-02-18
|
||||
최종 수정: 2026-02-18
|
||||
|
|
|
|||
Loading…
Reference in New Issue