"""Глобальные настройки контроля отклонений (recipes.db, singleton).""" from __future__ import annotations import json import logging import os from copy import deepcopy from pathlib import Path from typing import Any, Dict, Tuple from config import Config logger = logging.getLogger(__name__) SETTINGS_VERSION = 1 SETTINGS_ROW_ID = 1 DEFAULT_SETTINGS: Dict[str, Any] = { "version": SETTINGS_VERSION, "loading": { "enabled": True, "warning_pct": 10.0, "critical_pct": 15.0, "warning_min_sec": 3, "critical_min_sec": 1, "warning_max_sec": 180, "critical_max_sec": 600, "notify_warning": True, "notify_critical": True, }, "unloading": { "enabled": True, "warning_pct": 5.0, "critical_pct": 10.0, "notify_warning": True, "notify_critical": True, }, "mix_time": { "enabled": True, "warning_delta_sec": 30, "critical_delta_sec": 60, "notify_warning": True, "notify_critical": True, }, "left_in_mixer": { "enabled": True, "warning_min_kg": 5.0, "warning_min_pct": 2.0, "critical_min_kg": 15.0, "notify_warning": True, "notify_critical": True, }, } def get_legacy_json_path() -> Path: custom = os.getenv("WESP_FEED_QUALITY_SETTINGS_PATH", "").strip() if custom: p = Path(custom) if not p.is_absolute(): p = Path(Config.BASE_DIR) / p return p return Path(Config.DATA_DIR) / "wesp_feed_quality_settings.json" def default_settings() -> Dict[str, Any]: return deepcopy(DEFAULT_SETTINGS) def _coerce_bool(value: Any, default: bool) -> bool: if isinstance(value, bool): return value if isinstance(value, str): return value.strip().lower() in ("1", "true", "yes", "on") if isinstance(value, (int, float)): return bool(value) return default def _coerce_float(value: Any, default: float, *, min_v: float, max_v: float) -> float: try: num = float(value) except (TypeError, ValueError): num = default return max(min_v, min(max_v, num)) def _coerce_int(value: Any, default: int, *, min_v: int, max_v: int) -> int: try: num = int(round(float(value))) except (TypeError, ValueError): num = default return max(min_v, min(max_v, num)) def _merge_section( raw: Dict[str, Any], defaults: Dict[str, Any], *, float_fields: Tuple[Tuple[str, float, float, float], ...] = (), int_fields: Tuple[Tuple[str, int, int, int], ...] = (), ) -> Dict[str, Any]: out = deepcopy(defaults) if not isinstance(raw, dict): return out if "enabled" in raw: out["enabled"] = _coerce_bool(raw.get("enabled"), bool(defaults.get("enabled", True))) for key, default, min_v, max_v in float_fields: if key in raw: out[key] = _coerce_float(raw.get(key), float(defaults[key]), min_v=min_v, max_v=max_v) for key, default, min_v, max_v in int_fields: if key in raw: out[key] = _coerce_int(raw.get(key), int(defaults[key]), min_v=min_v, max_v=max_v) for notify_key in ("notify_warning", "notify_critical"): if notify_key in raw: out[notify_key] = _coerce_bool(raw.get(notify_key), bool(defaults.get(notify_key, True))) return out def normalize_settings(partial: Dict[str, Any]) -> Dict[str, Any]: """Валидирует и нормализует настройки; critical >= warning где применимо.""" base = default_settings() raw = partial if isinstance(partial, dict) else {} loading = _merge_section( raw.get("loading") or {}, base["loading"], float_fields=( ("warning_pct", 10.0, 0.1, 100.0), ("critical_pct", 15.0, 0.1, 100.0), ), int_fields=( ("warning_min_sec", 3, 0, 3600), ("critical_min_sec", 1, 0, 3600), ("warning_max_sec", 180, 0, 86400), ("critical_max_sec", 600, 0, 86400), ), ) if loading["critical_pct"] < loading["warning_pct"]: loading["critical_pct"] = loading["warning_pct"] if loading["critical_min_sec"] > loading["warning_min_sec"]: loading["critical_min_sec"] = loading["warning_min_sec"] if loading["critical_max_sec"] < loading["warning_max_sec"]: loading["critical_max_sec"] = loading["warning_max_sec"] unloading = _merge_section( raw.get("unloading") or {}, base["unloading"], float_fields=( ("warning_pct", 5.0, 0.1, 100.0), ("critical_pct", 10.0, 0.1, 100.0), ), ) if unloading["critical_pct"] < unloading["warning_pct"]: unloading["critical_pct"] = unloading["warning_pct"] mix_time = _merge_section( raw.get("mix_time") or {}, base["mix_time"], int_fields=( ("warning_delta_sec", 30, 1, 3600), ("critical_delta_sec", 60, 1, 3600), ), ) if mix_time["critical_delta_sec"] < mix_time["warning_delta_sec"]: mix_time["critical_delta_sec"] = mix_time["warning_delta_sec"] left_in_mixer = _merge_section( raw.get("left_in_mixer") or {}, base["left_in_mixer"], float_fields=( ("warning_min_kg", 5.0, 0.0, 10000.0), ("warning_min_pct", 2.0, 0.0, 100.0), ("critical_min_kg", 15.0, 0.0, 10000.0), ), ) if left_in_mixer["critical_min_kg"] < left_in_mixer["warning_min_kg"]: left_in_mixer["critical_min_kg"] = left_in_mixer["warning_min_kg"] return { "version": SETTINGS_VERSION, "loading": loading, "unloading": unloading, "mix_time": mix_time, "left_in_mixer": left_in_mixer, } def _read_legacy_json_raw() -> Dict[str, Any]: path = get_legacy_json_path() if not path.exists(): return {} try: data = json.loads(path.read_text(encoding="utf-8")) return data if isinstance(data, dict) else {} except Exception: logger.warning("Не удалось прочитать %s", path, exc_info=True) return {} def _payload_to_settings(payload: str | None) -> Dict[str, Any]: if not payload: return default_settings() try: raw = json.loads(payload) if not isinstance(raw, dict): return default_settings() return normalize_settings(raw) except Exception: return default_settings() def _persist_settings(settings: Dict[str, Any]) -> Dict[str, Any]: from app import db from app.models.feed_quality_settings import FeedQualitySettings from app.timeutil import utc_now_naive merged = normalize_settings(settings) row = db.session.get(FeedQualitySettings, SETTINGS_ROW_ID) if row is None: row = FeedQualitySettings( id=SETTINGS_ROW_ID, payload=json.dumps(merged, ensure_ascii=False), updated_at=utc_now_naive(), ) db.session.add(row) else: row.payload = json.dumps(merged, ensure_ascii=False) row.updated_at = utc_now_naive() db.session.commit() return merged def import_legacy_json_to_db(*, force: bool = False) -> bool: """Импортирует JSON в БД, если строки нет или force=True.""" from app import db from app.models.feed_quality_settings import FeedQualitySettings row = db.session.get(FeedQualitySettings, SETTINGS_ROW_ID) if row is not None and not force: return False raw = _read_legacy_json_raw() if not raw and row is not None: return False settings = normalize_settings(raw) if raw else default_settings() _persist_settings(settings) return True def create_feed_quality_settings_table(bind) -> None: from app.models.feed_quality_settings import FeedQualitySettings FeedQualitySettings.__table__.create(bind, checkfirst=True) def import_feed_quality_settings_json(bind) -> bool: """Импорт legacy JSON в пустую таблицу (миграция, без Flask app context).""" from sqlalchemy import inspect, text from sqlalchemy.engine import Connection, Engine from app.timeutil import utc_now_naive insp = inspect(bind) if not insp.has_table("feed_quality_settings"): return False def _import(conn: Connection) -> bool: existing = conn.execute( text("SELECT id FROM feed_quality_settings WHERE id = 1") ).fetchone() if existing is not None: return False raw = _read_legacy_json_raw() settings = normalize_settings(raw) if raw else default_settings() conn.execute( text( "INSERT INTO feed_quality_settings (id, payload, updated_at) " "VALUES (1, :payload, :updated_at)" ), { "payload": json.dumps(settings, ensure_ascii=False), "updated_at": utc_now_naive(), }, ) if conn.in_transaction(): conn.commit() return True if isinstance(bind, Engine): with bind.connect() as conn: return _import(conn) return _import(bind) def ensure_feed_quality_settings_table(bind, *, import_json: bool = False) -> None: create_feed_quality_settings_table(bind) if import_json: import_feed_quality_settings_json(bind) def get_feed_quality_settings() -> Dict[str, Any]: from app import db from app.models.feed_quality_settings import FeedQualitySettings row = db.session.get(FeedQualitySettings, SETTINGS_ROW_ID) if row is None: import_legacy_json_to_db() row = db.session.get(FeedQualitySettings, SETTINGS_ROW_ID) if row is None: return default_settings() return _payload_to_settings(row.payload) def save_feed_quality_settings(partial: Dict[str, Any]) -> Dict[str, Any]: current = get_feed_quality_settings() merged_input = deepcopy(current) for key, val in (partial or {}).items(): if key in ("loading", "unloading", "mix_time", "left_in_mixer") and isinstance(val, dict): merged_input[key] = {**current.get(key, {}), **val} else: merged_input[key] = val return _persist_settings(normalize_settings(merged_input)) def settings_for_api() -> Dict[str, Any]: return {"settings": get_feed_quality_settings(), "defaults": default_settings()}