Files
PacketStation/server/packetstation/api/feed_settings.py
T
spencermandClaude Fable 5.1 591fe90076
ci / frontend (push) Successful in 30s
ci / server (push) Successful in 4m12s
ci / image (push) Successful in 46s
ci / e2e (push) Successful in 1m24s
Phase 3: VISUAL feed and M-Files drawings in the worker
Migration 0004 (VISUAL and drawing caches, go-live ignored list, resource
map, job tables, order-derived columns with provenance; work order key
includes the operation sequence). The real worker on APScheduler with
settings re-read each fire, pause, run now, advisory locks, job_runs and a
heartbeat. The visual-feed job (five VisualKit reads, COPY and MERGE cache,
suspect-batch guard, the apply in one transaction: drafts, change orders,
retire-moved, work order gone, closed and cancelled observed, projects and
units from order links, auto-release) and the fingerprint. The drawing-lookup
job (queue, budget, download on version change, sha256 into assets, decision
table, staging for rev approval, drawing-origin change orders). Clerical From
VISUAL panel, flags, rev approval, hand uploads, change-order review; Admin
Feed settings and resource map. Synthetic fixture generator and a
fixture-driven worker in CI with a Playwright scenario. Docs updated.

Verified: 3249 server tests and 13 Playwright scenarios on the docker-1 CI
stack, 76 frontend tests, doctor at 0004; one feed and one drawing run
against VISUAL DEV and the vault from the developer machine (counts in the
changelog).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-16 14:45:38 -06:00

205 lines
7.2 KiB
Python

"""Admin: the feed settings and the VISUAL resource map. Every route needs the admin role and a password session.
A settings save NOTIFYs `ps_settings` so the worker reschedules at once. Resource map changes apply on the next
feed run. Every change is audited with before and after.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Body, Depends, Response
from psycopg.rows import dict_row
from pydantic import BaseModel, Field
from .. import audit
from ..domain.projects import Who
from ..feed import config
from .deps import Db, Principal, admin_principal, get_db
from .errors import ApiError
from .jobs import recent_runs
from .packets import WRITE
router = APIRouter(prefix="/api/admin/feed", tags=["admin"])
FEED_JOB = "visual-feed"
SPECIAL_TARGETS = ("fold", "ignore")
RESOURCE_MAX = 100
def _who(p: Principal) -> Who:
return Who(user_id=p.user_id, session_id=p.session_id, role_snapshot=p.role_snapshot, ip=p.ip)
def _shop_codes(conn: Any) -> set[str]:
rows = conn.execute("SELECT code FROM departments WHERE kind = 'shop' AND active").fetchall()
return {r[0] for r in rows}
def _settings_out(conn: Any) -> dict[str, Any]:
fs, version, updated_at, updated_by = config.load_versioned(conn)
nxt = conn.execute("SELECT next_run_at FROM job_schedule WHERE job = %s", (FEED_JOB,)).fetchone()
return {
**fs.model_dump(mode="json"),
"version": version,
"updated_at": updated_at,
"updated_by": str(updated_by) if updated_by else None,
"next_run_at": nxt[0] if nxt else None,
"runs": recent_runs(conn, FEED_JOB, 10),
}
@router.get("/settings")
def get_settings(p: Principal = Depends(admin_principal), d: Db = Depends(get_db)) -> dict[str, Any]: # noqa: B008
return _settings_out(d.conn)
@router.patch("/settings", dependencies=WRITE)
def patch_settings(
patch: dict[str, Any] = Body(...), # noqa: B008
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
conn = d.conn
if "departments" in patch and isinstance(patch["departments"], list):
shop = _shop_codes(conn)
bad = sorted({str(x).strip() for x in patch["departments"]} - shop)
if bad:
raise ApiError(
422,
"bad_settings",
f"Not an active shop department: {', '.join(bad)}.",
extra={"errors": [f"departments: {b} is not an active shop department" for b in bad]},
)
try:
config.save(conn, who=_who(p), patch=patch)
except config.SettingsError as e:
raise ApiError(422, "bad_settings", str(e), extra={"errors": e.errors}) from None
return _settings_out(conn)
# ---------------------------------------------------------------- resource map
class MapBody(BaseModel):
target: str = Field(max_length=20)
confirmed: bool = True
def _resource(resource: str) -> str:
r = resource.strip()
if not r or len(r) > RESOURCE_MAX:
raise ApiError(400, "bad_resource", "Give the VISUAL resource name.")
return r
def _map_row(conn: Any, resource: str) -> dict[str, Any] | None:
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(
"SELECT resource, target, confirmed, updated_at, updated_by FROM visual_resource_map WHERE resource = %s",
(resource,),
).fetchone()
return dict(r) if r else None
def _row_out(r: dict[str, Any], rows_last_run: int | None) -> dict[str, Any]:
return {
"resource": r["resource"],
"target": r.get("target"),
"confirmed": bool(r.get("confirmed")),
"rows_last_run": rows_last_run,
"updated_at": r.get("updated_at"),
}
def _last_counts(conn: Any) -> dict[str, int]:
rows = conn.execute(
"SELECT res, count(*) FROM visual_rows"
" WHERE last_batch = (SELECT max(id) FROM visual_batches) AND res <> '' GROUP BY res"
).fetchall()
return {r[0]: int(r[1]) for r in rows}
@router.get("/resource-map")
def resource_map(p: Principal = Depends(admin_principal), d: Db = Depends(get_db)) -> list[dict[str, Any]]: # noqa: B008
conn = d.conn
counts = _last_counts(conn)
with conn.cursor(row_factory=dict_row) as cur:
mapped = cur.execute("SELECT resource, target, confirmed, updated_at FROM visual_resource_map").fetchall()
out = {r["resource"]: _row_out(r, counts.get(r["resource"])) for r in mapped}
for res, n in counts.items():
if res not in out:
out[res] = {"resource": res, "target": None, "confirmed": False, "rows_last_run": n, "updated_at": None}
return sorted(out.values(), key=lambda r: (r["target"] is not None, r["resource"].lower()))
def _public(r: dict[str, Any] | None) -> dict[str, Any] | None:
if r is None:
return None
return {"target": r["target"], "confirmed": r["confirmed"]}
@router.put("/resource-map/{resource}", dependencies=WRITE)
def map_resource(
resource: str,
body: MapBody,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
conn = d.conn
res = _resource(resource)
target = body.target.strip()
if target.lower() in SPECIAL_TARGETS:
target = target.lower()
elif target not in _shop_codes(conn):
raise ApiError(422, "bad_target", "The target is Fold, Ignore or an active shop department.")
with conn.transaction():
before = _map_row(conn, res)
conn.execute(
"INSERT INTO visual_resource_map (resource, target, confirmed, updated_at, updated_by)"
" VALUES (%s, %s, %s, now(), %s) ON CONFLICT (resource) DO UPDATE SET target = EXCLUDED.target,"
" confirmed = EXCLUDED.confirmed, updated_at = now(), updated_by = EXCLUDED.updated_by",
(res, target, body.confirmed, p.user_id),
)
after = _map_row(conn, res)
audit.append(
conn,
action="feed.resource_mapped",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="visual_resource",
subject_id=res,
detail={"resource": res, "before": _public(before), "after": _public(after)},
ip=p.ip,
)
assert after is not None
return _row_out(after, _last_counts(conn).get(res))
@router.delete("/resource-map/{resource}", status_code=204, dependencies=WRITE)
def unmap_resource(
resource: str,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> Response:
conn = d.conn
res = _resource(resource)
with conn.transaction():
before = _map_row(conn, res)
if before is None:
raise ApiError(404, "not_found", "That resource is not mapped.")
conn.execute("DELETE FROM visual_resource_map WHERE resource = %s", (res,))
audit.append(
conn,
action="feed.resource_unmapped",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="visual_resource",
subject_id=res,
detail={"resource": res, "before": _public(before), "after": None},
ip=p.ip,
)
return Response(status_code=204)