""" Settings Manager — runtime-configurable values persisted in a "Settings" worksheet. Each setting is a (key, value) row. Complex values (e.g. visit_levels) are JSON-encoded. Reads are cached for a short TTL to avoid hammering the Google Sheets API. Defaults fall back to the constants in `config.py`. """ import json import time import threading from typing import Any import config from core import data_manager _SETTINGS_WORKSHEET = "Settings" _CACHE_TTL_SECONDS = 30 _lock = threading.RLock() _cache: dict = {"timestamp": 0.0, "values": {}} # Schema: # key -> (default_value, json_encoded?, type_hint) # type_hint is used to coerce the string-cell value back to the right Python type. _SCHEMA: dict[str, tuple] = { "min_lead_time_hours": (config.MIN_LEAD_TIME_HOURS, False, int), "buffer_minutes": (config.BUFFER_MINUTES, False, int), "visit_levels": (config.VISIT_LEVELS, True, "visit_levels"), "on_call_start_hour": ("07:00", False, str), "region_name": (config.REGION_NAME, False, str), "timezone": (config.TIMEZONE, False, str), "service_area": (config.SERVICE_AREA, False, str), "openai_model": (config.OPENAI_MODEL, False, str), } def _ensure_settings_worksheet() -> None: """Create the Settings worksheet on first use if it doesn't exist.""" try: data_manager._get_worksheet(_SETTINGS_WORKSHEET) data_manager._ensure_columns(_SETTINGS_WORKSHEET, ["key", "value"]) except Exception: # Worksheet doesn't exist — create it. ss = data_manager._get_spreadsheet() ws = ss.add_worksheet(title=_SETTINGS_WORKSHEET, rows=100, cols=2) ws.update(values=[["key", "value"]], range_name="A1:B1", value_input_option="RAW") # Bust caches so subsequent reads see the new worksheet. with data_manager._lock: data_manager._state["worksheets"].pop(_SETTINGS_WORKSHEET, None) data_manager._state["headers"].pop(_SETTINGS_WORKSHEET, None) data_manager._state["read_cache"].pop(_SETTINGS_WORKSHEET, None) def _read_all_settings() -> dict[str, str]: """Return {key: raw_string_value} for everything in the Settings sheet. Returns {} if the sheet doesn't exist yet (callers fall back to defaults). """ try: rows = data_manager._read_all(_SETTINGS_WORKSHEET) return {str(r.get("key", "")): str(r.get("value", "")) for r in rows if r.get("key")} except Exception: return {} def _refresh_cache_if_stale() -> None: with _lock: if (time.time() - _cache["timestamp"]) < _CACHE_TTL_SECONDS: return _cache["values"] = _read_all_settings() _cache["timestamp"] = time.time() def _coerce(raw: str, schema_entry: tuple) -> Any: default, is_json, type_hint = schema_entry if raw is None or raw == "": return default if is_json: try: parsed = json.loads(raw) except Exception: return default if type_hint == "visit_levels" and isinstance(parsed, dict): # JSON keys are always strings; rebuild int keys for visit levels. return { (int(k) if isinstance(k, str) and k.isdigit() else k): v for k, v in parsed.items() } return parsed if type_hint is int: try: return int(raw) except Exception: return default if type_hint is float: try: return float(raw) except Exception: return default return raw def get_setting(key: str, default: Any = None) -> Any: """Return the current value for `key`. Falls back to schema default or `default`.""" schema = _SCHEMA.get(key) if schema is None: return default _refresh_cache_if_stale() raw = _cache["values"].get(key) if raw is None or raw == "": return schema[0] if default is None else default return _coerce(raw, schema) def get_all_settings() -> dict[str, Any]: """Return every known setting with its current effective value.""" return {k: get_setting(k) for k in _SCHEMA.keys()} def set_setting(key: str, value: Any) -> Any: """Persist `value` for `key`. Returns the parsed-back value (after round-trip).""" if key not in _SCHEMA: raise KeyError(f"Unknown setting: {key}") schema = _SCHEMA[key] _, is_json, _ = schema _ensure_settings_worksheet() if is_json: # Normalize: for visit_levels, ensure int durations stay int. serialized = json.dumps(value) elif value is None: serialized = "" elif isinstance(value, bool): serialized = str(value) else: serialized = str(value) row_idx = data_manager._find_row_index(_SETTINGS_WORKSHEET, "key", key) payload = {"key": key, "value": serialized} if row_idx: data_manager._update_row(_SETTINGS_WORKSHEET, row_idx, payload) else: data_manager._append_row(_SETTINGS_WORKSHEET, payload) # Invalidate cache so the next read sees the new value. with _lock: _cache["timestamp"] = 0.0 return get_setting(key) def get_schema_defaults() -> dict[str, Any]: """Return the original config.py defaults for every setting (for "reset to default" UIs).""" return {k: v[0] for k, v in _SCHEMA.items()} # --- Convenience accessors --- # These let consumers write `settings.get_min_lead_time_hours()` instead of magic strings. def get_min_lead_time_hours() -> int: return get_setting("min_lead_time_hours") def get_buffer_minutes() -> int: return get_setting("buffer_minutes") def get_visit_levels() -> dict: return get_setting("visit_levels") def get_on_call_start_hour() -> str: return get_setting("on_call_start_hour") def get_region_name() -> str: return get_setting("region_name") def get_timezone() -> str: return get_setting("timezone") def get_service_area() -> str: return get_setting("service_area") def get_openai_model() -> str: return get_setting("openai_model")