"""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)