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]>
This commit is contained in:
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user