227 lines
8.9 KiB
Python
227 lines
8.9 KiB
Python
"""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
|