from __future__ import annotations from typing import Any from pydantic import BaseModel, Field from pydantic_settings import BaseSettings, SettingsConfigDict from paths import DATA_DIR, EXTENSION_DIR, ROOT_DIR, get_dates_file_path, get_env_files from services.settings_store import load_overrides, save_overrides CONFIGURABLE_FIELDS = [ "gitlab_url", "gitlab_token", "gitlab_project_path", "gitlab_board_id", "gitlab_board_labels", "gitlab_milestone_title", "pts_url", "pts_project_name", "pts_default_task_name", "auto_fill_enabled", "auto_fill_hour", "auto_fill_minute", "max_hours_per_day", "hour_step", "xai_api_key", "xai_base_url", "grok_model", "use_llm", "use_description_variation", "pts_username", "pts_password", "teams_username", "teams_password", "teams_calendar_enabled", "teams_calendar_timezone", "teams_meeting_task_name", "teams_headless", "teams_meeting_exclude_keywords", # CompBase attendance punch (independent of PTS) "compbase_list_url", "compbase_fill_url", "compbase_username", "compbase_password", "compbase_default_days", "compbase_out_time_mode", # legacy Graph fields kept for backward-compatible settings files "azure_client_id", "azure_tenant_id", ] class EnvSettings(BaseSettings): model_config = SettingsConfigDict( env_file=get_env_files() or None, env_file_encoding="utf-8", extra="ignore", ) gitlab_url: str = "https://gitlab.supermicro.com" gitlab_token: str = "" gitlab_project_path: str = "super-cloud/scc-flex" gitlab_board_id: int = 1126 gitlab_board_labels: str = "Category::BugFix,Type::Bug" gitlab_milestone_title: str = "" pts_url: str = "https://tw-timesheet.supermicro.com/PTS" pts_project_name: str = "SuperCloud Composer" pts_default_task_name: str = "Implement" pts_username: str = "" pts_password: str = "" auto_fill_enabled: bool = True auto_fill_hour: int = 18 auto_fill_minute: int = 0 backend_host: str = "0.0.0.0" backend_port: int = 8765 max_hours_per_day: float = 8.0 hour_step: float = 0.5 xai_api_key: str = "" xai_base_url: str = "https://api.x.ai" grok_model: str = "grok-3-mini" use_llm: bool = True use_description_variation: bool = True teams_calendar_enabled: bool = False teams_username: str = "" teams_password: str = "" teams_calendar_timezone: str = "Asia/Taipei" teams_meeting_task_name: str = "Meeting" teams_headless: bool = False teams_meeting_exclude_keywords: str = "貼心的提醒,請記得下班刷退" azure_client_id: str = "" azure_tenant_id: str = "organizations" compbase_list_url: str = "http://tw-compbase.supermicro.com:6699" compbase_fill_url: str = "http://tw-compbase.supermicro.com:6671" compbase_username: str = "" compbase_password: str = "" compbase_default_days: int = 14 compbase_out_time_mode: str = "expected" class AppSettings(BaseModel): gitlab_url: str = "https://gitlab.supermicro.com" gitlab_token: str = "" gitlab_project_path: str = "super-cloud/scc-flex" gitlab_board_id: int = 1126 gitlab_board_labels: str = "Category::BugFix,Type::Bug" gitlab_milestone_title: str = "" pts_url: str = "https://tw-timesheet.supermicro.com/PTS" pts_project_name: str = "SuperCloud Composer" pts_default_task_name: str = "Implement" pts_username: str = "" pts_password: str = "" auto_fill_enabled: bool = True auto_fill_hour: int = 18 auto_fill_minute: int = 0 backend_host: str = "0.0.0.0" backend_port: int = 8765 max_hours_per_day: float = 8.0 hour_step: float = 0.5 xai_api_key: str = "" xai_base_url: str = "https://api.x.ai" grok_model: str = "grok-3-mini" use_llm: bool = True use_description_variation: bool = True teams_calendar_enabled: bool = False teams_username: str = "" teams_password: str = "" teams_calendar_timezone: str = "Asia/Taipei" teams_meeting_task_name: str = "Meeting" teams_headless: bool = False teams_meeting_exclude_keywords: str = "貼心的提醒,請記得下班刷退" azure_client_id: str = "" azure_tenant_id: str = "organizations" compbase_list_url: str = "http://tw-compbase.supermicro.com:6699" compbase_fill_url: str = "http://tw-compbase.supermicro.com:6671" compbase_username: str = "" compbase_password: str = "" compbase_default_days: int = 14 compbase_out_time_mode: str = "expected" @property def teams_meeting_exclude_keyword_list(self) -> list[str]: return [part.strip() for part in self.teams_meeting_exclude_keywords.split(",") if part.strip()] @property def gitlab_board_label_list(self) -> list[str]: return [part.strip() for part in self.gitlab_board_labels.split(",") if part.strip()] _env = EnvSettings() def build_settings() -> AppSettings: data = _env.model_dump() overrides = load_overrides() for key in CONFIGURABLE_FIELDS: if key in overrides and overrides[key] is not None: data[key] = overrides[key] return AppSettings(**data) def get_settings_dict() -> dict[str, Any]: return build_settings().model_dump() def update_settings(updates: dict[str, Any]) -> AppSettings: current = load_overrides() merged = {**current} for key in CONFIGURABLE_FIELDS: if key not in updates: continue value = updates[key] if value is not None: merged[key] = value save_overrides(merged) return reload_settings() def reload_settings() -> AppSettings: global _runtime _runtime = build_settings() return _runtime _runtime = build_settings() class SettingsProxy: def __getattr__(self, name: str) -> Any: return getattr(_runtime, name) def __repr__(self) -> str: return f"SettingsProxy({_runtime!r})" settings = SettingsProxy()