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
+149 -3
View File
@@ -1,8 +1,12 @@
"""The department checklists and the Q.C. checks, ported from the old checklistFor(), qcChecksFor() and qcMissing().
Weld departments get the welding list, the others the plain list. The drawing, rev and WPS are filled into the text.
The lists live in the settings rows `checklist_templates` (migration 0006) and `qc_check_items` (migration 0007); the
defaults here are the fallback.
Each shop department has its own checklist (change C8, Gitea issue 23): the settings row `checklist:<dept code>` holds
{"items": [{group, key, text}]}, edited by that department's supervisor or by Admin. A department without its own
list uses the default for its kind: the welding list for a weld department, else the plain list, from the settings row
`checklist_templates` (migration 0006, Admin edits it) and, when that row is missing or bad, from the defaults here.
A stage freezes its department's list when work starts on it (`packet_stages.checklist_template`, migration 0011), so
a change reaches only stages not yet started. The drawing, rev and WPS are filled into the text at read time.
The Q.C. check items live in the settings row `qc_check_items` (migration 0007); they are not per department.
The keys are the old keys, so a checklist stored as {key: {done, at, by, stamp}} reads the same.
"""
@@ -13,6 +17,8 @@ import re
from collections.abc import Iterable, Mapping
from typing import Any
from psycopg.types.json import Jsonb
log = logging.getLogger(__name__)
NO_REV = "-"
@@ -98,6 +104,146 @@ def load_templates(conn: Any) -> dict[str, list[dict[str, str]]]:
return parsed
RX_BRACES = re.compile(r"\{[^{}]*\}")
ALLOWED_PLACEHOLDERS = ("{drawing}", "{rev}", "{wps}")
def check_list(name: str, items: Any) -> tuple[list[dict[str, str]] | None, list[str]]:
"""One checklist from an editor, cleaned, and one error line per problem (`name` starts each line). None when
it is not a list."""
errors: list[str] = []
if not isinstance(items, list) or not items:
return None, [f"{name}: at least one item"]
if len(items) > ITEMS_MAX:
errors.append(f"{name}: at most {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, Mapping):
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 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) > TEXT_MAX or len(text) > TEXT_MAX:
errors.append(f"{where}: at most {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(_item(group, key, text))
return clean, errors
# ---------------------------------------------------------------- per department (change C8)
DEPT_PREFIX = "checklist:"
def dept_key(code: str) -> str:
"""The settings key of one department's own checklist."""
return f"{DEPT_PREFIX}{code}"
def load_dept(conn: Any, code: str) -> tuple[list[dict[str, str]] | None, int]:
"""A department's own checklist and the row's version: (None, 0) when it never had one, (None, version) when it
was set back to the default (`{"items": null}`) or the row does not parse (with a warning). The default for its
kind applies then."""
row = conn.execute("SELECT value, version FROM settings WHERE key = %s", (dept_key(code),)).fetchone()
if row is None:
return None, 0
value = row[0]
if isinstance(value, Mapping) and value.get("items") is None:
return None, int(row[1]) # set back to the default for its kind
items = _valid_list(value.get("items")) if isinstance(value, Mapping) else None
if items is None:
log.warning("settings %s does not parse; using the default checklist", dept_key(code))
return items, int(row[1])
def dept_weld(conn: Any, code: str) -> bool:
row = conn.execute("SELECT weld FROM departments WHERE code = %s", (code,)).fetchone()
return bool(row and row[0])
def default_for(conn: Any, weld: bool) -> list[dict[str, str]]:
"""The default list for a department kind (weld or plain): Admin's `checklist_templates`, else the code's."""
return load_templates(conn)["weld" if weld else "plain"]
def live_list(conn: Any, code: str, weld: bool | None = None) -> list[dict[str, str]]:
"""The department's live checklist: its own list, else the default for its kind."""
own, _ = load_dept(conn, code)
if own is not None:
return own
return default_for(conn, dept_weld(conn, code) if weld is None else weld)
def as_templates(items: list[dict[str, str]]) -> dict[str, list[dict[str, str]]]:
"""One list in the {weld, plain} shape checklist_for() takes: both kinds read the same list, so the department's
weld flag no longer picks it."""
return {"weld": items, "plain": items}
def stage_list(conn: Any, stage: Mapping[str, Any]) -> list[dict[str, str]]:
"""The checklist of one stage: the list frozen at START when set, else its department's live list."""
frozen = _valid_list(stage.get("checklist_template"))
if frozen is not None:
return frozen
return live_list(conn, str(stage["dept_code"]))
def stage_templates(conn: Any, stage_id: Any, dept_code: str) -> dict[str, list[dict[str, str]]]:
"""The checklist of a stage by id (the data book's stage records): frozen when set, else the department's live
list. A record with no stage id reads the live list."""
frozen = None
if stage_id is not None:
row = conn.execute("SELECT checklist_template FROM packet_stages WHERE id = %s", (stage_id,)).fetchone()
frozen = row[0] if row else None
return as_templates(stage_list(conn, {"dept_code": dept_code, "checklist_template": frozen}))
def templates_for_packet(
conn: Any, dept_code: str, stages: Iterable[Mapping[str, Any]]
) -> dict[str, list[dict[str, str]]]:
"""The checklist the packet read, its completeness and the tick check use: the open stage's (frozen or live),
else the last stage's when it is in the packet's department, else the department's live list."""
stages = list(stages)
stage = next((s for s in stages if s.get("closed_at") is None), None)
if stage is None and stages and stages[-1].get("dept_code") == dept_code:
stage = stages[-1]
if stage is not None:
return as_templates(stage_list(conn, stage))
return as_templates(live_list(conn, dept_code))
def freeze_on_start(conn: Any, packet_id: int) -> None:
"""START: freeze the open stage's checklist, once. Later edits to the department's list do not reach it."""
row = conn.execute(
"SELECT id, dept_code FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL"
" AND checklist_template IS NULL",
(packet_id,),
).fetchone()
if row is None:
return
conn.execute(
"UPDATE packet_stages SET checklist_template = %s WHERE id = %s AND checklist_template IS NULL",
(Jsonb(live_list(conn, str(row[1]))), row[0]),
)
def _fill(text: str, drawing: str | None, rev: str | None, wps: str | None) -> str:
values = {"drawing": drawing or "", "rev": rev or NO_REV, "wps": wps or "per drawing"}
return " ".join(RX_PLACEHOLDER.sub(lambda m: values[m.group(1)], text).split())