688 lines
27 KiB
Python
688 lines
27 KiB
Python
"""
|
|
애플리케이션 메인 컨트롤러
|
|
|
|
전체적인 흐름을 제어하며, Model(Scraper, Database)과 View(UI) 사이의 중재자 역할을 수행합니다.
|
|
스케줄링, 알림, 보고서, 첨부파일, UI 관리는 각각의 Manager에 위임하여 단일 책임 원칙을 준수합니다.
|
|
|
|
주요 기능:
|
|
- 애플리케이션 초기화 및 생명주기 관리
|
|
- 설정 파일 관리
|
|
- 데이터베이스 입출력 관리
|
|
- Manager들에게 작업 위임
|
|
|
|
아키텍처:
|
|
AppController
|
|
├── SchedulerManager (크롤링, DB 체크)
|
|
├── NotificationManager (알림)
|
|
├── ReportManager (보고서 생성, 인쇄, PDF)
|
|
├── FileManager (첨부파일 처리)
|
|
├── UIManager (창 열기 로직)
|
|
├── ReportService (보고서 서비스)
|
|
├── VOCDatabase (데이터베이스)
|
|
└── SystemTray (트레이 아이콘)
|
|
|
|
작성자: KH.Choi
|
|
최종 수정: 2026-02-18
|
|
버전: 3.0 (리팩토링 - Manager 분리 완료)
|
|
"""
|
|
import json
|
|
import time
|
|
from typing import Optional
|
|
import customtkinter as ctk
|
|
from tkinter import messagebox
|
|
|
|
from utils.path_utils import get_base_dir, get_data_dir
|
|
from utils.database import VOCDatabase
|
|
from utils.logger import get_logger
|
|
from core.exceptions import DatabaseError, ScraperError
|
|
|
|
from services.scraper_service import VOCScraper
|
|
from services.report_service import ReportService
|
|
from controllers.scheduler_manager import SchedulerManager
|
|
from controllers.notification_manager import NotificationManager
|
|
from controllers.report_manager import ReportManager
|
|
from controllers.file_manager import FileManager
|
|
from controllers.ui_manager import UIManager
|
|
from updater.update_manager import ConfigError as UpdaterConfigError
|
|
from updater.update_manager import NetworkError as UpdaterNetworkError
|
|
from updater.update_manager import create_update_manager_from_settings
|
|
|
|
from view.tray_icon import SystemTray
|
|
|
|
|
|
class AppController:
|
|
"""
|
|
애플리케이션의 메인 컨트롤러 클래스
|
|
|
|
전체적인 흐름을 제어하며, Model과 View 사이의 중재자 역할을 수행합니다.
|
|
복잡한 스케줄링, 알림, 보고서, 첨부파일, UI 로직은 각각의 Manager에 위임하여
|
|
단일 책임 원칙을 준수하고 코드 복잡도를 낮췄습니다.
|
|
|
|
Attributes:
|
|
logger: 로거 인스턴스
|
|
test_mode (bool): 테스트 모드 여부
|
|
base_dir (Path): 베이스 디렉토리
|
|
settings_file (Path): 설정 파일 경로
|
|
settings (dict): 설정 정보
|
|
db (VOCDatabase): 데이터베이스 인스턴스
|
|
model (VOCScraper): 크롤러 인스턴스
|
|
report_service (ReportService): 보고서 서비스
|
|
scheduler (SchedulerManager): 스케줄러 관리자
|
|
notifier (NotificationManager): 알림 관리자
|
|
report_manager (ReportManager): 보고서 관리자
|
|
file_manager (FileManager): 첨부파일 관리자
|
|
ui_manager (UIManager): UI 관리자
|
|
tray (SystemTray): 트레이 아이콘
|
|
root (CTk): Tkinter 루트 윈도우
|
|
|
|
주요 메서드:
|
|
start_background: 백그라운드 작업 시작
|
|
open_list_view: 히스토리 다이얼로그 열기 (UIManager 위임)
|
|
open_settings: 설정 다이얼로그 열기 (UIManager 위임)
|
|
request_create_report: 보고서 생성 요청 (ReportManager 위임)
|
|
request_print_voc: 인쇄 요청 (ReportManager 위임)
|
|
open_attachment: 첨부파일 열기 (FileManager 위임)
|
|
quit_app: 애플리케이션 종료
|
|
|
|
사용 예시:
|
|
>>> controller = AppController(test_mode=False)
|
|
>>> controller.start_background()
|
|
"""
|
|
|
|
@staticmethod
|
|
def get_base_dir():
|
|
"""개발 환경과 패키징 환경(cx_Freeze)을 모두 지원하는 베이스 디렉토리 반환"""
|
|
return get_base_dir()
|
|
|
|
def __init__(self, test_mode=False, test_update_now=False):
|
|
"""
|
|
컨트롤러 초기화
|
|
|
|
Args:
|
|
test_mode (bool): 테스트 모드 여부 (True 시 크롤링 생략)
|
|
test_update_now (bool): 테스트 모드에서 시작 직후 업데이트 1회 확인 여부
|
|
"""
|
|
self.logger = get_logger("Controller")
|
|
self.test_mode = test_mode
|
|
self.test_update_now = test_update_now
|
|
self.base_dir = get_base_dir()
|
|
self.settings_file = get_data_dir() / "settings.json"
|
|
|
|
self.logger.info(
|
|
f"컨트롤러 초기화 중 (테스트 모드={test_mode}, 테스트 즉시 업데이트 확인={test_update_now})"
|
|
)
|
|
|
|
# 데이터 디렉토리 확인
|
|
self._ensure_data_dir()
|
|
|
|
# 설정 로드
|
|
self.load_settings()
|
|
|
|
# 데이터베이스 초기화
|
|
try:
|
|
self.db = VOCDatabase()
|
|
except DatabaseError as e:
|
|
self.logger.error(f"데이터베이스 초기화 실패: {e.message}")
|
|
raise
|
|
|
|
# 크롤러 초기화 (테스트 모드 제외)
|
|
if not self.test_mode:
|
|
headless = self.settings.get('crawling', {}).get('headless_mode', True)
|
|
try:
|
|
# 설정을 전달하여 하드코딩 제거 및 유연한 필터링 설정
|
|
self.model = VOCScraper(headless_mode=headless, settings=self.settings)
|
|
except ScraperError as e:
|
|
self.logger.error(f"크롤러 초기화 실패: {e.message}")
|
|
self.model = None
|
|
else:
|
|
self.model = None
|
|
|
|
# 서비스 초기화
|
|
self.report_service = ReportService()
|
|
|
|
# 업데이터 초기화 (통합 단계)
|
|
self.update_manager = None
|
|
try:
|
|
self.update_manager = create_update_manager_from_settings(self.settings)
|
|
self.logger.info("업데이터 초기화 완료")
|
|
except UpdaterConfigError as e:
|
|
self.logger.warning(f"업데이터 초기화 생략 (설정 오류): {e}")
|
|
except Exception as e:
|
|
self.logger.error(f"업데이터 초기화 실패: {e}", exc_info=True)
|
|
|
|
# Hidden root 생성 (모든 GUI의 부모)
|
|
self.root = ctk.CTk()
|
|
self.root.withdraw() # 메인 창은 숨김
|
|
|
|
# Manager 초기화
|
|
self.scheduler = None
|
|
self.notifier = NotificationManager(self.root, self.logger)
|
|
self.report_manager = ReportManager(self)
|
|
self.file_manager = FileManager(self)
|
|
self.ui_manager = UIManager(self)
|
|
|
|
# 트레이 아이콘 초기화
|
|
self.tray = SystemTray(self)
|
|
|
|
# UI 윈도우 참조
|
|
self.list_window = None
|
|
self.settings_window = None
|
|
|
|
# 업데이트 알림 중복 방지 상태
|
|
self._update_prompt_active = False
|
|
self._last_update_prompt_version = ""
|
|
self._last_update_prompt_ts = 0.0
|
|
|
|
def _ensure_data_dir(self):
|
|
"""데이터 디렉토리가 존재하는지 확인하고 없으면 생성합니다."""
|
|
data_dir = self.base_dir / "data"
|
|
if not data_dir.exists():
|
|
data_dir.mkdir(parents=True, exist_ok=True)
|
|
self.logger.info(f"데이터 디렉토리 생성: {data_dir}")
|
|
|
|
# ========================================================================
|
|
# 설정 관리 (Configuration Management)
|
|
# ========================================================================
|
|
|
|
def load_settings(self):
|
|
"""
|
|
설정 파일(settings.json)을 로드합니다.
|
|
|
|
파일이 없으면 기본 설정을 생성하여 저장합니다.
|
|
"""
|
|
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.settings.setdefault('update', {})
|
|
self.settings['update'].setdefault('connection_config_path', 'app/updater/config.json')
|
|
self.settings['update'].setdefault('environment', 'main')
|
|
self.settings['update'].setdefault('check_interval_hours', 1)
|
|
self.settings['update'].setdefault('program_id', 'voc_monitor')
|
|
self.settings['update'].setdefault('version_table', 'program_versions')
|
|
self.settings['update'].setdefault('ssl_verify', True)
|
|
self.settings['update'].setdefault('ca_bundle_path', '')
|
|
self.logger.info("설정 파일 로드 완료")
|
|
except FileNotFoundError:
|
|
# 기본 설정 생성
|
|
self.settings = {
|
|
"login": {"id": "", "pw": ""},
|
|
"crawling": {
|
|
"interval_minutes": 10,
|
|
"max_pages": 2,
|
|
"target_depts": ["차량"],
|
|
"keywords": ["1호선"],
|
|
"recheck_hours": 3,
|
|
"headless_mode": True
|
|
},
|
|
"noti": {
|
|
"sound": True,
|
|
"db_check_interval_minutes": 3,
|
|
"unchecked_check_interval_minutes": 10,
|
|
"unchecked_delay_enabled": True,
|
|
"use_related_filter": True
|
|
},
|
|
"update": {
|
|
"connection_config_path": "app/updater/config.json",
|
|
"environment": "main",
|
|
"check_interval_hours": 1,
|
|
"program_id": "voc_monitor",
|
|
"version_table": "program_versions",
|
|
"ssl_verify": True,
|
|
"ca_bundle_path": ""
|
|
},
|
|
"report": {
|
|
"output_path": str(get_data_dir() / "reports")
|
|
}
|
|
}
|
|
self.save_settings()
|
|
self.logger.info("기본 설정 파일 생성됨")
|
|
except Exception as e:
|
|
self.logger.error(f"설정 파일 로드 실패: {e}")
|
|
raise
|
|
|
|
def save_settings(self):
|
|
"""현재 메모리상의 설정(self.settings)을 파일로 저장합니다."""
|
|
try:
|
|
with open(self.settings_file, 'w', encoding='utf-8') as f:
|
|
json.dump(self.settings, f, indent=4, ensure_ascii=False)
|
|
self.logger.info("설정 파일 저장 완료")
|
|
except Exception as e:
|
|
self.logger.error(f"설정 파일 저장 실패: {e}")
|
|
|
|
def save_report_options(self, options):
|
|
"""
|
|
보고서 옵션(작성자, 부서 등)을 설정에 저장합니다.
|
|
|
|
Args:
|
|
options (dict): 보고서 옵션
|
|
"""
|
|
self.settings['report_options'] = options
|
|
self.save_settings()
|
|
|
|
# ========================================================================
|
|
# 비즈니스 로직 (Business Logic)
|
|
# ========================================================================
|
|
|
|
def is_related_post(self, title: str, dept: str) -> int:
|
|
"""
|
|
설정에 따른 관심글 여부를 판단합니다.
|
|
|
|
Args:
|
|
title: 게시글 제목
|
|
dept: 담당 부서
|
|
|
|
Returns:
|
|
int: 관심글이면 1, 아니면 0
|
|
"""
|
|
# 키워드 체크
|
|
for kw in self.settings['crawling']['keywords']:
|
|
if kw in title:
|
|
return 1
|
|
|
|
# 부서 체크
|
|
for target in self.settings['crawling']['target_depts']:
|
|
if target in dept:
|
|
return 1
|
|
|
|
return 0
|
|
|
|
# ========================================================================
|
|
# 백그라운드 작업 관리 (Background Task Management)
|
|
# ========================================================================
|
|
|
|
def start_background(self):
|
|
"""
|
|
백그라운드 작업 및 UI 루프를 시작합니다.
|
|
|
|
1. SchedulerManager 초기화 및 시작
|
|
2. NotificationManager 콜백 설정
|
|
3. 트레이 아이콘 실행
|
|
4. 시작 알림 표시
|
|
5. GUI 메인 루프 시작
|
|
"""
|
|
# 스케줄러 초기화 (테스트 모드 제외)
|
|
if not self.test_mode and self.model:
|
|
self.scheduler = SchedulerManager(self.settings, self.model, self.db, self.logger)
|
|
self.scheduler.set_callbacks(
|
|
is_related_func=self.is_related_post,
|
|
notify_func=self.show_popup_notification
|
|
)
|
|
self.scheduler.start()
|
|
else:
|
|
self.logger.info("테스트 모드: 웹 크롤링 생략, 로컬 DB 사용.")
|
|
|
|
# 알림 관리자 콜백 설정
|
|
self.notifier.set_popup_callback(self._create_notification_dialog)
|
|
|
|
# 업데이터 백그라운드 체크 시작
|
|
if self.update_manager:
|
|
try:
|
|
if self.test_mode and self.test_update_now:
|
|
self.logger.info("테스트 모드: 시작 직후 업데이트 1회 확인을 수행합니다.")
|
|
self.root.after(1200, self._check_updates_once)
|
|
else:
|
|
self.update_manager.start_background_check(
|
|
on_update_available=self._on_update_available,
|
|
on_error=self._on_update_check_error,
|
|
)
|
|
except Exception as e:
|
|
self.logger.error(f"업데이터 백그라운드 시작 실패: {e}", exc_info=True)
|
|
|
|
# 트레이 아이콘 실행
|
|
import threading
|
|
threading.Thread(target=self.tray.run, args=(
|
|
self.scheduler.run_crawling_cycle if self.scheduler else lambda: None, # on_check
|
|
self.check_updates_manual, # on_update
|
|
self.open_list_view, # on_list
|
|
self.open_settings, # on_settings
|
|
self.quit_app # on_quit
|
|
), daemon=True).start()
|
|
|
|
# 시작 알림
|
|
self.notifier.show_startup_notification()
|
|
|
|
# GUI 메인 루프 (Main Thread 점유)
|
|
self.root.mainloop()
|
|
|
|
def show_popup_notification(self, title: str, msg: str, voc_id: Optional[str] = None):
|
|
"""
|
|
팝업 알림 표시 (SchedulerManager 콜백)
|
|
|
|
Args:
|
|
title: 알림 제목
|
|
msg: 알림 내용
|
|
voc_id: VOC ID (선택)
|
|
"""
|
|
self.notifier.show_popup(title, msg, voc_id)
|
|
|
|
def _create_notification_dialog(self, title: str, msg: str, voc_id: Optional[str] = None):
|
|
"""
|
|
알림 다이얼로그 생성 (내부 메서드)
|
|
|
|
Args:
|
|
title: 알림 제목
|
|
msg: 알림 내용
|
|
voc_id: VOC ID (선택)
|
|
"""
|
|
from view.dialogs.notification_dialog import NotificationDialog
|
|
NotificationDialog(self, title, msg, voc_id)
|
|
|
|
# ========================================================================
|
|
# 업데이트 관련 (Updater Operations)
|
|
# ========================================================================
|
|
|
|
def check_updates_manual(self):
|
|
"""트레이 메뉴에서 수동 업데이트 확인"""
|
|
self.root.after(0, self._check_updates_once)
|
|
|
|
def _check_updates_once(self):
|
|
"""업데이트를 즉시 1회 확인"""
|
|
if not self.update_manager:
|
|
messagebox.showwarning("업데이트", "업데이터 설정이 없어 업데이트 확인을 수행할 수 없습니다.")
|
|
return
|
|
|
|
try:
|
|
self.logger.info("수동 업데이트 확인 시작")
|
|
version_info = self.update_manager.check_for_updates()
|
|
if version_info is None:
|
|
self.logger.info("수동 업데이트 확인 결과: 최신 버전")
|
|
self.notifier.show_toast("업데이트", "현재 최신 버전을 사용 중입니다.", with_sound=False)
|
|
return
|
|
|
|
self.logger.info(f"수동 업데이트 확인 결과: 새 버전 {version_info.version}")
|
|
self._show_update_prompt(version_info)
|
|
except UpdaterConfigError as e:
|
|
self.logger.warning(f"업데이트 확인 실패(설정): {e}")
|
|
messagebox.showerror("업데이트 설정 오류", str(e))
|
|
except UpdaterNetworkError as e:
|
|
self.logger.warning(f"업데이트 확인 실패(네트워크): {e}")
|
|
messagebox.showerror("업데이트 네트워크 오류", str(e))
|
|
except Exception as e:
|
|
self.logger.error(f"업데이트 수동 확인 실패: {e}", exc_info=True)
|
|
messagebox.showerror("업데이트 오류", f"업데이트 확인 중 오류가 발생했습니다.\n{e}")
|
|
|
|
def _on_update_available(self, version_info):
|
|
"""백그라운드 업데이트 발견 콜백"""
|
|
self.root.after(0, lambda: self._show_update_prompt(version_info))
|
|
|
|
def _on_update_check_error(self, error: Exception):
|
|
"""백그라운드 업데이트 확인 실패 콜백"""
|
|
self.logger.warning(f"백그라운드 업데이트 확인 실패: {error}")
|
|
|
|
def _show_update_prompt(self, version_info):
|
|
"""업데이트 설치 여부를 사용자에게 확인"""
|
|
manager = self.update_manager
|
|
if manager is None:
|
|
self.logger.warning("업데이터가 초기화되지 않아 업데이트 안내를 중단합니다.")
|
|
return
|
|
|
|
# 중복 알림 방지: 다이얼로그 표시 중에는 추가 표시 금지
|
|
if self._update_prompt_active:
|
|
self.logger.info("업데이트 안내창이 이미 표시 중이라 중복 표시를 생략합니다.")
|
|
return
|
|
|
|
# 중복 알림 방지: 동일 버전을 짧은 시간 내 재표시 금지
|
|
now_ts = time.monotonic()
|
|
if (
|
|
self._last_update_prompt_version == version_info.version
|
|
and (now_ts - self._last_update_prompt_ts) < 60
|
|
):
|
|
self.logger.info(
|
|
f"동일 버전({version_info.version}) 업데이트 안내 중복 감지로 재표시를 생략합니다."
|
|
)
|
|
return
|
|
|
|
release_note = (version_info.release_note or "-").strip()
|
|
release_note_preview = release_note[:200] + ("..." if len(release_note) > 200 else "")
|
|
current_version = manager.current_version
|
|
msg = (
|
|
f"새 버전이 있습니다.\n\n"
|
|
f"현재: {current_version}\n"
|
|
f"최신: {version_info.version}\n\n"
|
|
f"배포 노트:\n{release_note_preview}\n\n"
|
|
f"지금 업데이트를 진행할까요?"
|
|
)
|
|
|
|
self._update_prompt_active = True
|
|
self._last_update_prompt_version = version_info.version
|
|
self._last_update_prompt_ts = now_ts
|
|
|
|
try:
|
|
answer = messagebox.askyesno("업데이트 확인", msg)
|
|
if not answer:
|
|
self.logger.info("사용자가 업데이트를 나중에 진행하도록 선택")
|
|
return
|
|
|
|
ok, prep_msg = manager.prepare_update(version_info)
|
|
if not ok:
|
|
self.logger.error(f"업데이트 준비 실패: {prep_msg}")
|
|
messagebox.showerror("업데이트 준비 실패", prep_msg)
|
|
return
|
|
|
|
if not manager.launch_updater():
|
|
self.logger.error("updater.exe 실행 실패")
|
|
messagebox.showerror("업데이트 실행 실패", "updater.exe 실행에 실패했습니다.")
|
|
return
|
|
|
|
self.logger.info("업데이트 프로세스 시작, 메인 앱 종료")
|
|
self.quit_app()
|
|
finally:
|
|
self._update_prompt_active = False
|
|
|
|
# ========================================================================
|
|
# UI 이벤트 처리 (UI Event Handling) - UIManager에 위임
|
|
# ========================================================================
|
|
|
|
def open_list_view(self, focus_id=None):
|
|
"""
|
|
수집 목록 창 열기 (UIManager에 위임)
|
|
|
|
Args:
|
|
focus_id (str, optional): 창을 연 후 상세 팝업을 띄울 VOC ID
|
|
"""
|
|
self.ui_manager.open_list_view(focus_id)
|
|
|
|
def open_settings(self, icon=None, item=None):
|
|
"""설정 창 열기 (UIManager에 위임)"""
|
|
self.ui_manager.open_settings(icon, item)
|
|
|
|
def open_statistics(self, icon=None, item=None):
|
|
"""통계 분석 창 열기 (UIManager에 위임)"""
|
|
self.ui_manager.open_statistics(icon, item)
|
|
|
|
def request_detail_popup(self, voc_id: str):
|
|
"""
|
|
상세 정보 팝업 열기 (UIManager에 위임)
|
|
|
|
Args:
|
|
voc_id: VOC ID
|
|
"""
|
|
self.ui_manager.request_detail_popup(voc_id)
|
|
|
|
# ========================================================================
|
|
# 보고서 관련 (Report Operations) - ReportManager에 위임
|
|
# ========================================================================
|
|
|
|
def request_create_report(self, data: dict, parent=None):
|
|
"""
|
|
보고서 생성 요청 (ReportManager에 위임)
|
|
|
|
Args:
|
|
data: VOC 데이터
|
|
parent: 부모 윈도우 (선택)
|
|
"""
|
|
self.report_manager.request_create_report(data, parent)
|
|
|
|
def request_print_voc(self, data: dict):
|
|
"""
|
|
인쇄 요청 (ReportManager에 위임)
|
|
|
|
Args:
|
|
data: 인쇄할 VOC 데이터
|
|
"""
|
|
self.report_manager.request_print_voc(data)
|
|
|
|
def request_create_pdf(self, data: dict):
|
|
"""
|
|
PDF 생성 요청 (ReportManager에 위임)
|
|
|
|
Args:
|
|
data: VOC 데이터
|
|
"""
|
|
self.report_manager.request_create_pdf(data)
|
|
|
|
# ========================================================================
|
|
# 첨부파일 처리 (Attachment Handling) - FileManager에 위임
|
|
# ========================================================================
|
|
|
|
def open_attachment(self, data: dict, parent=None):
|
|
"""
|
|
첨부파일 열기 요청 (FileManager에 위임)
|
|
|
|
Args:
|
|
data: VOC 데이터
|
|
parent: 부모 윈도우 (선택)
|
|
"""
|
|
self.file_manager.open_attachment(data, parent)
|
|
|
|
# ========================================================================
|
|
# 데이터베이스 작업 (Database Operations)
|
|
# ========================================================================
|
|
|
|
def get_all_posts_formatted(self):
|
|
"""
|
|
DB에서 모든 포스트를 가져와 View용 list[dict]로 변환하여 반환
|
|
|
|
Returns:
|
|
list: 포맷팅된 게시글 리스트
|
|
"""
|
|
try:
|
|
rows = self.db.get_all_posts()
|
|
return self._format_rows(rows)
|
|
except DatabaseError as e:
|
|
self.logger.error(f"게시글 조회 실패: {e.message}")
|
|
return []
|
|
|
|
def _format_rows(self, rows):
|
|
"""
|
|
SQLite Row 객체를 딕셔너리 리스트로 변환 (내부 메서드)
|
|
|
|
Args:
|
|
rows: sqlite3.Row 객체 리스트
|
|
|
|
Returns:
|
|
list: 딕셔너리 리스트
|
|
"""
|
|
formatted = []
|
|
for r in rows:
|
|
formatted.append({
|
|
'id': str(r['id']),
|
|
'title': r['title'],
|
|
'writer': r['writer'],
|
|
'department': r['department'],
|
|
'is_public': r['is_public'],
|
|
'status': r['status'],
|
|
'channel': r['channel'] if 'channel' in r.keys() else '',
|
|
'date': r['date'],
|
|
'checked_at': r['checked_at'] if 'checked_at' in r.keys() else None
|
|
})
|
|
return formatted
|
|
|
|
def mark_as_read(self, voc_id: str):
|
|
"""
|
|
해당 VOC를 읽음(확인함) 처리하고 UI 갱신을 트리거합니다.
|
|
|
|
Args:
|
|
voc_id: VOC ID
|
|
"""
|
|
try:
|
|
self.db.mark_as_checked(voc_id)
|
|
|
|
# 목록 창이 열려있으면 갱신
|
|
if self.list_window and self.list_window.winfo_exists():
|
|
self.list_window.refresh_data_only()
|
|
except DatabaseError as e:
|
|
self.logger.error(f"읽음 처리 실패 (ID: {voc_id}): {e.message}")
|
|
|
|
# ========================================================================
|
|
# 스케줄러 제어 (Scheduler Control)
|
|
# ========================================================================
|
|
|
|
def update_config_runtime(self):
|
|
"""
|
|
설정 변경 사항을 런타임에 반영하고 스케줄러를 재설정합니다.
|
|
"""
|
|
try:
|
|
if self.scheduler:
|
|
self.scheduler.settings = self.settings
|
|
self.scheduler.update_schedule()
|
|
|
|
if self.update_manager:
|
|
update_settings = self.settings.get('update', {})
|
|
self.update_manager.check_interval = int(update_settings.get('check_interval_hours', 1))
|
|
|
|
self.logger.info("런타임 설정 반영 완료")
|
|
except Exception as e:
|
|
self.logger.error(f"런타임 설정 반영 실패: {e}", exc_info=True)
|
|
raise
|
|
|
|
# ========================================================================
|
|
# 애플리케이션 종료 (Application Shutdown)
|
|
# ========================================================================
|
|
|
|
def quit_app(self, icon=None, item=None):
|
|
"""
|
|
애플리케이션을 안전하게 종료합니다.
|
|
|
|
브라우저 종료, 스케줄러 중지, 트레이 아이콘 제거 등을 수행합니다.
|
|
"""
|
|
self.logger.info("애플리케이션 종료 중...")
|
|
|
|
try:
|
|
# 스케줄러 중지
|
|
if self.scheduler:
|
|
self.scheduler.stop()
|
|
|
|
# 업데이터 백그라운드 중지
|
|
if self.update_manager:
|
|
self.update_manager.stop_background_check()
|
|
self.update_manager.cleanup()
|
|
|
|
# 크롤러 종료
|
|
if self.model:
|
|
self.model.close()
|
|
|
|
# 트레이 아이콘 제거
|
|
self.tray.stop()
|
|
|
|
# GUI 종료
|
|
self.root.quit()
|
|
self.root.destroy()
|
|
|
|
self.logger.info("애플리케이션 종료 완료")
|
|
except Exception as e:
|
|
self.logger.error(f"종료 중 오류: {e}", exc_info=True)
|