Clerical close and file: the VISUAL observed state beside the close confirm, a 409 with a second confirm when the feed still shows work orders open, filing with a signature and the HMAC seal, the data book built on the server and stored on the seal in the filing transaction, admin reopen keeping the broken seal with its data book. Server PDFs with reportlab, pypdf and pypdfium2: the manufacturing data book page for page after the old build (cover, index, drawings with VOID stamps, traceability and heat map rasters, weld maps and welding records, Q.C. inspections, Q.C. and test documents, MTRs, record history), the job traveler with work order QR codes, the ITP PDF filed automatically at sign-off, every print kind with the PRINTED COPY stamp, the controlled-copy stamp signed with the seal key, PDF metadata, the traceability CSV with the formula guard, and the append-only print_log with an audit row for every print and export (migration 0008). Frontend: the close and file panel, the print menu and its entry points, the printed copies list on the History tab, the ITP PDF buttons. Tests: server (close, file, reopen, seal, the builders, prints, CSV, stamps), frontend, and the Playwright close and print scenarios. Docs updated; SECURITY closes M2, S6, S7 and S8. Co-Authored-By: Claude Fable 5.1 <[email protected]>
944 lines
37 KiB
Python
944 lines
37 KiB
Python
"""The write pipeline: every packet write is one transaction through `packet_write`.
|
||
|
||
Inside one transaction: lock the packet row (SELECT ... FOR UPDATE), build the policy's PacketState, check the
|
||
caller's expected step (409 stale_step), check the packet version when If-Match is given (409 version_conflict),
|
||
ask the policy (403 or 400 with the decision's code), take the signature when the rule needs one, hand the caller a
|
||
`Write`, then bump the version, write the packet_log row, the transition row and its side effects, the audit row and
|
||
the NOTIFY. The policy decides; a handler never does.
|
||
|
||
A wrong signature secret is the one failure that commits: the step-up failure count and the attempt row must stay,
|
||
so the three-strike rule works. Nothing else has been written at that point.
|
||
|
||
Child-row endpoints build on the same pieces: `check_row_version`, `next_counter`, `apply_field_cas`, `parse_if_match`.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import contextlib
|
||
import hashlib
|
||
import hmac
|
||
import logging
|
||
import os
|
||
import tempfile
|
||
from collections.abc import Iterator, Mapping, Sequence
|
||
from contextlib import contextmanager
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Any, Literal
|
||
|
||
from fastapi import Request
|
||
from psycopg.types.json import Jsonb
|
||
from pydantic import BaseModel, Field
|
||
|
||
from .. import audit, events, policy
|
||
from ..api import deps
|
||
from ..api.errors import ApiError
|
||
from ..auth import signatures
|
||
from ..auth.signatures import Signature
|
||
from ..policy.engine import MSG_FILED
|
||
from ..settings import SecretFileError
|
||
from ..text import LIMITS, clean_opt, reason_ok
|
||
from . import packets
|
||
from . import qc as qc_dom
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
A = policy.Action
|
||
|
||
MSG_STALE = "Someone else already took this step."
|
||
MSG_VERSION = "The packet changed since you opened it. Reload and try again."
|
||
MSG_ROW = "Someone else changed this line. Reload and try again."
|
||
MSG_FIELD = "Someone else changed this since you opened it."
|
||
MSG_DATABOOK = "The data book could not be built: {reason}. Nothing was filed."
|
||
|
||
|
||
def open_wos_text(wos: Sequence[str]) -> str:
|
||
"""The FILE confirm question: "VISUAL still shows 2 work orders open on this packet: L489052/1, L489052/2. File
|
||
anyway?" """
|
||
n = len(wos)
|
||
what = "1 work order" if n == 1 else f"{n} work orders"
|
||
return f"VISUAL still shows {what} open on this packet: {', '.join(wos)}. File anyway?"
|
||
|
||
|
||
# Codes that answer 400 (the UI asks for input and retries); every other refusal answers 403.
|
||
ASK_CODES = frozenset({"it_reason_required", "reason_required"})
|
||
|
||
COUNTERS = {"weld": "next_weld_no", "heat": "next_heat_no", "page": "next_page_no", "co": "next_co_no"}
|
||
|
||
# The packet_log label per action, when the caller gives none.
|
||
LABELS: dict[policy.Action, str] = {
|
||
A.SEND_TO_WAREHOUSE: "Sent to warehouse",
|
||
A.RELEASE_TO_SHOP: "Sent straight to the shop",
|
||
A.SEND_DRAFT_TO_QC: "Sent straight to Q.C.",
|
||
A.WH_SEND_BACK: "Sent back to Clerical",
|
||
A.RETURN_TO_CLERICAL: "Sent back to Clerical",
|
||
A.WH_RELEASE: "Released to supervisor",
|
||
A.SEND_BACK_TO_WAREHOUSE: "Sent back to the warehouse",
|
||
A.ASSIGN: "Sent to a station",
|
||
A.UNASSIGN: "Taken off the station",
|
||
A.PULL_BACK: "Pulled back from the station",
|
||
A.START: "Work started",
|
||
A.SUBMIT: "Submitted to supervisor",
|
||
A.RETURN_TO_STATION: "Returned to the station",
|
||
A.SEND_TO_QC: "Sent to Q.C.",
|
||
A.ROUTE_FROM_REVIEW: "Routed to another department",
|
||
A.QC_REJECT: "Q.C. rejected",
|
||
A.QC_ACCEPT: "Q.C. accepted",
|
||
A.QC_ACCEPT_AND_ROUTE: "Q.C. accepted and routed",
|
||
A.FILE: "Filed in the data book",
|
||
A.ADMIN_REOPEN: "Reopened by IT",
|
||
A.EDIT_HEADER: "Details changed",
|
||
A.EDIT_DRAWING_REF: "Drawing number changed",
|
||
A.EDIT_DEPT: "Department changed",
|
||
A.MOVE_UNIT: "Moved to another unit",
|
||
A.EDIT_WOS: "Work orders changed",
|
||
A.EDIT_DOCUMENT_SET: "Drawing attached by hand",
|
||
A.DOC_ADD: "Document added",
|
||
A.DOC_REMOVE: "Document removed",
|
||
A.SET_VCLOSED: "Job closed in VISUAL",
|
||
A.SET_DATABOOK_AT: "Data book date set",
|
||
A.REMOVE: "Packet removed",
|
||
}
|
||
|
||
AUDIT_ACTIONS: dict[policy.Action, str] = {A.REMOVE: "packet.removed", A.MOVE_UNIT: "packet.moved"}
|
||
|
||
|
||
# ---------------------------------------------------------------- request bodies
|
||
|
||
|
||
class SignBody(BaseModel):
|
||
factor: Literal["pin", "password"]
|
||
secret: str = Field(max_length=1024)
|
||
|
||
|
||
class Expect(BaseModel):
|
||
status: str = Field(max_length=20)
|
||
dept: str = Field(max_length=10)
|
||
|
||
|
||
# ---------------------------------------------------------------- the Write object
|
||
|
||
|
||
@dataclass
|
||
class Write:
|
||
conn: Any # the request connection, inside one transaction
|
||
principal: deps.Principal # the session principal
|
||
policy_principal: policy.Principal
|
||
packet: dict[str, Any] # the packets row, locked FOR UPDATE
|
||
state: policy.PacketState
|
||
decision: policy.Decision
|
||
signature: Signature | None
|
||
version: int # the new version after the bump (set on exit)
|
||
action: policy.Action
|
||
extra: Mapping[str, Any]
|
||
it_reason: str | None # the cleaned IT reason, kept only when the admin bypass was used
|
||
request: Request
|
||
label: str | None = None
|
||
note: str | None = None
|
||
fields: list[str] = field(default_factory=list)
|
||
detail: dict[str, Any] = field(default_factory=dict)
|
||
transitioned: bool = False
|
||
transition_id: int | None = None
|
||
noop: bool = False
|
||
from_status: str = ""
|
||
from_dept: str = ""
|
||
children: Any = None # packets.Children loaded under the lock (full=False)
|
||
more_logs: list[tuple[str, str | None]] = field(default_factory=list)
|
||
|
||
@property
|
||
def id(self) -> int:
|
||
return int(self.packet["id"])
|
||
|
||
@property
|
||
def user_id(self) -> Any:
|
||
return self.principal.user_id
|
||
|
||
@property
|
||
def rule(self) -> policy.Rule:
|
||
return policy.RULES[self.action]
|
||
|
||
@property
|
||
def settings(self) -> Any:
|
||
return _settings(self)
|
||
|
||
@property
|
||
def actor_ref(self) -> Any:
|
||
"""The signed-in person as the PDF builders' WhoRef: id, display name and employee number."""
|
||
from . import prints
|
||
|
||
return prints.who_of(self.principal)
|
||
|
||
def log(self, action_label: str, note: str | None = None) -> None:
|
||
"""Override the packet_log label (and note) for this write."""
|
||
self.label = action_label
|
||
if note is not None:
|
||
self.note = note
|
||
|
||
def log_also(self, action_label: str, note: str | None = None) -> None:
|
||
"""One more packet_log line for this write, written after the main one (same user, no signature)."""
|
||
self.more_logs.append((action_label, note))
|
||
|
||
def changed(self, *names: str) -> None:
|
||
"""Record the fields this write changed (they go into the audit detail)."""
|
||
for n in names:
|
||
if n not in self.fields:
|
||
self.fields.append(n)
|
||
|
||
def skip(self) -> None:
|
||
"""Nothing changed: no version bump, no log, audit or event."""
|
||
self.noop = True
|
||
|
||
def check(self, action: policy.Action, extra: Mapping[str, Any] | None = None) -> policy.Decision:
|
||
"""Check one more action on the same locked packet (for a write that touches several rules)."""
|
||
d = policy.check(self.policy_principal, self.state, action, extra=extra or self.extra, it_reason=self.it_reason)
|
||
refuse(d)
|
||
return d
|
||
|
||
|
||
# ---------------------------------------------------------------- helpers for callers
|
||
|
||
|
||
def refuse(decision: policy.Decision) -> None:
|
||
"""Raise the API error for a failed policy decision."""
|
||
if decision.ok:
|
||
return
|
||
status = 400 if decision.code in ASK_CODES else 403
|
||
raise ApiError(status, decision.code, decision.why)
|
||
|
||
|
||
def current_of(row: Mapping[str, Any]) -> dict[str, Any]:
|
||
return {"status": str(row["status"]), "dept": str(row["dept_code"]), "version": int(row["version"])}
|
||
|
||
|
||
def parse_if_match(request: Request) -> int | None:
|
||
"""The If-Match header as a plain integer (quotes and a W/ prefix are tolerated), or None when absent."""
|
||
raw = request.headers.get("if-match")
|
||
if raw is None:
|
||
return None
|
||
text = raw.strip().removeprefix("W/").strip().strip('"')
|
||
try:
|
||
return int(text)
|
||
except ValueError:
|
||
raise ApiError(400, "bad_if_match", "If-Match must be a version number.") from None
|
||
|
||
|
||
def require_if_match(request: Request) -> int:
|
||
v = parse_if_match(request)
|
||
if v is None:
|
||
raise ApiError(428, "if_match_required", "Send If-Match with the version you started from.")
|
||
return v
|
||
|
||
|
||
def check_row_version(row: Mapping[str, Any] | None, if_match: int | None, *, current: Any = None) -> None:
|
||
"""409 row_conflict with the current row when its row_version differs from If-Match.
|
||
|
||
`current` is what goes into the error (defaults to the row itself, JSON-shaped by the caller if needed).
|
||
A missing If-Match is 428 if_match_required.
|
||
"""
|
||
if row is None:
|
||
raise ApiError(404, "not_found", "No such line.")
|
||
if if_match is None:
|
||
raise ApiError(428, "if_match_required", "Send If-Match with the version you started from.")
|
||
if int(row["row_version"]) != int(if_match):
|
||
raise ApiError(
|
||
409, "row_conflict", MSG_ROW, extra={"current": jsonable(current if current is not None else row)}
|
||
)
|
||
|
||
|
||
def next_counter(conn: Any, packet_id: int, kind: str, n: int = 1) -> int:
|
||
"""Take `n` consecutive numbers from a packet counter (weld, heat, page, co); returns the first one.
|
||
|
||
Call it inside packet_write: the packet row is locked FOR UPDATE, so concurrent writers serialise on it.
|
||
Outside a pipeline the UPDATE itself takes the row lock for the rest of the transaction.
|
||
"""
|
||
col = COUNTERS.get(kind)
|
||
if col is None:
|
||
raise ValueError(f"unknown counter {kind!r}")
|
||
if n < 1:
|
||
raise ValueError("n must be at least 1")
|
||
row = conn.execute(
|
||
f"UPDATE packets SET {col} = {col} + %s WHERE id = %s RETURNING {col} - %s", # noqa: S608 (col from COUNTERS)
|
||
(n, packet_id, n),
|
||
).fetchone()
|
||
if row is None:
|
||
raise ApiError(404, "not_found", "No such packet.")
|
||
return int(row[0])
|
||
|
||
|
||
def _norm(value: Any) -> Any:
|
||
"""Comparable form of a value: uuid and dates as text, numbers as numbers, '' as None."""
|
||
if value is None or value == "":
|
||
return None
|
||
if isinstance(value, bool | int | float | str):
|
||
return value
|
||
return str(value)
|
||
|
||
|
||
def apply_field_cas(
|
||
current: Mapping[str, Any], set_: Mapping[str, Any], base: Mapping[str, Any] | None
|
||
) -> dict[str, Any]:
|
||
"""Field-level compare-and-set. For every key in set_ that base also has, base must equal current.
|
||
|
||
Conflicts raise 409 field_conflict with `current: {key: value}` for the conflicting keys. Keys without a base
|
||
entry are written without a check (the old merge3: mine wins). Returns the keys whose value actually changes.
|
||
"""
|
||
base = base or {}
|
||
conflicts = {k: current.get(k) for k in set_ if k in base and _norm(base[k]) != _norm(current.get(k))}
|
||
if conflicts:
|
||
raise ApiError(409, "field_conflict", MSG_FIELD, extra={"current": jsonable(conflicts)})
|
||
return {k: v for k, v in set_.items() if _norm(v) != _norm(current.get(k))}
|
||
|
||
|
||
def jsonable(value: Any) -> Any:
|
||
from fastapi.encoders import jsonable_encoder
|
||
|
||
return jsonable_encoder(value)
|
||
|
||
|
||
# ---------------------------------------------------------------- the pipeline
|
||
|
||
|
||
@contextmanager
|
||
def packet_write(
|
||
d: deps.Db,
|
||
p: deps.Principal,
|
||
packet_id: int,
|
||
action: policy.Action,
|
||
*,
|
||
request: Request,
|
||
extra: Mapping[str, Any] | None = None,
|
||
it_reason: str | None = None,
|
||
sign: SignBody | None = None,
|
||
expect: Expect | None = None,
|
||
sign_content: Any = None,
|
||
if_match: int | None = None,
|
||
also: Sequence[policy.Action] = (),
|
||
require_sign: bool | None = None,
|
||
policy_principal: policy.Principal | None = None,
|
||
) -> Iterator[Write]:
|
||
"""One packet write. See the module docstring for the order of steps.
|
||
|
||
`also` lists more actions the same write needs (each is checked; the first action is the one logged).
|
||
`require_sign` overrides the rule's signature: False skips it (removing a hand-attached drawing), True forces it.
|
||
"""
|
||
conn = d.conn
|
||
extra = dict(extra or {})
|
||
pp = policy_principal or packets.policy_principal_for(conn, p)
|
||
failure: ApiError | None = None
|
||
with conn.transaction():
|
||
row = packets.load_row(conn, packet_id, lock=True)
|
||
if row is None:
|
||
raise ApiError(404, "not_found", "No such packet.")
|
||
ch = packets.load_children(conn, row, full=False)
|
||
state = packets.state_from(row, ch)
|
||
if expect is not None and (expect.status != row["status"] or expect.dept != row["dept_code"]):
|
||
raise ApiError(409, "stale_step", MSG_STALE, extra={"current": current_of(row)})
|
||
extra.setdefault("holder_name", packets.holder_text(state, ch))
|
||
rule = policy.RULES[action]
|
||
if not state.removed and state.status == "filed" and not rule.filed_ok:
|
||
# Say "filed" rather than "wrong status" or "give an IT reason": nothing but a reopen changes it.
|
||
raise ApiError(403, "filed", MSG_FILED)
|
||
cleaned_it = reason_ok(it_reason)
|
||
decision = policy.check(pp, state, action, extra=extra, it_reason=it_reason)
|
||
refuse(decision)
|
||
bypass = decision.admin_bypass
|
||
for other in also:
|
||
dd = policy.check(pp, state, other, extra=extra, it_reason=it_reason)
|
||
refuse(dd)
|
||
bypass = bypass or dd.admin_bypass
|
||
if if_match is not None and int(if_match) != int(row["version"]):
|
||
raise ApiError(409, "version_conflict", MSG_VERSION, extra={"current": current_of(row)})
|
||
if action is A.FILE and extra.get("confirm_open_wos") is not True:
|
||
# Before the signature: a cancelled confirm costs no attempt and writes nothing.
|
||
wos = packets.packet_open_wos(conn, row)
|
||
if wos:
|
||
raise ApiError(409, "open_work_orders", open_wos_text(wos), extra={"work_orders": wos})
|
||
w = Write(
|
||
conn=conn,
|
||
principal=p,
|
||
policy_principal=pp,
|
||
packet=row,
|
||
state=state,
|
||
decision=decision,
|
||
signature=None,
|
||
version=int(row["version"]),
|
||
action=action,
|
||
extra=extra,
|
||
it_reason=cleaned_it if bypass else None,
|
||
request=request,
|
||
from_status=str(row["status"]),
|
||
from_dept=str(row["dept_code"]),
|
||
children=ch,
|
||
)
|
||
meaning = decision.signature
|
||
needs = bool(meaning) if require_sign is None else require_sign
|
||
if needs:
|
||
if sign is None:
|
||
raise ApiError(
|
||
400,
|
||
"signature_required",
|
||
"This step needs your signature.",
|
||
extra={"meaning": meaning or LABELS.get(action, str(action))},
|
||
)
|
||
try:
|
||
w.signature = signatures.sign(
|
||
conn,
|
||
deps.directory_of(request),
|
||
p.actor,
|
||
factor=sign.factor,
|
||
secret=sign.secret,
|
||
action=f"packet.{action}",
|
||
meaning=meaning,
|
||
packet_id=packet_id,
|
||
subject_type="packet",
|
||
subject_id=packet_id,
|
||
content=sign_content
|
||
if sign_content is not None
|
||
else {"action": str(action), "packet_id": packet_id, "version": int(row["version"])},
|
||
ip=p.ip,
|
||
settings=deps.settings_of(request),
|
||
)
|
||
except ApiError as e:
|
||
failure = e # commit the step-up failure records, then raise below
|
||
if failure is None:
|
||
yield w
|
||
_finish(w)
|
||
if failure is not None:
|
||
raise failure
|
||
|
||
|
||
def _finish(w: Write) -> None:
|
||
if w.noop:
|
||
return
|
||
if w.rule.transition and not w.transitioned:
|
||
apply_transition(w)
|
||
conn = w.conn
|
||
w.version = int(
|
||
conn.execute("UPDATE packets SET version = version + 1 WHERE id = %s RETURNING version", (w.id,)).fetchone()[0]
|
||
)
|
||
after = conn.execute("SELECT status::text, dept_code FROM packets WHERE id = %s", (w.id,)).fetchone()
|
||
status, dept = str(after[0]), str(after[1])
|
||
label = w.label or LABELS.get(w.action, str(w.action).replace("_", " ").capitalize())
|
||
note = clean_opt(w.note, LIMITS["note"]) if w.note else None
|
||
sig_id = w.signature.id if w.signature else None
|
||
conn.execute(
|
||
"INSERT INTO packet_log (packet_id, by, by_name, role, action, note, it_reason, signature_id, transition_id)"
|
||
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
|
||
(
|
||
w.id,
|
||
w.user_id,
|
||
w.principal.user["display_name"],
|
||
w.principal.role_snapshot,
|
||
label,
|
||
note,
|
||
w.it_reason,
|
||
sig_id,
|
||
w.transition_id,
|
||
),
|
||
)
|
||
for extra_label, extra_note in w.more_logs:
|
||
conn.execute(
|
||
"INSERT INTO packet_log (packet_id, by, by_name, role, action, note, it_reason)"
|
||
" VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
||
(
|
||
w.id,
|
||
w.user_id,
|
||
w.principal.user["display_name"],
|
||
w.principal.role_snapshot,
|
||
extra_label,
|
||
clean_opt(extra_note, LIMITS["note"]) if extra_note else None,
|
||
w.it_reason,
|
||
),
|
||
)
|
||
detail: dict[str, Any] = {"from": w.from_status, "to": status, "dept": dept}
|
||
if w.from_dept != dept:
|
||
detail["from_dept"] = w.from_dept
|
||
if w.fields:
|
||
detail["fields"] = w.fields
|
||
if note:
|
||
detail["note"] = note
|
||
if sig_id:
|
||
detail["signature_id"] = sig_id
|
||
detail.update(w.detail)
|
||
audit.append(
|
||
conn,
|
||
action=AUDIT_ACTIONS.get(w.action, f"packet.{w.action}"),
|
||
user_id=w.user_id,
|
||
session_id=w.principal.session_id,
|
||
role_snapshot=w.principal.role_snapshot,
|
||
subject_type="packet",
|
||
subject_id=w.id,
|
||
packet_id=w.id,
|
||
detail=detail,
|
||
it_reason=w.it_reason,
|
||
ip=w.principal.ip,
|
||
)
|
||
events.publish(conn, {"event": "changed", "packet_id": w.id, "version": w.version, "status": status, "dept": dept})
|
||
|
||
|
||
# ---------------------------------------------------------------- transitions and their side effects
|
||
|
||
|
||
CLOCK_STOP = (
|
||
"clock_elapsed_ms = clock_elapsed_ms + CASE WHEN clock_running_since IS NULL THEN 0"
|
||
" ELSE (extract(epoch FROM (now() - clock_running_since)) * 1000)::bigint END, clock_running_since = NULL"
|
||
)
|
||
|
||
|
||
def _reason(w: Write) -> str | None:
|
||
return reason_ok(w.extra.get("reason"))
|
||
|
||
|
||
def _note(w: Write) -> str | None:
|
||
return clean_opt(w.extra.get("note"), LIMITS["note"])
|
||
|
||
|
||
def _wh_incoming(w: Write) -> None:
|
||
w.conn.execute("UPDATE packet_wh SET closed_at = now() WHERE packet_id = %s AND closed_at IS NULL", (w.id,))
|
||
w.conn.execute(
|
||
"INSERT INTO packet_wh (packet_id, stage, by, note) VALUES (%s, 'incoming', %s, %s)",
|
||
(w.id, w.user_id, _note(w)),
|
||
)
|
||
|
||
|
||
def _wh_close(w: Write) -> None:
|
||
w.conn.execute("UPDATE packet_wh SET closed_at = now() WHERE packet_id = %s AND closed_at IS NULL", (w.id,))
|
||
|
||
|
||
def _open_stage(w: Write, dept: str) -> None:
|
||
w.conn.execute("INSERT INTO packet_stages (packet_id, dept_code) VALUES (%s, %s)", (w.id, dept))
|
||
|
||
|
||
def _close_stage(w: Write, qc_snapshot: Any = None) -> None:
|
||
w.conn.execute(
|
||
f"UPDATE packet_stages SET {CLOCK_STOP}, closed_at = now(), qc_snapshot = coalesce(%s, qc_snapshot)," # noqa: S608
|
||
" row_version = row_version + 1 WHERE packet_id = %s AND closed_at IS NULL",
|
||
(None if qc_snapshot is None else Jsonb(jsonable(qc_snapshot)), w.id),
|
||
)
|
||
|
||
|
||
def _stage_update(w: Write, assignments: str, params: Sequence[Any] = ()) -> None:
|
||
w.conn.execute(
|
||
f"UPDATE packet_stages SET {assignments}, row_version = row_version + 1" # noqa: S608 (fixed fragments)
|
||
" WHERE packet_id = %s AND closed_at IS NULL",
|
||
(*params, w.id),
|
||
)
|
||
|
||
|
||
def _open_inspection(w: Write) -> dict[str, Any] | None:
|
||
return packets.one(w.conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (w.id,))
|
||
|
||
|
||
def _close_inspection(w: Write, insp: Mapping[str, Any] | None, dept: str) -> None:
|
||
"""Close the open inspection; when it had a result, keep it in qc_history."""
|
||
if insp is None:
|
||
return
|
||
w.conn.execute(
|
||
"UPDATE qc_inspections SET closed_at = now(), row_version = row_version + 1 WHERE id = %s", (insp["id"],)
|
||
)
|
||
if insp.get("result"):
|
||
w.conn.execute(
|
||
"INSERT INTO qc_history (packet_id, result, inspector, note, dept_code, snapshot)"
|
||
" VALUES (%s, %s, %s, %s, %s, %s)",
|
||
(w.id, insp["result"], insp.get("inspector"), insp.get("result_note"), dept, Jsonb(jsonable(dict(insp)))),
|
||
)
|
||
|
||
|
||
def _set_result(w: Write, insp: Mapping[str, Any] | None, result: str, note: str | None) -> dict[str, Any] | None:
|
||
if insp is None:
|
||
return None
|
||
return packets.one(
|
||
w.conn,
|
||
"UPDATE qc_inspections SET result = %s, result_at = now(), result_by = %s, result_note = %s,"
|
||
" signature_id = %s, row_version = row_version + 1 WHERE id = %s RETURNING *",
|
||
(result, w.user_id, note, w.signature.id if w.signature else None, insp["id"]),
|
||
)
|
||
|
||
|
||
def _set_packet(w: Write, assignments: str, params: Sequence[Any] = ()) -> None:
|
||
w.conn.execute(f"UPDATE packets SET {assignments} WHERE id = %s", (*params, w.id)) # noqa: S608 (fixed fragments)
|
||
|
||
|
||
def _settings(w: Write) -> Any:
|
||
return deps.settings_of(w.request)
|
||
|
||
|
||
def seal_key_id(key: bytes) -> str:
|
||
return hashlib.sha256(key).hexdigest()[:8]
|
||
|
||
|
||
def seal_hash(key: bytes, content: Any) -> bytes:
|
||
return hmac.new(key, signatures.canonical_json(content), hashlib.sha256).digest()
|
||
|
||
|
||
def _seal_key(w: Write) -> bytes:
|
||
try:
|
||
key: bytes = _settings(w).seal_key()
|
||
except SecretFileError as e:
|
||
log.error("seal key unavailable: %s", e)
|
||
raise ApiError(503, "server_misconfigured", "The server is missing its seal key.") from None
|
||
return key
|
||
|
||
|
||
def qty_text(value: Any) -> str:
|
||
"""A quantity without trailing zeros: 4, 2.5."""
|
||
if value is None:
|
||
return "0"
|
||
try:
|
||
f = float(value)
|
||
except (TypeError, ValueError):
|
||
return str(value)
|
||
return str(int(f)) if f.is_integer() else f"{f:g}"
|
||
|
||
|
||
def _release_text(w: Write) -> None:
|
||
"""The old release label and note.
|
||
|
||
Label "Released to supervisor", or "... with {n} short"; note "{heats} heats recorded", plus " · SHORT: ..."
|
||
when shorts are open.
|
||
"""
|
||
lines = packets.rows(
|
||
w.conn,
|
||
"SELECT b.item, b.heat, s.qty_open, s.reason FROM bom_lines b LEFT JOIN bom_shorts s"
|
||
" ON s.bom_line_id = b.id AND s.closed_at IS NULL WHERE b.packet_id = %s ORDER BY b.item",
|
||
(w.id,),
|
||
)
|
||
heats = sum(1 for b in lines if str(b["heat"] or "").strip())
|
||
shorts = [b for b in lines if b["qty_open"] is not None]
|
||
note = f"{heats} heats recorded"
|
||
if shorts:
|
||
note += " · SHORT: " + ", ".join(
|
||
f"item {b['item']} ×{qty_text(b['qty_open'])} ({b['reason'] or ''})" for b in shorts
|
||
)
|
||
w.log(f"{LABELS[A.WH_RELEASE]} with {len(shorts)} short", note)
|
||
else:
|
||
w.log(LABELS[A.WH_RELEASE], note)
|
||
|
||
|
||
def hours_text(ms: Any) -> str:
|
||
"""Stage clock hours with one decimal: 1.5 h."""
|
||
return f"{int(ms or 0) / 3600000:.1f} h"
|
||
|
||
|
||
def _station_name(w: Write, station_id: Any) -> str | None:
|
||
if station_id is None:
|
||
return None
|
||
ch = w.children
|
||
if ch is not None and ch.station and ch.station.get("id") == station_id:
|
||
return str(ch.station["name"])
|
||
r = w.conn.execute("SELECT name FROM stations WHERE id = %s", (station_id,)).fetchone()
|
||
return str(r[0]) if r else None
|
||
|
||
|
||
def _dept_text(w: Write, code: str) -> str:
|
||
labels = getattr(w.children, "dept_labels", None) or packets.dept_labels(w.conn)
|
||
return str(labels.get(code, code))
|
||
|
||
|
||
def _shop_texts(w: Write, a: policy.Action, to_dept: str, stamp: str | None = None) -> None:
|
||
"""The packet_log label and note of a shop step (the old click handlers' texts)."""
|
||
ex = w.extra
|
||
here = _station_name(w, w.packet["station_id"]) or "the station"
|
||
note = _note(w)
|
||
if a is A.ASSIGN:
|
||
station = _station_name(w, int(ex["station_id"])) or "a station"
|
||
r = w.conn.execute("SELECT display_name FROM users WHERE id = %s", (str(ex["welder_id"]),)).fetchone()
|
||
who = str(r[0]) if r else ""
|
||
w.log(f"Sent to {station}", f"{who} [{stamp}]" if stamp else who)
|
||
elif a is A.UNASSIGN:
|
||
w.log(f"Taken off {here}", note)
|
||
elif a is A.PULL_BACK:
|
||
w.log(f"Pulled back from {here}", note)
|
||
elif a is A.START:
|
||
w.log(LABELS[A.START], " · ".join(x for x in (here if w.packet["station_id"] else None, note) if x) or None)
|
||
elif a is A.SUBMIT:
|
||
r = w.conn.execute(
|
||
"SELECT clock_elapsed_ms FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,)
|
||
).fetchone()
|
||
n = packets.dept_welds(w.packet, w.children) if w.children is not None else 0
|
||
text = f"{n} welds · {hours_text(r[0] if r else 0)}"
|
||
w.log(LABELS[A.SUBMIT], f"{text} · {note}" if note else text)
|
||
elif a is A.RETURN_TO_STATION:
|
||
w.log(f"Returned to {here}", _reason(w))
|
||
elif a is A.SEND_TO_QC:
|
||
loc = clean_opt(ex.get("location"), LIMITS["location"])
|
||
w.log(LABELS[A.SEND_TO_QC], " · ".join(x for x in (f"Spool at {loc}" if loc else None, note) if x) or None)
|
||
elif a is A.ROUTE_FROM_REVIEW:
|
||
text = f"Supervisor, Q.C. skipped · from {_dept_text(w, w.from_dept)}"
|
||
w.log(f"Routed to {_dept_text(w, to_dept)}", f"{text} · {note}" if note else text)
|
||
|
||
|
||
SHOP_TEXTS = frozenset(
|
||
{A.ASSIGN, A.UNASSIGN, A.PULL_BACK, A.START, A.SUBMIT, A.RETURN_TO_STATION, A.SEND_TO_QC, A.ROUTE_FROM_REVIEW}
|
||
)
|
||
QC_TEXTS = frozenset({A.QC_ACCEPT, A.QC_ACCEPT_AND_ROUTE, A.QC_REJECT})
|
||
|
||
|
||
def apply_transition(w: Write) -> None:
|
||
"""Set status, status_at and dept, write the packet_transitions row and run the action's side effects."""
|
||
if w.transitioned:
|
||
return
|
||
w.transitioned = True
|
||
a = w.action
|
||
rule = w.rule
|
||
to_status = rule.to_status
|
||
assert to_status is not None
|
||
ex = w.extra
|
||
reason = _reason(w)
|
||
from_dept = w.from_dept
|
||
to_dept = from_dept
|
||
if rule.dept_change != "no" and isinstance(ex.get("dept"), str) and ex.get("dept"):
|
||
to_dept = str(ex["dept"])
|
||
|
||
stamp: str | None = None
|
||
if a in SHOP_TEXTS and a is not A.ASSIGN and a is not A.SUBMIT:
|
||
_shop_texts(w, a, to_dept) # before the side effects clear the station
|
||
if a in QC_TEXTS:
|
||
qc_dom.qc_texts(w, a, to_dept) # before the side effects close the inspection
|
||
|
||
if a in (A.SEND_TO_WAREHOUSE, A.VIS_AUTO_RELEASE):
|
||
_set_packet(w, "wh_skipped = false, wh_sent_at = now(), return_note = NULL")
|
||
_wh_incoming(w)
|
||
elif a is A.RELEASE_TO_SHOP:
|
||
_set_packet(
|
||
w,
|
||
"wh_skipped = true, for_sup_id = %s, return_note = %s",
|
||
(ex.get("for_sup_id") or None, _note(w)),
|
||
)
|
||
_close_stage(w)
|
||
_open_stage(w, to_dept)
|
||
elif a is A.SEND_DRAFT_TO_QC:
|
||
_set_packet(w, "wh_skipped = true")
|
||
_close_inspection(w, _open_inspection(w), from_dept)
|
||
w.conn.execute(
|
||
"INSERT INTO qc_inspections (packet_id, sent_by, sent_note) VALUES (%s, %s, %s)",
|
||
(w.id, w.user_id, _note(w)),
|
||
)
|
||
elif a is A.WH_SEND_BACK:
|
||
_set_packet(w, "return_note = %s", (reason,))
|
||
_wh_close(w)
|
||
elif a is A.RETURN_TO_CLERICAL:
|
||
_set_packet(w, "return_note = %s", (reason,))
|
||
_close_stage(w)
|
||
elif a is A.WH_RELEASE:
|
||
_release_text(w)
|
||
_wh_close(w)
|
||
_close_stage(w)
|
||
_open_stage(w, to_dept)
|
||
elif a is A.SEND_BACK_TO_WAREHOUSE:
|
||
_set_packet(w, "return_note = %s, wh_skipped = false", (reason,))
|
||
_close_stage(w)
|
||
_wh_incoming(w)
|
||
elif a is A.ASSIGN:
|
||
station_id = int(ex["station_id"])
|
||
welder_id = str(ex["welder_id"])
|
||
stamp = clean_opt(ex.get("stamp"), LIMITS["stamp"])
|
||
if stamp is None:
|
||
r = w.conn.execute("SELECT welder_stamp FROM users WHERE id = %s", (welder_id,)).fetchone()
|
||
stamp = r[0] if r else None
|
||
_set_packet(w, "station_id = %s, welder_id = %s, welder_stamp = %s", (station_id, welder_id, stamp))
|
||
if not packets.one(w.conn, "SELECT id FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,)):
|
||
_open_stage(w, from_dept)
|
||
_stage_update(w, "station_id = %s, welder_id = %s, welder_stamp = %s", (station_id, welder_id, stamp))
|
||
_shop_texts(w, a, to_dept, stamp)
|
||
elif a in (A.UNASSIGN, A.PULL_BACK):
|
||
_set_packet(w, "station_id = NULL, welder_id = NULL, welder_stamp = NULL")
|
||
_stage_update(w, f"{CLOCK_STOP}, station_id = NULL, welder_id = NULL, welder_stamp = NULL")
|
||
elif a is A.START:
|
||
# A clock that is already running keeps its start; only the server sets it.
|
||
_stage_update(
|
||
w,
|
||
"clock_running_since = coalesce(clock_running_since, now()),"
|
||
" clock_started_at = coalesce(clock_started_at, now())",
|
||
)
|
||
elif a is A.SUBMIT:
|
||
_set_packet(w, "return_note = NULL")
|
||
_stage_update(w, CLOCK_STOP)
|
||
_shop_texts(w, a, to_dept)
|
||
elif a is A.RETURN_TO_STATION:
|
||
_set_packet(w, "return_note = %s", (reason,))
|
||
elif a is A.SEND_TO_QC:
|
||
_close_inspection(w, _open_inspection(w), from_dept)
|
||
stage = packets.one(w.conn, "SELECT id FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,))
|
||
w.conn.execute(
|
||
"INSERT INTO qc_inspections (packet_id, stage_id, sent_by, book, spool, location, sent_note)"
|
||
" VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
||
(
|
||
w.id,
|
||
stage["id"] if stage else None,
|
||
w.user_id,
|
||
ex.get("book") is True,
|
||
ex.get("spool") is True,
|
||
clean_opt(ex.get("location"), LIMITS["location"]),
|
||
_note(w),
|
||
),
|
||
)
|
||
elif a in (A.ROUTE_FROM_REVIEW, A.QC_ACCEPT_AND_ROUTE):
|
||
insp = _open_inspection(w)
|
||
if a is A.QC_ACCEPT_AND_ROUTE:
|
||
insp = _set_result(w, insp, "accepted", _note(w))
|
||
_close_stage(w, qc_snapshot=insp)
|
||
_close_inspection(w, insp, from_dept)
|
||
_open_stage(w, to_dept)
|
||
_set_packet(w, "station_id = NULL, welder_id = NULL, welder_stamp = NULL, return_note = NULL, routed_to = NULL")
|
||
elif a is A.QC_REJECT:
|
||
# The reject closes the inspection into qc_history at once; the next SEND_TO_QC opens a fresh one.
|
||
insp = _open_inspection(w)
|
||
if insp is not None:
|
||
w.conn.execute("UPDATE qc_inspections SET ncr = %s WHERE id = %s", (ex.get("ncr") or None, insp["id"]))
|
||
insp = _set_result(w, insp, "rejected", reason)
|
||
_close_inspection(w, insp, from_dept)
|
||
_set_packet(w, "return_note = %s", (reason,))
|
||
if not packets.one(w.conn, "SELECT id FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,)):
|
||
_open_stage(w, from_dept) # a draft sent straight to Q.C. has no stage yet
|
||
elif a is A.QC_ACCEPT:
|
||
insp = _set_result(w, _open_inspection(w), "accepted", _note(w))
|
||
_close_inspection(w, insp, from_dept)
|
||
_close_stage(w, qc_snapshot=insp)
|
||
_set_packet(w, "routed_to = %s::route_kind", (str(ex["routed_to"]),))
|
||
|
||
_set_packet(w, "status = %s::packet_status, status_at = now(), dept_code = %s", (to_status, to_dept))
|
||
|
||
if a is A.FILE:
|
||
_file(w)
|
||
elif a is A.ADMIN_REOPEN:
|
||
w.conn.execute(
|
||
"UPDATE packet_seals SET broken_at = now(), broken_by = %s, broken_reason = %s"
|
||
" WHERE packet_id = %s AND broken_at IS NULL",
|
||
(w.user_id, reason, w.id),
|
||
)
|
||
|
||
tid = w.conn.execute(
|
||
"INSERT INTO packet_transitions (packet_id, by, action, from_status, to_status, from_dept, to_dept, reason,"
|
||
" signature_id, it_reason) VALUES (%s, %s, %s, %s::packet_status, %s::packet_status, %s, %s, %s, %s, %s)"
|
||
" RETURNING id",
|
||
(
|
||
w.id,
|
||
w.user_id,
|
||
str(a),
|
||
w.from_status,
|
||
to_status,
|
||
from_dept,
|
||
to_dept,
|
||
reason or _note(w),
|
||
w.signature.id if w.signature else None,
|
||
w.it_reason,
|
||
),
|
||
).fetchone()
|
||
w.transition_id = int(tid[0])
|
||
if w.note is None:
|
||
w.note = reason or _note(w)
|
||
|
||
|
||
def _file(w: Write) -> None:
|
||
"""FILE: the data book date, the seal, and the data book built and stored on the seal, all in this transaction.
|
||
|
||
A data book that cannot be built answers 500 databook_failed and the whole filing rolls back."""
|
||
key = _seal_key(w)
|
||
_set_packet(w, "databook_at = now()")
|
||
row = packets.load_row(w.conn, w.id)
|
||
assert row is not None
|
||
wos = packets.packet_open_wos(w.conn, row) if w.extra.get("confirm_open_wos") is True else []
|
||
content = packets.seal_content(w.conn, row)
|
||
digest = seal_hash(key, content)
|
||
kid = seal_key_id(key)
|
||
seal_id, sealed_at = w.conn.execute(
|
||
"INSERT INTO packet_seals (packet_id, hash, key_id, sealed_by, signature_id, open_wos)"
|
||
" VALUES (%s, %s, %s, %s, %s, %s) RETURNING id, sealed_at",
|
||
(w.id, digest, kid, w.user_id, w.signature.id if w.signature else None, wos or None),
|
||
).fetchone()
|
||
pages = _file_databook(w, row, {"hash": digest.hex(), "key_id": kid, "sealed_at": sealed_at}, int(seal_id))
|
||
w.log_also("Data book filed", f"{pages} pages")
|
||
if wos:
|
||
w.note = " · ".join(x for x in (_note(w), "Filed with work orders open in VISUAL: " + ", ".join(wos)) if x)
|
||
w.detail["open_wos"] = wos
|
||
w.detail["seal_id"] = int(seal_id)
|
||
w.detail["databook_pages"] = pages
|
||
|
||
|
||
def _file_databook(w: Write, row: Mapping[str, Any], seal: dict[str, Any], seal_id: int) -> int:
|
||
"""Build the filed data book, store it as an asset and put it on the seal row. Returns its page count."""
|
||
from ..pdf import databook
|
||
from ..pdf.style import PdfError
|
||
from . import assets
|
||
from . import prints as prints_dom
|
||
|
||
settings = _settings(w)
|
||
try:
|
||
built = databook.build(w.conn, settings, w.id, who=w.actor_ref, seal=seal, mode="filed")
|
||
except PdfError as e:
|
||
log.warning("data book for packet %s failed: %s", w.id, e)
|
||
raise ApiError(500, "databook_failed", MSG_DATABOOK.format(reason=_reason_text(e))) from None
|
||
read = packets.read_packet(w.conn, w.id, None, include_children=False)
|
||
name = prints_dom.databook_name(read)
|
||
tmp_dir = Path(settings.assets_dir) / "tmp"
|
||
tmp_dir.mkdir(parents=True, exist_ok=True)
|
||
fd, tmp_name = tempfile.mkstemp(prefix="databook-", suffix=".pdf", dir=tmp_dir)
|
||
with os.fdopen(fd, "wb") as f:
|
||
f.write(built.bytes)
|
||
try:
|
||
stored = assets.store_file(
|
||
w.conn,
|
||
settings,
|
||
Path(tmp_name),
|
||
mime=assets.PDF,
|
||
original_name=name,
|
||
source="databook",
|
||
source_ref={"packet_id": w.id, "seal_id": seal_id},
|
||
uploaded_by=w.user_id,
|
||
packet_id=w.id,
|
||
)
|
||
except ApiError as e:
|
||
log.warning("data book for packet %s was not stored: %s", w.id, e.detail)
|
||
raise ApiError(500, "databook_failed", MSG_DATABOOK.format(reason=e.detail.rstrip("."))) from None
|
||
finally:
|
||
with contextlib.suppress(OSError):
|
||
Path(tmp_name).unlink()
|
||
pages = int(built.pages)
|
||
w.conn.execute(
|
||
"UPDATE packet_seals SET databook_asset_id = %s, databook_pages = %s WHERE id = %s",
|
||
(stored["id"], pages, seal_id),
|
||
)
|
||
w.detail["databook_asset_id"] = str(stored["id"])
|
||
return pages
|
||
|
||
|
||
def _reason_text(e: BaseException) -> str:
|
||
text = " ".join(str(e).split()).rstrip(".")
|
||
return text[:300] or "an unknown error"
|
||
|
||
|
||
def verify_seal(conn: Any, settings: Any, packet_id: int) -> dict[str, Any]:
|
||
"""The seal history of a packet and whether the current unbroken seal still matches the packet."""
|
||
seals = packets.rows(conn, "SELECT * FROM packet_seals WHERE packet_id = %s ORDER BY id DESC", (packet_id,))
|
||
people = packets.People(conn, [s["sealed_by"] for s in seals] + [s["broken_by"] for s in seals])
|
||
current = next((s for s in seals if s["broken_at"] is None), None)
|
||
valid: bool | None = None
|
||
if current is not None:
|
||
try:
|
||
key = settings.seal_key()
|
||
except SecretFileError:
|
||
key = None
|
||
if key is not None and seal_key_id(key) == current["key_id"]:
|
||
row = packets.load_row(conn, packet_id)
|
||
assert row is not None
|
||
valid = hmac.compare_digest(seal_hash(key, packets.seal_content(conn, row)), bytes(current["hash"]))
|
||
|
||
def shape(s: Mapping[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"id": s["id"],
|
||
"sealed_at": s["sealed_at"],
|
||
"sealed_by": people.ref(s["sealed_by"]),
|
||
"key_id": s["key_id"],
|
||
"hash": bytes(s["hash"]).hex(),
|
||
"signed": s["signature_id"] is not None,
|
||
"broken_at": s["broken_at"],
|
||
"broken_by": people.ref(s["broken_by"]),
|
||
"broken_reason": s["broken_reason"],
|
||
"open_wos": None if s.get("open_wos") is None else list(s["open_wos"]),
|
||
"databook_asset_id": None if s.get("databook_asset_id") is None else str(s["databook_asset_id"]),
|
||
"databook_pages": s.get("databook_pages"),
|
||
}
|
||
|
||
return {"seal": shape(current) if current else None, "valid": valid, "seals": [shape(s) for s in seals]}
|