"""Feed settings: one `settings` row with key `feed`, a JSON object read by the worker on every fire. Unknown keys are ignored on load and missing keys take the default, so the row may be partial. A save validates, bumps the row version, writes an audit row and NOTIFYs `ps_settings` so the worker reschedules. """ from __future__ import annotations import json import re from datetime import date, datetime, time, timedelta from typing import Any from psycopg.types.json import Jsonb from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator from .. import audit KEY = "feed" SETTINGS_CHANNEL = "ps_settings" _HHMM = re.compile(r"^([01][0-9]|2[0-3]):[0-5][0-9]$") _DEPT = re.compile(r"^[A-Za-z0-9]{1,10}$") class SettingsError(ValueError): """A patch the model refuses. `errors` holds one short line per problem.""" def __init__(self, errors: list[str]): super().__init__("; ".join(errors)) self.errors = errors class FeedSettings(BaseModel): model_config = ConfigDict(extra="ignore") interval_shop_minutes: int = Field(default=5, ge=1, le=1440) interval_off_minutes: int = Field(default=30, ge=1, le=1440) shop_days: list[int] = Field(default_factory=lambda: [0, 1, 2, 3, 4]) # Monday = 0, site time shop_start: str = "06:00" # HH:MM site time shop_end: str = "18:00" window_back_days: int = Field(default=14, ge=0, le=120) window_ahead_days: int = Field(default=30, ge=0, le=365) golive_date: date | None = None # work orders released before this date are ignored departments: list[str] = Field(default_factory=list) # shop codes; empty = every department the map routes to drawing_recheck_hours: int = Field(default=4, ge=1, le=168) drawing_budget: int = Field(default=60, ge=1, le=500) drawing_interval_minutes: int = Field(default=30, ge=1, le=1440) labor_tickets: bool = True # read labor tickets (at most every 30 minutes) auto_release: bool = True auto_release_max: int = Field(default=25, ge=0, le=500) paused: bool = False # stops scheduled feed, drawing and auto-release runs; "run now" still works @field_validator("shop_days") @classmethod def _days(cls, v: list[int]) -> list[int]: if any(d < 0 or d > 6 for d in v): raise ValueError("shop days are 0 (Monday) to 6 (Sunday)") return sorted(set(v)) @field_validator("shop_start", "shop_end") @classmethod def _hhmm(cls, v: str) -> str: v = v.strip() if not _HHMM.match(v): raise ValueError("a time is HH:MM, 00:00 to 23:59") return v @field_validator("departments") @classmethod def _depts(cls, v: list[str]) -> list[str]: out: list[str] = [] for d in v: d = str(d).strip() if not _DEPT.match(d): raise ValueError("a department is its short code, like 03") if d not in out: out.append(d) return out @model_validator(mode="after") def _hours(self) -> FeedSettings: if self.shop_start == self.shop_end: raise ValueError("shop_start and shop_end must differ") return self FIELDS = tuple(FeedSettings.model_fields) def _clock(hhmm: str) -> time: h, m = hhmm.split(":") return time(int(h), int(m)) def _errors(e: ValidationError) -> list[str]: out = [] for err in e.errors(): loc = ".".join(str(x) for x in err.get("loc", ())) msg = str(err.get("msg", "invalid")).removeprefix("Value error, ") out.append(f"{loc}: {msg}" if loc else msg) return out def from_value(value: Any) -> FeedSettings: """The model from a stored value. A bad stored value keeps its good fields; the rest take the default.""" if not isinstance(value, dict): return FeedSettings() try: return FeedSettings.model_validate(value) except ValidationError: good: dict[str, Any] = {} for k in FIELDS: if k not in value: continue try: FeedSettings.model_validate({**good, k: value[k]}) except ValidationError: continue good[k] = value[k] return FeedSettings.model_validate(good) def load(conn: Any) -> FeedSettings: """The current feed settings; the defaults when the row is missing.""" row = conn.execute("SELECT value FROM settings WHERE key = %s", (KEY,)).fetchone() return from_value(row[0] if row else None) def load_versioned(conn: Any) -> tuple[FeedSettings, int, Any, Any]: """(settings, version, updated_at, updated_by); version 0 when the row is missing.""" row = conn.execute("SELECT value, version, updated_at, updated_by FROM settings WHERE key = %s", (KEY,)).fetchone() if row is None: return FeedSettings(), 0, None, None return from_value(row[0]), int(row[1]), row[2], row[3] def seed(conn: Any) -> bool: """Write the defaults when the row is missing. True when it wrote.""" cur = conn.execute( "INSERT INTO settings (key, value, version, updated_at) VALUES (%s, %s, 1, now()) ON CONFLICT (key) DO NOTHING", (KEY, Jsonb(FeedSettings().model_dump(mode="json"))), ) return bool(cur.rowcount) def validate_patch(current: FeedSettings, patch: dict[str, Any]) -> FeedSettings: """The settings after a partial change. Raises SettingsError, also for a key that is not a setting.""" unknown = sorted(k for k in patch if k not in FIELDS) if unknown: raise SettingsError([f"{k}: not a feed setting" for k in unknown]) try: return FeedSettings.model_validate({**current.model_dump(mode="json"), **patch}) except ValidationError as e: raise SettingsError(_errors(e)) from None def save(conn: Any, *, who: Any, patch: dict[str, Any]) -> FeedSettings: """Validate and store a partial change. Raises SettingsError. A patch that changes nothing writes nothing. `who` has user_id, session_id, role_snapshot, ip and it_reason (domain.projects.Who). """ with conn.transaction(): row = conn.execute("SELECT value FROM settings WHERE key = %s FOR UPDATE", (KEY,)).fetchone() current = from_value(row[0] if row else None) new = validate_patch(current, patch) before, after = current.model_dump(mode="json"), new.model_dump(mode="json") changed = {k: {"from": before[k], "to": after[k]} for k in FIELDS if before[k] != after[k]} if not changed and row is not None: return new version = conn.execute( "INSERT INTO settings (key, value, version, updated_at, updated_by) VALUES (%s, %s, 1, now(), %s)" " ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1," " updated_at = now(), updated_by = EXCLUDED.updated_by RETURNING version", (KEY, Jsonb(after), who.user_id), ).fetchone()[0] audit.append( conn, action="feed.settings_changed", user_id=who.user_id, session_id=getattr(who, "session_id", None), role_snapshot=getattr(who, "role_snapshot", None), subject_type="settings", subject_id=KEY, detail={"changed": changed}, it_reason=getattr(who, "it_reason", None), ip=getattr(who, "ip", None), ) notify_settings(conn, int(version)) return new def notify_settings(conn: Any, version: int | None = None) -> None: """Tell the worker the settings changed. Inside a transaction it is delivered on commit.""" payload = json.dumps({"key": KEY, "version": version}, separators=(",", ":")) conn.execute("SELECT ps_notify(%s, %s)", (SETTINGS_CHANNEL, payload)) # ---------------------------------------------------------------- derived values (pure) def window(settings: FeedSettings, today: date) -> tuple[date, date]: """(since, until) for the released read, from the site-local date.""" return today - timedelta(days=settings.window_back_days), today + timedelta(days=settings.window_ahead_days) def in_shop_hours(settings: FeedSettings, now_site: datetime) -> bool: """True on a shop day from shop_start up to shop_end, site time. An end before the start spans midnight; the hours after midnight belong to the day the shift started. """ start, end = _clock(settings.shop_start), _clock(settings.shop_end) t = now_site.time().replace(tzinfo=None) day = now_site.weekday() if start < end: return day in settings.shop_days and start <= t < end if t >= start: return day in settings.shop_days if t < end: return (day - 1) % 7 in settings.shop_days return False def interval_seconds(settings: FeedSettings, now_site: datetime) -> int: """The feed's cadence now: the shop interval inside shop hours, the off interval outside.""" minutes = settings.interval_shop_minutes if in_shop_hours(settings, now_site) else settings.interval_off_minutes return minutes * 60