"""The VISUAL feed for Clerical: the From VISUAL panel (status, runs, ignored work orders, unmapped resources, projects without names), Run now, pulling in a work order released before go-live, and what VISUAL changed. Clerical (clerical or clerical_supervisor) or an admin. Pulling in a work order changes what the feed tracks, so an admin outside Clerical gives an IT reason for it. Screens read PostgreSQL only: nothing here calls VISUAL. Run now inserts a job request and NOTIFYs the worker, which does the reading. """ from __future__ import annotations import json from typing import Any, Literal from fastapi import APIRouter, Depends, Query from pydantic import BaseModel, Field from .. import audit from ..domain import packets from ..domain.pipeline import jsonable from ..text import LIMITS, clean_opt, reason_ok from .deps import Db, Principal, current_principal, get_db, require_password_session from .errors import ApiError from .packets import WRITE router = APIRouter(prefix="/api/feed", tags=["feed"]) CLERICAL = frozenset({"clerical", "clerical_supervisor"}) JOBS = ("visual-feed", "drawing-lookup") IGNORED_MAX = 200 UNMAPPED_MAX = 30 STALE_DEFAULT = 90 PULL_IN_REASON_MIN = 3 PULL_IN_REASON_MAX = 300 class RunBody(BaseModel): job: Literal["visual-feed", "drawing-lookup"] = "visual-feed" note: str | None = Field(default=None, max_length=1000) class PullInBody(BaseModel): reason: str | None = Field(default=None, max_length=2000) it_reason: str | None = Field(default=None, max_length=1000) # ---------------------------------------------------------------- access def require_clerical(p: Principal = Depends(current_principal)) -> Principal: # noqa: B008 if CLERICAL & set(p.roles) or p.is_admin: return p raise ApiError(403, "forbidden", "Only Clerical can see the VISUAL feed.") def _it_reason(p: Principal, it_reason: str | None) -> str | None: """None for Clerical. An admin outside Clerical needs a password session and an IT reason.""" if CLERICAL & set(p.roles): return None require_password_session(p) why = reason_ok(it_reason) if why is None: raise ApiError(400, "it_reason_required", "Give an IT reason of at least 8 characters.") return why # ---------------------------------------------------------------- shapes def _run_out(r: dict[str, Any]) -> dict[str, Any]: return { "id": r["id"], "job": r["job"], "trigger": r["trigger"], "requested_by": [str(u) for u in r.get("requested_by") or []], "started_at": r["started_at"], "finished_at": r["finished_at"], "status": r["status"], "counts": r["counts"] or {}, "errors": r["errors"] or [], "build_version": r.get("build_version"), "visual_target": r.get("visual_target"), "host": r.get("host"), } def _runs(conn: Any, job: str, limit: int) -> list[dict[str, Any]]: return [ _run_out(r) for r in packets.rows(conn, "SELECT * FROM job_runs WHERE job = %s ORDER BY id DESC LIMIT %s", (job, limit)) ] def _ignored_out(r: dict[str, Any]) -> dict[str, Any]: keys = ("id", "wo_base", "lot", "split", "sub", "rel", "project", "unit_number", "res", "dwg", "last_seen_at") out = {k: r[k] for k in keys} out["pulled_in_at"] = r["pulled_in_at"] out["pulled_in_by"] = str(r["pulled_in_by"]) if r["pulled_in_by"] else None out["pulled_in_reason"] = r["pulled_in_reason"] return out def _stale_seconds() -> int: try: from ..worker import STALE_SECONDS except ImportError: # the worker package is agent A's; the panel still answers without it return STALE_DEFAULT return int(STALE_SECONDS) def _feed_settings(conn: Any) -> Any: from ..feed import config return config.load(conn) def _request_run(conn: Any, p: Principal, job: str, note: str | None) -> int: """Insert a job request and NOTIFY the worker in the same transaction. Returns the pending request count.""" conn.execute( "INSERT INTO job_requests (job, requested_by, note) VALUES (%s, %s, %s)", (job, p.user_id, clean_opt(note, LIMITS["job_note"])), ) conn.execute("SELECT ps_notify('ps_job_request', %s)", (json.dumps({"job": job}),)) return int( conn.execute("SELECT count(*) FROM job_requests WHERE job = %s AND claimed_at IS NULL", (job,)).fetchone()[0] ) # ---------------------------------------------------------------- endpoints @router.get("/overview") def overview( p: Principal = Depends(require_clerical), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: """Everything the From VISUAL panel shows, from PostgreSQL only.""" conn = d.conn fs = _feed_settings(conn) sched = packets.one(conn, "SELECT next_run_at, paused FROM job_schedule WHERE job = 'visual-feed'") beat = packets.one( conn, "SELECT at, build, running, now() - at > make_interval(secs => %s) AS stale FROM worker_heartbeat WHERE id = 1", (_stale_seconds(),), ) runs = _runs(conn, "visual-feed", 10) last_run = runs[0] if runs else None finished = next((r for r in runs if r["status"] in ("ok", "degraded")), None) unmapped_raw = (finished or {}).get("counts", {}).get("unmapped") or {} unmapped: list[dict[str, Any]] = [] if isinstance(unmapped_raw, dict): for k, v in unmapped_raw.items(): if isinstance(v, int) or (isinstance(v, str) and v.isdigit()): unmapped.append({"resource": str(k), "rows": int(v)}) unmapped.sort(key=lambda x: (-int(x["rows"]), str(x["resource"]))) unmapped = unmapped[:UNMAPPED_MAX] ignored = packets.rows( conn, "SELECT * FROM visual_ignored_wos WHERE pulled_in_at IS NULL ORDER BY rel DESC NULLS LAST, wo_base, lot, id" " LIMIT %s", (IGNORED_MAX,), ) nameless = packets.rows( conn, "SELECT p.id, p.number," " (SELECT count(*) FROM units u WHERE u.project_id = p.id AND u.removed_at IS NULL) AS units," " (SELECT count(*) FROM packets k JOIN units u ON u.id = k.unit_id" " WHERE u.project_id = p.id AND k.removed_at IS NULL) AS packets" " FROM projects p WHERE p.source = 'visual' AND p.needs_details AND p.name IS NULL AND p.removed_at IS NULL" " ORDER BY p.number LIMIT 200", ) return { "status": { "paused": bool(fs.paused), "next_run_at": (sched or {}).get("next_run_at"), "worker": None if beat is None else { "at": beat["at"], "build": beat["build"], "running": list(beat["running"] or []), "stale": bool(beat["stale"]), }, }, "last_run": last_run, "runs": runs, "drawing_runs": _runs(conn, "drawing-lookup", 10), "pending_requests": int( conn.execute( "SELECT count(*) FROM job_requests WHERE job = 'visual-feed' AND claimed_at IS NULL" ).fetchone()[0] ), "ignored": [_ignored_out(r) for r in ignored], "unmapped": unmapped, "projects_without_names": [ {"id": r["id"], "number": r["number"], "units": int(r["units"]), "packets": int(r["packets"])} for r in nameless ], "golive_date": fs.golive_date, "departments": list(fs.departments), } @router.post("/run", dependencies=WRITE) def run_now( body: RunBody | None = None, p: Principal = Depends(require_clerical), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: """Run now: ask the worker for a feed (or drawing) run. Requests made while one is pending share that run.""" body = body or RunBody() conn = d.conn with conn.transaction(): pending = _request_run(conn, p, body.job, body.note) audit.append( conn, action="feed.run_requested", user_id=p.user_id, session_id=p.session_id, role_snapshot=p.role_snapshot, subject_type="job", subject_id=body.job, detail={"job": body.job, "pending": pending}, ip=p.ip, ) return {"requested": True, "job": body.job, "pending": pending} @router.post("/ignored/{row_id}/pull-in", dependencies=WRITE) def pull_in( row_id: int, body: PullInBody, p: Principal = Depends(require_clerical), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: """Track a work order released before the go-live date after all. The next feed run makes its packet.""" it_reason = _it_reason(p, body.it_reason) reason = clean_opt(body.reason, PULL_IN_REASON_MAX) if reason is None or len(reason) < PULL_IN_REASON_MIN: raise ApiError(400, "reason_required", "Say why this work order is pulled in (at least 3 characters).") conn = d.conn with conn.transaction(): row = packets.one(conn, "SELECT * FROM visual_ignored_wos WHERE id = %s FOR UPDATE", (row_id,)) if row is None: raise ApiError(404, "not_found", "No such work order on the ignored list.") if row["pulled_in_at"] is not None: raise ApiError( 409, "already_pulled_in", "This work order was already pulled in.", extra={"current": jsonable(_ignored_out(row))}, ) row = packets.one( conn, "UPDATE visual_ignored_wos SET pulled_in_at = now(), pulled_in_by = %s, pulled_in_reason = %s" " WHERE id = %s RETURNING *", (p.user_id, reason, row_id), ) assert row is not None key = "/".join(str(row[k]) for k in ("wo_base", "lot", "split", "sub")) audit.append( conn, action="feed.pulled_in", user_id=p.user_id, session_id=p.session_id, role_snapshot=p.role_snapshot, subject_type="work_order", subject_id=key, detail={"id": row_id, "key": key, "rel": str(row["rel"]) if row["rel"] else None, "reason": reason}, it_reason=it_reason, ip=p.ip, ) _request_run(conn, p, "visual-feed", f"pull in {key}") return _ignored_out(row) @router.get("/batches") def batches( limit: int = Query(20, ge=1, le=200), p: Principal = Depends(require_clerical), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> list[dict[str, Any]]: return packets.rows(d.conn, "SELECT * FROM visual_batches ORDER BY id DESC LIMIT %s", (limit,)) @router.get("/changes") def changes( batch_id: int | None = Query(None), limit: int = Query(200, ge=1, le=2000), p: Principal = Depends(require_clerical), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: """What VISUAL changed in one batch (the latest when none is given): work order rows and material lines.""" conn = d.conn if batch_id is None: batch = packets.one(conn, "SELECT * FROM visual_batches ORDER BY id DESC LIMIT 1") else: batch = packets.one(conn, "SELECT * FROM visual_batches WHERE id = %s", (batch_id,)) if batch is None: raise ApiError(404, "not_found", "No such feed batch.") if batch is None: return {"batch": None, "rows": [], "materials": []} rows = packets.rows( conn, "SELECT * FROM visual_row_changes WHERE batch_id = %s ORDER BY id LIMIT %s", (batch["id"], limit) ) mats = packets.rows( conn, "SELECT * FROM visual_material_changes WHERE batch_id = %s ORDER BY id LIMIT %s", (batch["id"], limit) ) return {"batch": batch, "rows": rows, "materials": mats}