# -*- coding: utf-8 -*- """ 설정 관리 모듈 애플리케이션의 설정을 관리합니다. 이 모듈은 다음 기능을 제공합니다: - 설정 파일 읽기/쓰기 - 기본 설정 값 관리 - 런타임 설정 변경 - 설정 유효성 검사 """ import os import json import configparser from pathlib import Path from typing import Any, Dict, Optional, Union, List from dataclasses import dataclass, field, asdict from .constants import ( CONFIG_FILE, DATA_DIR, LAYOUT_RATIOS, FONT_FAMILY, FONT_SIZES, UI_FONT_SETTINGS, ) from .logger import get_logger # 로거 설정 logger = get_logger(__name__) # ============================================================================ # 설정 데이터 클래스 # ============================================================================ @dataclass class AppSettings: """애플리케이션 일반 설정""" language: str = "ko" theme: str = "dark" font_family: str = FONT_FAMILY font_size: int = FONT_SIZES["body"] auto_save: bool = True auto_save_interval: int = 30 # 초 check_updates: bool = True update_check_interval: int = 3600 # 초 start_minimized: bool = False minimize_to_tray: bool = True show_notifications: bool = True @dataclass class LayoutSettings: """레이아웃 설정""" info_bar_ratio: float = LAYOUT_RATIOS["info_bar"] content_area_ratio: float = LAYOUT_RATIOS["content_area"] status_bar_ratio: float = LAYOUT_RATIOS["status_bar"] section_panel_ratio: float = LAYOUT_RATIOS["section_panel"] todo_panel_ratio: float = LAYOUT_RATIOS["todo_panel"] daily_inspection_ratio: float = LAYOUT_RATIOS["daily_inspection"] todo_list_ratio: float = LAYOUT_RATIOS["todo_list"] memo_ratio: float = LAYOUT_RATIOS["memo"] window_width: int = 1600 window_height: int = 900 window_x: int = -1 # -1은 화면 중앙 window_y: int = -1 @dataclass class DatabaseSettings: """데이터베이스 설정""" db_path: str = str(DATA_DIR / "handover.db") backup_enabled: bool = True backup_interval: int = 86400 # 24시간 backup_count: int = 7 sync_enabled: bool = False sync_url: str = "" sync_key: str = "" sync_interval: int = 300 # 5분 @dataclass class WeatherSettings: """날씨 설정""" enabled: bool = True api_key: str = "" location_lat: float = 35.1796 # 부산 기본값 location_lon: float = 129.0756 location_name: str = "부산" update_interval: int = 1800 # 30분 forecast_unit: str = "1시간 단위" # 1시간 단위 또는 3시간 단위 @dataclass class FieldSetting: """필드 설정 데이터 클래스""" name: str = "" visible: bool = True width: int = 100 display_format: Optional[str] = None # "full", "short", "month_day" 등 @dataclass class UserSettings: """사용자 설정 (런타임)""" current_team: str = "1팀" current_shift: str = "주간" last_user_id: int = 0 remember_login: bool = True # 필드 설정: 팀별로 섹션별 필드 설정 저장 # 형식: {"팀명": {"섹션명": [FieldSetting, ...], ...}, ...} field_settings: Dict[str, Dict[str, List[Dict[str, Any]]]] = field(default_factory=dict) @dataclass class TrainSettings: """편성 설정 (A: 구형, B: 신형)""" # 기본값: 홀수=A(구형), 짝수=B(신형) # 신평 차량: 6,7,8,9,13,16,32~48 train_1_type: str = "A" train_2_type: str = "B" train_3_type: str = "A" train_4_type: str = "B" train_5_type: str = "A" train_6_type: str = "B" train_7_type: str = "B" train_8_type: str = "B" train_9_type: str = "B" train_10_type: str = "B" train_11_type: str = "A" train_12_type: str = "B" train_13_type: str = "B" train_14_type: str = "B" train_15_type: str = "A" train_16_type: str = "B" train_17_type: str = "A" train_18_type: str = "B" train_19_type: str = "A" train_20_type: str = "B" train_21_type: str = "A" train_22_type: str = "B" train_23_type: str = "A" train_24_type: str = "B" train_25_type: str = "A" train_26_type: str = "B" train_27_type: str = "A" train_28_type: str = "B" train_29_type: str = "A" train_30_type: str = "B" train_31_type: str = "A" train_32_type: str = "B" train_33_type: str = "B" train_34_type: str = "B" train_35_type: str = "B" train_36_type: str = "B" train_37_type: str = "B" train_38_type: str = "B" train_39_type: str = "B" train_40_type: str = "B" train_41_type: str = "B" train_42_type: str = "B" train_43_type: str = "B" train_44_type: str = "B" train_45_type: str = "B" train_46_type: str = "B" train_47_type: str = "B" train_48_type: str = "B" train_49_type: str = "A" train_50_type: str = "B" train_51_type: str = "A" @dataclass class UIFontSettings: """UI 폰트 설정""" # 인포바 info_bar_title_family: str = FONT_FAMILY info_bar_title_size: int = 16 info_bar_title_weight: str = "bold" info_bar_content_family: str = FONT_FAMILY info_bar_content_size: int = 14 info_bar_content_weight: str = "normal" # 섹션 section_title_family: str = FONT_FAMILY section_title_size: int = 16 section_title_weight: str = "bold" section_header_family: str = FONT_FAMILY section_header_size: int = 13 section_header_weight: str = "bold" section_content_family: str = FONT_FAMILY section_content_size: int = 13 section_content_weight: str = "normal" # 할일 todo_title_family: str = FONT_FAMILY todo_title_size: int = 14 todo_title_weight: str = "bold" todo_content_family: str = FONT_FAMILY todo_content_size: int = 13 todo_content_weight: str = "normal" # 메모 memo_title_family: str = FONT_FAMILY memo_title_size: int = 14 memo_title_weight: str = "bold" memo_content_family: str = FONT_FAMILY memo_content_size: int = 13 memo_content_weight: str = "normal" # 일상검수 daily_title_family: str = FONT_FAMILY daily_title_size: int = 14 daily_title_weight: str = "bold" daily_content_family: str = FONT_FAMILY daily_content_size: int = 13 daily_content_weight: str = "normal" daily_train_family: str = FONT_FAMILY daily_train_size: int = 15 daily_train_weight: str = "bold" # 상태바 status_content_family: str = FONT_FAMILY status_content_size: int = 12 status_content_weight: str = "normal" # 다이얼로그 dialog_title_family: str = FONT_FAMILY dialog_title_size: int = 14 dialog_title_weight: str = "bold" dialog_label_family: str = FONT_FAMILY dialog_label_size: int = 12 dialog_label_weight: str = "normal" dialog_input_family: str = FONT_FAMILY dialog_input_size: int = 12 dialog_input_weight: str = "normal" dialog_button_family: str = FONT_FAMILY dialog_button_size: int = 12 dialog_button_weight: str = "medium" @dataclass class FieldSetting: """필드 설정 데이터 클래스""" name: str visible: bool = True width: int = 100 display_format: Optional[str] = None # "full", "short", "month_day" 등 @dataclass class AllSettings: """모든 설정을 통합하는 클래스""" app: AppSettings = field(default_factory=AppSettings) layout: LayoutSettings = field(default_factory=LayoutSettings) database: DatabaseSettings = field(default_factory=DatabaseSettings) weather: WeatherSettings = field(default_factory=WeatherSettings) user: UserSettings = field(default_factory=UserSettings) ui_font: UIFontSettings = field(default_factory=UIFontSettings) train: TrainSettings = field(default_factory=TrainSettings) # ============================================================================ # 설정 관리자 클래스 # ============================================================================ class ConfigManager: """ 설정 관리자 클래스 싱글톤 패턴을 사용하여 애플리케이션 전역에서 하나의 인스턴스만 사용합니다. 설정 파일의 읽기/쓰기 및 런타임 설정 변경을 담당합니다. Attributes: config_path: 설정 파일 경로 settings: 현재 설정 객체 Examples: >>> config = ConfigManager() >>> config.get('app', 'theme') 'dark' >>> config.set('app', 'theme', 'light') >>> config.save() """ _instance: Optional['ConfigManager'] = None def __new__(cls, config_path: Path = None): """싱글톤 패턴 구현""" if cls._instance is None: cls._instance = super().__new__(cls) cls._instance._initialized = False return cls._instance def __init__(self, config_path: Path = None): """ 설정 관리자 초기화 Args: config_path: 설정 파일 경로 (기본값: CONFIG_FILE) """ # 이미 초기화된 경우 건너뛰기 if self._initialized: return self.config_path = config_path or CONFIG_FILE self.settings = AllSettings() self._parser = configparser.ConfigParser() # 필드 설정 파일 경로 (레거시, 마이그레이션용) self.field_settings_file = DATA_DIR / "field_settings.json" # SettingsManager 인스턴스 (지연 로딩) self._settings_manager = None # 설정 파일 로드 self.load() # 필드 설정 로드 (JSON -> SQLite 마이그레이션 포함) self._load_field_settings() self._initialized = True logger.info(f"설정 관리자 초기화 완료: {self.config_path}") def load(self) -> bool: """ 설정 파일에서 설정을 로드합니다. Returns: 로드 성공 여부 """ try: if self.config_path.exists(): self._parser.read(self.config_path, encoding='utf-8') self._load_settings_from_parser() logger.info("설정 파일 로드 완료") return True else: logger.warning("설정 파일이 없습니다. 기본 설정을 사용합니다.") self._create_default_config() return False except Exception as e: logger.error(f"설정 파일 로드 실패: {e}") self._create_default_config() return False def save(self) -> bool: """ 현재 설정을 파일에 저장합니다. Returns: 저장 성공 여부 """ try: self._save_settings_to_parser() # 부모 디렉토리 생성 self.config_path.parent.mkdir(parents=True, exist_ok=True) with open(self.config_path, 'w', encoding='utf-8') as f: self._parser.write(f) logger.info("설정 파일 저장 완료") return True except Exception as e: logger.error(f"설정 파일 저장 실패: {e}") return False def get(self, section: str, key: str, default: Any = None) -> Any: """ 설정 값을 가져옵니다. Args: section: 설정 섹션 (app, layout, database, weather, user) key: 설정 키 default: 기본값 Returns: 설정 값 Examples: >>> config.get('app', 'theme') 'dark' """ try: section_obj = getattr(self.settings, section, None) if section_obj is None: logger.warning(f"존재하지 않는 섹션: {section}") return default value = getattr(section_obj, key, default) return value except Exception as e: logger.error(f"설정 값 가져오기 실패: {section}.{key} - {e}") return default def set(self, section: str, key: str, value: Any) -> bool: """ 설정 값을 변경합니다. Args: section: 설정 섹션 key: 설정 키 value: 새로운 값 Returns: 변경 성공 여부 Examples: >>> config.set('app', 'theme', 'light') True """ try: section_obj = getattr(self.settings, section, None) if section_obj is None: logger.warning(f"존재하지 않는 섹션: {section}") return False if not hasattr(section_obj, key): logger.warning(f"존재하지 않는 키: {section}.{key}") return False setattr(section_obj, key, value) logger.debug(f"설정 변경: {section}.{key} = {value}") return True except Exception as e: logger.error(f"설정 값 변경 실패: {section}.{key} - {e}") return False def get_section(self, section: str) -> Optional[Dict[str, Any]]: """ 설정 섹션 전체를 딕셔너리로 반환합니다. Args: section: 설정 섹션 Returns: 섹션 설정 딕셔너리 """ try: section_obj = getattr(self.settings, section, None) if section_obj is None: return None return asdict(section_obj) except Exception as e: logger.error(f"섹션 가져오기 실패: {section} - {e}") return None def reset_to_default(self, section: str = None): """ 설정을 기본값으로 초기화합니다. Args: section: 초기화할 섹션 (None이면 전체 초기화) """ if section: if section == 'app': self.settings.app = AppSettings() elif section == 'layout': self.settings.layout = LayoutSettings() elif section == 'database': self.settings.database = DatabaseSettings() elif section == 'weather': self.settings.weather = WeatherSettings() elif section == 'user': self.settings.user = UserSettings() elif section == 'ui_font': self.settings.ui_font = UIFontSettings() elif section == 'train': self.settings.train = TrainSettings() logger.info(f"섹션 '{section}' 기본값으로 초기화") else: self.settings = AllSettings() logger.info("전체 설정 기본값으로 초기화") def _load_settings_from_parser(self): """파서에서 설정 객체로 로드""" for section_name in ['app', 'layout', 'database', 'weather', 'user', 'ui_font', 'train']: if self._parser.has_section(section_name): section_obj = getattr(self.settings, section_name) for key in self._parser.options(section_name): if hasattr(section_obj, key): raw_value = self._parser.get(section_name, key) # 타입 변환 expected_type = type(getattr(section_obj, key)) converted_value = self._convert_value(raw_value, expected_type) setattr(section_obj, key, converted_value) def _save_settings_to_parser(self): """설정 객체에서 파서로 저장""" for section_name in ['app', 'layout', 'database', 'weather', 'user', 'ui_font', 'train']: if not self._parser.has_section(section_name): self._parser.add_section(section_name) section_obj = getattr(self.settings, section_name) section_dict = asdict(section_obj) for key, value in section_dict.items(): # 필드 설정은 JSON으로 저장 if key == 'field_settings': self._parser.set(section_name, key, json.dumps(value, ensure_ascii=False)) else: self._parser.set(section_name, key, str(value)) def _create_default_config(self): """기본 설정 파일 생성""" self.settings = AllSettings() self.save() logger.info("기본 설정 파일 생성 완료") @staticmethod def _convert_value(value: str, target_type: type) -> Any: """ 문자열 값을 대상 타입으로 변환합니다. Args: value: 변환할 문자열 target_type: 대상 타입 Returns: 변환된 값 """ if target_type == bool: return value.lower() in ('true', 'yes', '1', 'on') elif target_type == int: return int(value) elif target_type == float: return float(value) else: return value # ======================================================================== # 편의 메서드 # ======================================================================== @property def theme(self) -> str: """현재 테마 반환""" return self.settings.app.theme @theme.setter def theme(self, value: str): """테마 설정""" self.settings.app.theme = value @property def current_team(self) -> str: """현재 팀 반환""" return self.settings.user.current_team @current_team.setter def current_team(self, value: str): """현재 팀 설정""" self.settings.user.current_team = value @property def current_shift(self) -> str: """현재 근무 유형 반환""" return self.settings.user.current_shift @current_shift.setter def current_shift(self, value: str): """현재 근무 유형 설정""" self.settings.user.current_shift = value def get_layout_sizes(self) -> Dict[str, float]: """레이아웃 크기 비율 반환""" return asdict(self.settings.layout) def get_ui_font(self, area: str, style: str) -> Dict[str, Any]: """ UI 영역별 폰트 설정 가져오기 Args: area: 영역 (info_bar, section, todo, memo, daily, status, dialog) style: 스타일 (title, content, header, label, input, button, train 등) Returns: {"family": str, "size": int, "weight": str} Examples: >>> config.get_ui_font("section", "title") {"family": "GmarketSans", "size": 16, "weight": "bold"} """ ui_font = self.settings.ui_font # 영역과 스타일 조합으로 속성 이름 생성 prefix = area.replace("_", "") # info_bar -> infobar # 영역별 prefix 매핑 area_map = { "info_bar": "info_bar", "section": "section", "todo": "todo", "memo": "memo", "daily": "daily", "daily_inspection": "daily", "status": "status", "status_bar": "status", "dialog": "dialog", } prefix = area_map.get(area, area) # 속성 이름 생성 family_attr = f"{prefix}_{style}_family" size_attr = f"{prefix}_{style}_size" weight_attr = f"{prefix}_{style}_weight" # 기본값 default = {"family": FONT_FAMILY, "size": 13, "weight": "normal"} try: return { "family": getattr(ui_font, family_attr, default["family"]), "size": getattr(ui_font, size_attr, default["size"]), "weight": getattr(ui_font, weight_attr, default["weight"]), } except AttributeError: logger.warning(f"UI 폰트 설정을 찾을 수 없음: {area}.{style}") return default def set_ui_font(self, area: str, style: str, family: str = None, size: int = None, weight: str = None): """ UI 영역별 폰트 설정 변경 Args: area: 영역 style: 스타일 family: 폰트 패밀리 (None이면 변경 안함) size: 폰트 크기 (None이면 변경 안함) weight: 폰트 굵기 (None이면 변경 안함) """ ui_font = self.settings.ui_font area_map = { "info_bar": "info_bar", "section": "section", "todo": "todo", "memo": "memo", "daily": "daily", "daily_inspection": "daily", "status": "status", "status_bar": "status", "dialog": "dialog", } prefix = area_map.get(area, area) if family is not None: setattr(ui_font, f"{prefix}_{style}_family", family) if size is not None: setattr(ui_font, f"{prefix}_{style}_size", size) if weight is not None: setattr(ui_font, f"{prefix}_{style}_weight", weight) logger.debug(f"UI 폰트 설정 변경: {area}.{style}") @property def ui_font_settings(self) -> 'UIFontSettings': """UI 폰트 설정 반환""" return self.settings.ui_font # ======================================================================== # 필드 설정 관리 메서드 (SettingsManager 사용) # ======================================================================== def _get_settings_manager(self): """SettingsManager 인스턴스 반환 (지연 로딩)""" if not hasattr(self, '_settings_manager') or self._settings_manager is None: from .settings_manager import get_settings_manager self._settings_manager = get_settings_manager() return self._settings_manager def _load_field_settings(self): """ 필드 설정 로드 (레거시 JSON -> SQLite DB 마이그레이션) 기존 JSON 파일이 있으면 SQLite DB로 마이그레이션하고, 이후에는 DB에서 직접 로드합니다. """ try: settings_mgr = self._get_settings_manager() # 기존 JSON 파일 마이그레이션 체크 if self.field_settings_file.exists(): logger.info("기존 JSON 필드 설정을 SQLite DB로 마이그레이션합니다.") self._migrate_json_to_db() # 기존 section_field_settings.json 파일도 마이그레이션 체크 old_settings_file = DATA_DIR / "section_field_settings.json" if old_settings_file.exists(): logger.info("기존 section_field_settings.json을 SQLite DB로 마이그레이션합니다.") self._migrate_old_json_to_db(old_settings_file) logger.info("필드 설정 로드 완료 (SQLite DB 사용)") except Exception as e: logger.error(f"필드 설정 로드 실패: {e}") def _migrate_json_to_db(self): """JSON 파일을 SQLite DB로 마이그레이션""" try: with open(self.field_settings_file, 'r', encoding='utf-8') as f: data = json.load(f) settings_mgr = self._get_settings_manager() from .settings_manager import FieldSetting as DBFieldSetting for team, sections_data in data.items(): for section_name, fields_data in sections_data.items(): fields = [] for field_data in fields_data: field = DBFieldSetting( name=field_data.get('name', ''), visible=field_data.get('visible', True), width=field_data.get('width', 100), display_format=field_data.get('display_format') ) fields.append(field) settings_mgr.save_field_settings(team, section_name, fields) # 마이그레이션 완료 후 기존 파일 백업 backup_file = self.field_settings_file.with_suffix('.json.bak') self.field_settings_file.rename(backup_file) logger.info(f"JSON 파일을 SQLite DB로 마이그레이션 완료, 백업: {backup_file}") except Exception as e: logger.error(f"JSON 마이그레이션 실패: {e}") def _migrate_old_json_to_db(self, old_file): """기존 형식의 JSON 파일을 SQLite DB로 마이그레이션""" try: with open(old_file, 'r', encoding='utf-8') as f: old_data = json.load(f) settings_mgr = self._get_settings_manager() from .settings_manager import FieldSetting as DBFieldSetting for team, team_data in old_data.items(): sections = team_data.get("sections", {}) for section_name, section_data in sections.items(): fields_data = section_data.get("fields", []) fields = [] for field_data in fields_data: field = DBFieldSetting( name=field_data.get('name', ''), visible=field_data.get('visible', True), width=field_data.get('width', 100), display_format=field_data.get('display_format') ) fields.append(field) settings_mgr.save_field_settings(team, section_name, fields) # 마이그레이션 완료 후 기존 파일 백업 backup_file = old_file.with_suffix('.json.bak') old_file.rename(backup_file) logger.info(f"기존 JSON 파일을 SQLite DB로 마이그레이션 완료, 백업: {backup_file}") except Exception as e: logger.error(f"기존 JSON 마이그레이션 실패: {e}") def save_field_settings( self, team: str, section_name: str, fields: List[FieldSetting] ): """ 필드 설정 저장 (SQLite DB 사용) Args: team: 팀 이름 (예: "1팀") section_name: 섹션 이름 (예: "고장") fields: 필드 설정 리스트 """ try: settings_mgr = self._get_settings_manager() from .settings_manager import FieldSetting as DBFieldSetting # FieldSetting -> DBFieldSetting 변환 db_fields = [] for f in fields: db_field = DBFieldSetting( name=f.name, visible=f.visible, width=f.width, display_format=f.display_format ) db_fields.append(db_field) settings_mgr.save_field_settings(team, section_name, db_fields) logger.info(f"필드 설정 저장 완료: {team} - {section_name}") except Exception as e: logger.error(f"필드 설정 저장 실패: {e}") raise def load_field_settings( self, team: str, section_name: str ) -> Optional[List[FieldSetting]]: """ 필드 설정 로드 (SQLite DB 사용) Args: team: 팀 이름 section_name: 섹션 이름 Returns: 필드 설정 리스트 (없으면 None) """ try: settings_mgr = self._get_settings_manager() db_fields = settings_mgr.load_field_settings(team, section_name) if not db_fields: return None # DBFieldSetting -> FieldSetting 변환 fields = [] for f in db_fields: field = FieldSetting( name=f.name, visible=f.visible, width=f.width, display_format=f.display_format ) fields.append(field) return fields except Exception as e: logger.error(f"필드 설정 로드 실패: {e}") return None def get_field_setting( self, team: str, section_name: str, field_name: str ) -> Optional[FieldSetting]: """ 특정 필드 설정 가져오기 Args: team: 팀 이름 section_name: 섹션 이름 field_name: 필드 이름 Returns: 필드 설정 (없으면 None) """ fields = self.load_field_settings(team, section_name) if not fields: return None for field_setting in fields: if field_setting.name == field_name: return field_setting return None def apply_field_settings_to_fields( self, team: str, section_name: str, fields: List[Any] # FieldConfig 리스트 ): """ 저장된 설정을 필드에 적용 Args: team: 팀 이름 section_name: 섹션 이름 fields: FieldConfig 리스트 """ settings_mgr = self._get_settings_manager() settings_mgr.apply_field_settings_to_fields(team, section_name, fields) logger.debug(f"필드 설정 적용 완료: {team} - {section_name}") def reset_team_field_settings(self, team: str): """팀별 필드 설정 초기화""" settings_mgr = self._get_settings_manager() settings_mgr.reset_team_field_settings(team) logger.info(f"팀 필드 설정 초기화: {team}") def reset_all_field_settings(self): """모든 필드 설정 초기화""" settings_mgr = self._get_settings_manager() settings_mgr.reset_all_field_settings() logger.info("모든 필드 설정 초기화 완료") # ============================================================================ # 모듈 레벨 편의 함수 # ============================================================================ def get_config() -> ConfigManager: """ 설정 관리자 인스턴스를 반환합니다. Returns: ConfigManager 인스턴스 """ return ConfigManager()