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

- 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 16:36:49 -06:00
co-authored by Claude Opus 5.5
parent 150c507a31
commit 70a97398f1
101 changed files with 3871 additions and 1098 deletions
+8 -41
View File
@@ -1,11 +1,11 @@
"""Admin: departments and stations, people's profiles (one at a time and the CSV import), checklist templates.
"""Admin: departments and stations, people's profiles (one at a time and the CSV import), the default checklists by
kind (weld and plain; each department's own list is at /api/checklists, change C8).
Every route needs the admin role and a password session. Every change is audited (`admin.*`).
"""
from __future__ import annotations
import re
import uuid
from typing import Any
@@ -171,9 +171,6 @@ def profile_import(
# ---------------------------------------------------------------- checklists
RX_BRACES = re.compile(r"\{[^{}]*\}")
ALLOWED_PLACEHOLDERS = ("{drawing}", "{rev}", "{wps}")
class ChecklistsPut(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -183,7 +180,8 @@ class ChecklistsPut(BaseModel):
def check_templates(value: Any) -> tuple[dict[str, list[dict[str, str]]], list[str]]:
"""The checklist templates cleaned, and one error line per problem."""
"""The default checklists by kind (weld and plain) cleaned, and one error line per problem. A department with
its own list (change C8, /api/checklists) does not read these."""
errors: list[str] = []
out: dict[str, list[dict[str, str]]] = {}
if not isinstance(value, dict):
@@ -192,41 +190,10 @@ def check_templates(value: Any) -> tuple[dict[str, list[dict[str, str]]], list[s
if extra:
errors.append(f"unknown list: {', '.join(extra)}")
for name in ("weld", "plain"):
items = value.get(name)
if not isinstance(items, list) or not items:
errors.append(f"{name}: at least one item")
continue
if len(items) > checklists.ITEMS_MAX:
errors.append(f"{name}: at most {checklists.ITEMS_MAX} items")
seen: set[str] = set()
clean: list[dict[str, str]] = []
for i, it in enumerate(items, start=1):
where = f"{name} item {i}"
if not isinstance(it, dict):
errors.append(f"{where}: must be an object with group, key and text")
continue
group = " ".join(str(it.get("group") or "").split())
key = str(it.get("key") or "").strip()
text = " ".join(str(it.get("text") or "").split())
if not checklists.RX_KEY.match(key):
errors.append(f"{where}: the key is a lower-case letter, then letters, digits or _ (at most 30)")
elif key in seen:
errors.append(f"{where}: key {key} is used twice")
seen.add(key)
if not group:
errors.append(f"{where}: the group is empty")
if not text:
errors.append(f"{where}: the text is empty")
if len(group) > checklists.TEXT_MAX or len(text) > checklists.TEXT_MAX:
errors.append(f"{where}: at most {checklists.TEXT_MAX} characters")
bad = [m for m in RX_BRACES.findall(text) if m not in ALLOWED_PLACEHOLDERS]
stray = RX_BRACES.sub("", text)
if bad:
errors.append(f"{where}: unknown placeholder {', '.join(bad)} (use {{drawing}}, {{rev}} or {{wps}})")
elif "{" in stray or "}" in stray:
errors.append(f"{where}: a brace without its pair")
clean.append({"group": group, "key": key, "text": text})
out[name] = clean
clean, errs = checklists.check_list(name, value.get(name))
errors += errs
if clean is not None:
out[name] = clean
return out, errors
+26 -4
View File
@@ -1,7 +1,8 @@
"""Admin: the Security tab (settings, alerts, the integrity result), the access log and its CSV, who is in now, the
stamp check, and the directory flags.
Every route needs the admin role and a password session. Every change is audited (`admin.*`).
Every route needs the admin role and a password session, except the stamp check: Admin, Q.C. and Clerical on a password
session, at /api/stamp-verify (change C9) and at the old /api/admin paths. Every change is audited (`admin.*`).
"""
from __future__ import annotations
@@ -26,12 +27,23 @@ from ..auth import secsettings
from ..domain import security_alerts
from ..pdf import stamps, tracecsv
from .account import LOG_PREFIXES
from .deps import Db, Principal, admin_principal, get_db, idle_minutes, settings_of
from .deps import (
Db,
Principal,
admin_principal,
current_principal,
get_db,
idle_minutes,
require_password_session,
settings_of,
)
from .errors import ApiError
from .jobs import run_out
from .packets import WRITE
router = APIRouter(prefix="/api/admin", tags=["admin"])
# The stamp check outside /admin (change C9): Admin, Q.C. and Clerical. The /api/admin paths stay as aliases.
stamp_router = APIRouter(prefix="/api", tags=["stamp"])
CSV = "text/csv; charset=utf-8"
PDF_MAX_BYTES = 25 * 1024 * 1024
@@ -499,6 +511,14 @@ def check_stamp(conn: Any, key: bytes | None, sig: str | None, shown: str | None
return out
def stamp_principal(p: Principal = Depends(current_principal)) -> Principal: # noqa: B008
"""Admin, every Q.C. role and every Clerical role (change C9, Gitea issue 32), on a password session as the Admin
path always needed."""
if not (p.is_admin or any(r.startswith(("qc", "clerical")) for r in p.roles)):
raise ApiError(403, "forbidden", "The stamp check is for Admin, Q.C. and Clerical.")
return require_password_session(p)
def _seal_key(request: Request) -> bytes | None:
try:
return settings_of(request).seal_key()
@@ -526,10 +546,11 @@ def _audit_verify(conn: Any, p: Principal, results: list[dict[str, Any]], source
@router.post("/stamp-verify", dependencies=WRITE)
@stamp_router.post("/stamp-verify", dependencies=WRITE)
def stamp_verify(
body: StampText,
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
p: Principal = Depends(stamp_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
text = body.text
@@ -569,9 +590,10 @@ def pdf_stamps(data: bytes) -> list[tuple[str, str]]:
@router.post("/stamp-verify/pdf")
@stamp_router.post("/stamp-verify/pdf")
async def stamp_verify_pdf(
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
p: Principal = Depends(stamp_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
length = request.headers.get("content-length")
+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)
@@ -170,6 +170,11 @@ class ReceiptBody(It):
note: str | None = Field(default=None, max_length=2000)
class ShortApproveBody(It):
sign: SignBody | None = None
note: str | None = Field(default=None, max_length=2000)
class WeldBody(It):
page: int | None = Field(default=None, ge=1, le=100_000)
fx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
@@ -880,6 +885,75 @@ def receive_short(
return _read(d, pp, packet_id)
def _not_approved(conn: Any, receipt_id: int) -> None:
if conn.execute("SELECT 1 FROM short_approvals WHERE receipt_id = %s", (receipt_id,)).fetchone():
raise ApiError(409, "already_approved", "This receipt is already approved.")
@router.post("/{packet_id}/shorts/receipts/{receipt_id}/approve", dependencies=WRITE)
def approve_short_receipt(
packet_id: int,
receipt_id: int,
body: ShortApproveBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""The department supervisor approves one received short (C7): the part and its heat number match what the
warehouse issued. One approval per receipt; a second answers 409 already_approved."""
note = clean_opt(body.note, LIMITS["note"])
pp = _pp(d, p)
conn = d.conn
receipt = packets.one(
conn,
"SELECT r.*, b.item FROM short_receipts r JOIN bom_shorts s ON s.id = r.short_id"
" JOIN bom_lines b ON b.id = s.bom_line_id WHERE r.id = %s AND r.packet_id = %s",
(receipt_id, packet_id),
)
if receipt is None:
raise ApiError(404, "not_found", "No such short receipt on this packet.")
_not_approved(conn, receipt_id) # before the signature: a second approval costs no attempt
content = {
"action": str(A.SHORT_APPROVE),
"packet_id": packet_id,
"receipt_id": receipt_id,
"item": receipt["item"],
"qty": qty_text(receipt["qty"]),
"heat": receipt["heat"],
"note": note,
}
with packet_write(
d,
p,
packet_id,
A.SHORT_APPROVE,
request=request,
it_reason=body.it_reason,
sign=body.sign,
sign_content=content,
policy_principal=pp,
) as w:
_not_approved(conn, receipt_id) # again under the packet lock: a racing approval waits, then sees the first
conn.execute(
"INSERT INTO short_approvals (receipt_id, short_id, packet_id, dept_code, note, signature_id, by)"
" VALUES (%s, %s, %s, %s, %s, %s, %s)",
(
receipt_id,
receipt["short_id"],
packet_id,
w.state.dept,
note,
w.signature.id if w.signature else None,
w.user_id,
),
)
w.changed("bom.short")
w.detail.update({"item": receipt["item"], "receipt_id": receipt_id})
parts = [f"{qty_text(receipt['qty'])} pc", receipt["heat"] and f"heat {receipt['heat']}", note]
w.log(f"Short approved · item {receipt['item']}", " · ".join(x for x in parts if x))
return _read(d, pp, packet_id)
# ---------------------------------------------------------------- welds
+17 -12
View File
@@ -115,7 +115,6 @@ class TransitionBody(BaseModel):
location: str | None = Field(default=None, max_length=500)
for_sup_id: uuid.UUID | None = None
ncr: str | None = Field(default=None, max_length=100)
confirm_open_wos: bool | None = None
sign: SignBody | None = None
it_reason: str | None = Field(default=None, max_length=1000)
@@ -539,9 +538,9 @@ TRANSITION_FIELDS: dict[policy.Action, tuple[str, ...]] = {
A.SEND_TO_QC: ("book", "spool", "location", "note"),
A.ROUTE_FROM_REVIEW: ("dept", "note"),
A.QC_REJECT: ("reason", "ncr"),
A.QC_ACCEPT: ("routed_to", "note"),
A.QC_ACCEPT_AND_ROUTE: ("dept", "note"),
A.FILE: ("note", "confirm_open_wos"),
A.QC_ACCEPT: ("routed_to", "note", "ncr"),
A.QC_ACCEPT_AND_ROUTE: ("dept", "note", "ncr"),
A.FILE: ("note",),
A.ADMIN_REOPEN: ("reason",),
}
DEPT_ACTIONS = (A.RELEASE_TO_SHOP, A.ROUTE_FROM_REVIEW, A.QC_ACCEPT_AND_ROUTE)
@@ -580,6 +579,8 @@ def transition(
extra["for_sup_id"] = active_user(conn, extra["for_sup_id"], "supervisor")
if action is A.QC_REJECT:
_reject_reason(extra)
elif "ncr" in extra:
_accept_ncr(extra)
if "stamp" in extra:
# The assign sheet's stamp overrides the welder's own for this assignment; empty means use theirs.
stamp = clean_text(extra["stamp"], 40)
@@ -617,20 +618,25 @@ def transition(
def _reject_reason(extra: dict[str, Any]) -> None:
"""A Q.C. reject takes a reason, an NCR number, or both. The NCR stands in for the reason text: the reason the
policy checks is "NCR-2026-0021" alone, or "NCR-2026-0021 · <reason>". Without an NCR the reason needs its usual
8 characters."""
"""A Q.C. reject always needs a detailed reason (8 characters or more, C2); an NCR number does not stand in for
it. The reject may carry an NCR number too: the return note then reads "NCR-2026-0021 · <reason>"."""
text = clean_text(extra.pop("reason", None), LIMITS["reason"])
raw = clean_text(extra.pop("ncr", None), 100)
ncr = ncrs.validate_ncr(raw) if raw else None
if ncr:
extra["ncr"] = ncr
extra["reason"] = f"{ncr} · {text}" if text else ncr
elif text:
if text:
extra["reason"] = text
extra["reason_text"] = text
def _accept_ncr(extra: dict[str, Any]) -> None:
"""The NCR number an accept carries after a reject without one (C2). An empty one counts as not given."""
raw = clean_text(extra.pop("ncr", None), 100)
if raw:
extra["ncr"] = ncrs.validate_ncr(raw)
@router.post("/{packet_id}/reopen", dependencies=WRITE)
def reopen(
packet_id: int,
@@ -958,9 +964,8 @@ def set_vclosed(
"SELECT DISTINCT wo_base FROM packet_work_orders WHERE packet_id = %s ORDER BY 1", (packet_id,)
)
]
still_open = packets.packet_open_wos(d.conn, w.packet)
suffix = "VISUAL still shows open: " + ", ".join(still_open) if still_open else None
w.note = " · ".join(x for x in (", ".join(wos), suffix) if x) or None
# Only a hand packet gets here (C5): the feed closes a VISUAL packet, so there is no feed state to compare.
w.note = ", ".join(wos) or None
else:
d.conn.execute("UPDATE packets SET vclosed_at = NULL, vclosed_by = NULL WHERE id = %s", (packet_id,))
w.changed("vclosed")
+11 -2
View File
@@ -1,6 +1,7 @@
"""Project Management: the read-only reports, the CSV exports and the production status PDF.
Any signed-in session reads (every role may open every packet, PM included). Nothing here writes a packet; an export
PS-ProjectMgmt and Admin only (change C10, Gitea issue 33); every other role gets 403 on every endpoint here. No other
screen calls /api/pm: the find bar and the boards read /api/packets. Nothing here writes a packet; an export
writes one print_log row and one audit row (`report exported`). Every endpoint takes the find bar's scope: the
project and unit pick, dept, status, station_id, welder_id, flags, flag, q, and the history period (30, 90, 365 or
all days; default 90). Mine is ignored here. The reports read PostgreSQL only.
@@ -21,7 +22,15 @@ from .deps import Db, Principal, current_principal, get_db, settings_of
from .errors import ApiError
from .packets import FIND
router = APIRouter(prefix="/api/pm", tags=["pm"])
def pm_only(p: Principal = Depends(current_principal)) -> Principal: # noqa: B008
"""PS-ProjectMgmt (role `pm`) or Admin. Others get 403."""
if not (p.is_admin or "pm" in p.roles):
raise ApiError(403, "forbidden", "Project Management only.")
return p
router = APIRouter(prefix="/api/pm", tags=["pm"], dependencies=[Depends(pm_only)])
PERIOD = Query(pm.DEFAULT_PERIOD, pattern="^(30|90|365|all)$")
+19 -1
View File
@@ -1,4 +1,5 @@
"""The shop screens: the supervisor board, the stations a person may work at, and the stall board.
"""The shop screens: the supervisor board, the stations a person may work at, the stall board of one station, and
the Stall screen's own list (/api/stall/mine: the packets assigned to me, across stations).
Any signed-in session reads them; the board answers 403 for someone who leads no department. They read
PostgreSQL only.
@@ -16,6 +17,7 @@ from .deps import Db, Principal, current_principal, get_db
from .packets import FIND, site_today
router = APIRouter(prefix="/api/shop", tags=["shop"])
stall_router = APIRouter(prefix="/api/stall", tags=["shop"])
@router.get("/board")
@@ -65,3 +67,19 @@ def stall(
raw = {**find, "station_id": None, "mine_station_id": find.get("mine_station_id") or station_id}
filters = search.parse_filters(raw, pp, screen="stall", today=site_today(request))
return shop.stall(conn, pp, station_id, filters)
@stall_router.get("/mine")
def stall_mine(
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
find: dict[str, Any] = FIND,
) -> dict[str, Any]:
"""The Stall screen (change C11): the packets assigned to the signed-in person, each at the station the
supervisor picked, across stations. No station in the request; the session decides. The find bar values narrow
the cards (Mine is these cards already)."""
conn = d.conn
pp = packets.policy_principal_for(conn, p)
filters = search.parse_filters(find, pp, screen="stall", today=site_today(request))
return shop.stall_mine(conn, pp, filters)