Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
ci / frontend (pull_request) Successful in 57s
ci / server (pull_request) Successful in 12m33s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 5m4s

- C1 (issue 1): over target follows VISUAL ticket hours, not the timer.
- C2 (issue 24): a Q.C. reject always needs a reason; accept needs an NCR number after a reject without one.
- C3 (issue 25): an incomplete unit ITP blocks filing.
- C4 (issue 27): prints open the print dialog on the page, no new tab.
- C5 (issue 31): filing is blocked while VISUAL shows a work order open; the feed sets vclosed on VISUAL packets.
- C6 (issue 8): the data book leaves out the history pages.
- C7 (issue 18): the supervisor approves each received short; accept and filing wait for it.
- C8 (issue 23): per-department checklists, edited by the department supervisor or Admin, frozen at START.
- C9 (issue 32): Q.C. and Clerical may run the stamp check.
- C10 (issue 33): the PM screen and reports are for PS-ProjectMgmt and Admin only.
- C11 (issue 36): the Stall screen lists the packets assigned to the welder, at the supervisor's stall.

Migration 0011. Docs: PLAN (also issue 9 confirmed, issue 4 asked), POLICY, SECURITY, SCHEMA, FEED, README, CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-30 13:33:51 -06:00
co-authored by Claude Opus 5.5
parent 1ae6819289
commit 1cd6688247
101 changed files with 3871 additions and 1098 deletions
+155
View File
@@ -0,0 +1,155 @@
"""Per-department checklists (change C8, Gitea issue 23).
Every signed-in session reads them. A department's supervisor edits that department's list and Admin edits any;
a lead, a worker, Q.C. and the office roles do not. A save needs a password session, carries the version it started
from (`base`, or If-Match) and writes one audit row (`checklists.dept_updated`). A change reaches only stages that
have not started: a stage freezes its list at START. `items: null` sets the department back to the default for its
kind (Admin's weld or plain list at /api/admin/checklists). The Q.C. check items are not here.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Request
from fastapi.encoders import jsonable_encoder
from psycopg.types.json import Jsonb
from pydantic import BaseModel, ConfigDict, Field
from .. import audit, policy
from ..domain import checklists, packets
from ..policy.custody import SUPERVISOR
from .deps import Db, Principal, current_principal, get_db, require_password_session
from .errors import ApiError
from .packets import WRITE
router = APIRouter(prefix="/api/checklists", tags=["checklists"])
MSG_NOT_YOURS = "Only this department's supervisor or an Admin can change its checklist."
MSG_STALE = "Someone else changed this checklist. Reload and try again."
MSG_NO_DEPT = "No such shop department."
class DeptChecklistPut(BaseModel):
model_config = ConfigDict(extra="forbid")
items: list[Any] | None = Field(max_length=checklists.ITEMS_MAX * 2)
base: int | None = Field(None, ge=0)
def can_edit(pp: policy.Principal, dept_code: str) -> bool:
"""Admin, or the department's Supervisor (a lead is not enough). Q.C. never edits a department list."""
return pp.admin or pp.level(dept_code) >= SUPERVISOR
def _shop_departments(conn: Any) -> list[dict[str, Any]]:
return packets.rows(
conn,
"SELECT code, name, weld FROM departments WHERE kind = 'shop' AND active ORDER BY sort, code",
)
def _dept(conn: Any, code: str) -> dict[str, Any]:
d = packets.one(conn, "SELECT code, name, weld FROM departments WHERE code = %s AND kind = 'shop'", (code,))
if d is None:
raise ApiError(404, "not_found", MSG_NO_DEPT)
return d
def _summary(conn: Any, pp: policy.Principal, d: dict[str, Any]) -> dict[str, Any]:
own, version = checklists.load_dept(conn, d["code"])
return {
"dept_code": d["code"],
"dept_name": d["name"],
"weld": bool(d["weld"]),
"custom": own is not None,
"version": version,
"editable": can_edit(pp, d["code"]),
}
def _one(conn: Any, pp: policy.Principal, d: dict[str, Any]) -> dict[str, Any]:
out = _summary(conn, pp, d)
out["items"] = checklists.live_list(conn, d["code"], bool(d["weld"]))
out["defaults"] = checklists.default_for(conn, bool(d["weld"]))
return out
@router.get("")
def list_checklists(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> list[dict[str, Any]]:
"""Every active shop department: whether it has its own list, the version and whether the caller may edit."""
conn = d.conn
pp = packets.policy_principal_for(conn, p)
return [_summary(conn, pp, x) for x in _shop_departments(conn)]
@router.get("/{dept}")
def get_checklist(
dept: str,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""One department's live list (its own, else the default for its kind), unfilled: {drawing}, {rev} and {wps}
stay as placeholders. `defaults` is the default for its kind."""
conn = d.conn
pp = packets.policy_principal_for(conn, p)
return _one(conn, pp, _dept(conn, dept[:10]))
@router.put("/{dept}", dependencies=WRITE)
def put_checklist(
dept: str,
body: DeptChecklistPut,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
conn = d.conn
pp = packets.policy_principal_for(conn, p)
row = _dept(conn, dept[:10])
code = str(row["code"])
if not can_edit(pp, code):
raise ApiError(403, "forbidden", MSG_NOT_YOURS)
require_password_session(p)
base = body.base
if base is None:
tag = (request.headers.get("if-match") or "").strip().strip('"').removeprefix("W/").strip('"')
if not tag.isdigit():
raise ApiError(428, "if_match_required", "Send the version you started from.")
base = int(tag)
value: dict[str, Any]
if body.items is None:
value = {"items": None}
else:
clean, errors = checklists.check_list("checklist", body.items)
if errors or clean is None:
raise ApiError(422, "bad_checklist", errors[0], extra={"errors": errors})
value = {"items": clean}
key = checklists.dept_key(code)
with conn.transaction():
cur = conn.execute("SELECT version FROM settings WHERE key = %s FOR UPDATE", (key,)).fetchone()
current = int(cur[0]) if cur else 0
if base != current:
raise ApiError(409, "version_conflict", MSG_STALE, extra={"current": jsonable_encoder(_one(conn, pp, row))})
before = checklists.load_dept(conn, code)[0]
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",
(key, Jsonb(value), p.user_id),
)
audit.append(
conn,
action="checklists.dept_updated",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="settings",
subject_id=key,
detail={"dept": code, "before": before, "after": value["items"]},
ip=p.ip,
)
return _one(conn, pp, row)