A filed packet's sealed data book, documents and a manifest are copied to the archive folder by the worker. A failed copy never blocks filing: it is retried with backoff and raises the archive_failed condition. Co-Authored-By: Claude Opus 5.5 <[email protected]>
945 lines
37 KiB
Python
945 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, 400 or 422 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 checklists, 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."
|
||
|
||
|
||
# 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 422 if decision.code == "ncr_required" 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)})
|
||
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_ncr(w: Write, insp: Mapping[str, Any] | None) -> None:
|
||
"""The NCR number the step carries (a reject, or the accept after a reject without one), on the inspection."""
|
||
ncr = w.extra.get("ncr") or None
|
||
if insp is not None and (ncr or w.action is A.QC_REJECT):
|
||
w.conn.execute("UPDATE qc_inspections SET ncr = %s WHERE id = %s", (ncr, 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)
|
||
if a is A.QC_REJECT and reason and ex.get("ncr"):
|
||
reason = f"{ex['ncr']} · {reason}" # the supervisor's return note leads with the NCR number
|
||
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())",
|
||
)
|
||
# change C8: the stage keeps the checklist it started with
|
||
checklists.freeze_on_start(w.conn, w.id)
|
||
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:
|
||
_set_ncr(w, insp)
|
||
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)
|
||
_set_ncr(w, insp)
|
||
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 = _open_inspection(w)
|
||
_set_ncr(w, insp)
|
||
insp = _set_result(w, insp, "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
|
||
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)"
|
||
" VALUES (%s, %s, %s, %s, %s) RETURNING id, sealed_at",
|
||
(w.id, digest, kid, w.user_id, w.signature.id if w.signature else 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")
|
||
w.detail["seal_id"] = int(seal_id)
|
||
w.detail["databook_pages"] = pages
|
||
# The archive copy runs in the worker after this commits. A failed copy never blocks filing.
|
||
from . import archive
|
||
|
||
if archive.on_filed(w.conn, _settings(w), w.user_id, w.id):
|
||
w.detail["archive_requested"] = True
|
||
|
||
|
||
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]}
|