VOC_Monitor/app/controllers/controller.py

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)