Files
PacketStation/server/packetstation/domain/checklists.py
T
spencermandClaude Opus 5.5 70a97398f1
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
Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
- 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]>
2026-10-30 16:36:49 -06:00

396 lines
18 KiB
Python

"""The department checklists and the Q.C. checks, ported from the old checklistFor(), qcChecksFor() and qcMissing().
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.
"""
from __future__ import annotations
import logging
import re
from collections.abc import Iterable, Mapping
from typing import Any
from psycopg.types.json import Jsonb
log = logging.getLogger(__name__)
NO_REV = "-"
NDE_NONE = ("", "none", "none required")
NDE_OK = ("acceptable", "accept", "accepted")
REJECTS = ("reject", "rejected")
def _item(group: str, key: str, text: str) -> dict[str, str]:
return {"group": group, "key": key, "text": text}
DWG_TEXT = "Drawing {drawing} Rev {rev} matches the packet"
# The defaults. The settings row `checklist_templates` holds the same lists (migration 0006); these are the fallback
# when that row is missing or does not parse.
DEFAULT_TEMPLATES: dict[str, list[dict[str, str]]] = {
"weld": [
_item("Before welding", "dwg", DWG_TEXT),
_item("Before welding", "heat", "Heat numbers on material match the Materials tab"),
_item("Before welding", "wps", "WPS reviewed: {wps}"),
_item("Before welding", "fit", "Fit-up and dimensions checked against the drawing"),
_item("Before welding", "pre", "Preheat and interpass temperature per WPS"),
_item("After welding", "vt", "Visual inspection of all welds acceptable"),
_item("After welding", "stamp", "Welds stamped with welder ID"),
_item("After welding", "clean", "Slag and spatter removed"),
_item("After welding", "qty", "Completed quantity matches each work order"),
],
"plain": [
_item("Before starting", "dwg", DWG_TEXT),
_item("Before starting", "prev", "Previous department's work complete and released"),
_item("Before starting", "mat", "Material and parts on hand match the packet"),
_item("When done", "work", "Work completed per drawing and procedure"),
_item("When done", "insp", "Self-inspection done; no damage to finished surfaces"),
_item("When done", "qty", "Completed quantity matches each work order"),
],
}
SETTINGS_KEY = "checklist_templates"
RX_KEY = re.compile(r"^[a-z][a-z0-9_]{0,29}$")
RX_PLACEHOLDER = re.compile(r"\{(drawing|rev|wps)\}")
TEXT_MAX = 200
ITEMS_MAX = 40
def _valid_list(value: Any) -> list[dict[str, str]] | None:
"""A template list from settings, or None when it does not have the right shape."""
if not isinstance(value, list) or not value or len(value) > ITEMS_MAX:
return None
out: list[dict[str, str]] = []
seen: set[str] = set()
for it in value:
if not isinstance(it, Mapping):
return None
group, key, text = it.get("group"), it.get("key"), it.get("text")
if not (isinstance(group, str) and isinstance(key, str) and isinstance(text, str)):
return None
if not RX_KEY.match(key) or key in seen or not group.strip() or not text.strip():
return None
seen.add(key)
out.append(_item(group.strip()[:TEXT_MAX], key, text.strip()[:TEXT_MAX]))
return out
def parse_templates(value: Any) -> dict[str, list[dict[str, str]]] | None:
"""The `checklist_templates` settings value, checked. None when it is malformed."""
if not isinstance(value, Mapping):
return None
weld, plain = _valid_list(value.get("weld")), _valid_list(value.get("plain"))
if weld is None or plain is None:
return None
return {"weld": weld, "plain": plain}
def load_templates(conn: Any) -> dict[str, list[dict[str, str]]]:
"""The department checklists from settings, else the defaults in code (with a warning when the row is bad)."""
row = conn.execute("SELECT value FROM settings WHERE key = %s", (SETTINGS_KEY,)).fetchone()
if row is None:
return DEFAULT_TEMPLATES
parsed = parse_templates(row[0])
if parsed is None:
log.warning("settings %s does not parse; using the default checklists", SETTINGS_KEY)
return DEFAULT_TEMPLATES
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())
def checklist_for(
weld: bool,
drawing: str | None,
rev: str | None,
wps: str | None,
templates: Mapping[str, list[dict[str, str]]] | None = None,
) -> list[dict[str, str]]:
"""The stage checklist for a department, as a flat list of {group, key, text} in display order."""
t = templates or DEFAULT_TEMPLATES
items = t.get("weld" if weld else "plain") or DEFAULT_TEMPLATES["weld" if weld else "plain"]
return [_item(i["group"], i["key"], _fill(i["text"], drawing, rev, wps)) for i in items]
QC_SETTINGS_KEY = "qc_check_items"
RX_QC_PLACEHOLDER = re.compile(r"\{(drawing|rev|wps|stamp)\}")
G_BOOK, G_SPOOL = "Data book package", "Physical spool"
# The Q.C. check items, with {wps}, {stamp}, {drawing} and {rev} placeholders. The settings row `qc_check_items`
# (migration 0007) holds the same list; this is the fallback when that row is missing or does not parse.
QC_DEFAULT_ITEMS: list[dict[str, str]] = [
_item(G_BOOK, "db_heats", "Heat number recorded for every BOM item"),
_item(G_BOOK, "db_mtr", "MTRs on file and match the heat numbers"),
_item(G_BOOK, "db_weldmap", "Weld map complete: every weld identified"),
_item(G_BOOK, "db_wps", "Welds made to {wps}; welder {stamp}qualified"),
_item(G_BOOK, "db_ck", "Stall checklist and weld record complete"),
_item(G_SPOOL, "ph_dim", "Dimensions verified against {drawing} Rev {rev}"),
_item(G_SPOOL, "ph_heat", "Heat numbers on material match the heat map"),
_item(G_SPOOL, "ph_stamp", "Welds stamped with welder ID"),
_item(G_SPOOL, "ph_clean", "Clean: no slag, spatter or arc strikes"),
_item(G_SPOOL, "ph_qty", "Quantity matches the work orders"),
]
def parse_qc_items(value: Any) -> list[dict[str, str]] | None:
"""The `qc_check_items` settings value ({"groups": [{"group", "items": [{"key", "text"}]}]}) as a flat list of
{group, key, text}. None when it is malformed."""
if not isinstance(value, Mapping) or not isinstance(value.get("groups"), list):
return None
flat: list[dict[str, Any]] = []
for g in value["groups"]:
if not isinstance(g, Mapping) or not isinstance(g.get("items"), list):
return None
flat += [{**i, "group": g.get("group")} if isinstance(i, Mapping) else i for i in g["items"]]
return _valid_list(flat)
def load_qc_items(conn: Any) -> list[dict[str, str]]:
"""The Q.C. check items from settings, else the defaults in code (with a warning when the row is bad)."""
row = conn.execute("SELECT value FROM settings WHERE key = %s", (QC_SETTINGS_KEY,)).fetchone()
if row is None:
return QC_DEFAULT_ITEMS
parsed = parse_qc_items(row[0])
if parsed is None:
log.warning("settings %s does not parse; using the default Q.C. items", QC_SETTINGS_KEY)
return QC_DEFAULT_ITEMS
return parsed
def qc_fill(text: str, drawing: str | None, rev: str | None, wps: str | None, welder_stamp: str | None) -> str:
values = {
"drawing": drawing or "the drawing",
"rev": rev or NO_REV,
"wps": wps or "the WPS",
"stamp": f"[{welder_stamp}] " if welder_stamp else "",
}
return " ".join(RX_QC_PLACEHOLDER.sub(lambda m: values[m.group(1)], text).split())
def qc_items_for(
drawing: str | None,
rev: str | None,
wps: str | None,
welder_stamp: str | None,
items: Iterable[Mapping[str, str]] | None = None,
) -> list[dict[str, str]]:
"""The Q.C. inspection items, as a flat list of {group, key, text} with the packet's values filled in."""
src = list(items) if items is not None else QC_DEFAULT_ITEMS
return [_item(i["group"], i["key"], qc_fill(i["text"], drawing, rev, wps, welder_stamp)) for i in src]
def checklist_keys(weld: bool, templates: Mapping[str, list[dict[str, str]]] | None = None) -> list[str]:
return [i["key"] for i in checklist_for(weld, None, None, None, templates)]
def qc_keys(items: Iterable[Mapping[str, str]] | None = None) -> list[str]:
return [i["key"] for i in (items if items is not None else QC_DEFAULT_ITEMS)]
def is_done(value: Any) -> bool:
"""A checklist or Q.C. check entry: `true`, or `{"done": true, ...}`."""
if value is True:
return True
return isinstance(value, Mapping) and value.get("done") is True
def checklist_complete(
checklist: Mapping[str, Any] | None, weld: bool, templates: Mapping[str, list[dict[str, str]]] | None = None
) -> bool:
"""Every key of the department checklist is done. An empty list counts as complete."""
ck = checklist or {}
return all(is_done(ck.get(k)) for k in checklist_keys(weld, templates))
def _plural(n: int, word: str) -> str:
return f"{n} {word}{'s' if n > 1 else ''}"
def qc_missing(
packet: Mapping[str, Any],
qc: Mapping[str, Any] | None,
welds: Iterable[Mapping[str, Any]],
items: Iterable[Mapping[str, str]] | None = None,
) -> list[str]:
"""What Q.C. still has to record before an accept. The old qcMissing().
`packet` is unused today (the item list does not depend on it for the keys) but kept for the old signature.
`qc` is the open inspection row (checks, weld_exam, nde_method, nde_result, inspector) or None.
`welds` are the live weld rows (weld_no). `items` are the loaded Q.C. items (load_qc_items); None means the
defaults.
"""
del packet
q = qc or {}
checks = q.get("checks") or {}
missing: list[str] = []
open_items = sum(1 for k in qc_keys(items) if not is_done(checks.get(k)))
if open_items:
missing.append(_plural(open_items, "inspection item"))
exam = q.get("weld_exam") or {}
results = []
for w in welds:
entry = exam.get(str(w.get("weld_no")))
result = entry.get("result") if isinstance(entry, Mapping) else None
results.append(str(result).lower() if result else "")
no_result = sum(1 for r in results if not r)
if no_result:
missing.append(_plural(no_result, "weld") + " without a visual result")
if any(r in REJECTS for r in results):
missing.append("a rejected weld (reject the packet instead)")
method = str(q.get("nde_method") or "").strip().lower()
if method not in NDE_NONE and str(q.get("nde_result") or "").strip().lower() not in NDE_OK:
missing.append("NDE result")
if not str(q.get("inspector") or "").strip():
missing.append("inspector name")
return missing