Phase 2: core domain, policy engine, packet API, Clerical screen

Migration 0003: projects, aliases, renumbers, units, packets and every child table,
change orders, assets, documents, ITPs, station limits, department weld flag.
Policy package: Principal, PacketState, Action rules, custody, preconditions,
CO hold, order of checks, can/blocked/holder on reads. docs/POLICY.md with the
tightenings table for Seth. Write pipeline: FOR UPDATE, CAS on status and dept,
If-Match row versions, server-assigned numbers, field CAS, version bump, packet
log, audit, NOTIFY, signatures, IT reasons, filed lock and HMAC seal.
API: /api/packets (header, transitions, work orders, pages, docs, move, remove,
BOM, shorts, welds, heat tags, heat table, stage, checklist, clock, warehouse
stage, Q.C., change orders, log, seal), /api/projects, /api/units, /api/assets,
/_blob. Frontend: Clerical home, projects and units, packet page with pdf.js
and QR codes, hand draft, heat map, send to warehouse, department or Q.C.
Tests: policy matrix, API, concurrency, schema, projects; Playwright Clerical
draft scenario. Verified on docker-1 with the CI stack and the Samba DC.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 09:31:45 -06:00
co-authored by Claude Fable 5.1
parent 97b0973130
commit 37d9a0cb50
83 changed files with 16490 additions and 55 deletions
+1
View File
@@ -0,0 +1 @@
"""The core domain: packets, projects and units, documents, the write pipeline. Pure SQL helpers over one connection."""
+231
View File
@@ -0,0 +1,231 @@
"""Stored files. One row per file in `assets`; the bytes live at ASSETS_DIR/<id[:2]>/<id>.
An upload is streamed to a temp file in ASSETS_DIR in chunks (never held in memory whole), hashed as it goes,
typed by its first bytes (not by its name or the browser's claim), fsynced and renamed into place. The same bytes
uploaded twice (same sha256 and size) return the existing row.
"""
from __future__ import annotations
import contextlib
import hashlib
import logging
import os
import re
import tempfile
import uuid
from pathlib import Path
from typing import Any, BinaryIO
from psycopg.rows import dict_row
from .. import audit
from ..api.errors import ApiError
from ..settings import Settings
from ..text import clean_text
log = logging.getLogger(__name__)
CHUNK = 1024 * 1024
HEAD = 16
PDF = "application/pdf"
MAGIC: tuple[tuple[bytes, str], ...] = (
(b"%PDF-", PDF),
(b"\x89PNG\r\n\x1a\n", "image/png"),
(b"\xff\xd8\xff", "image/jpeg"),
)
EXT = {PDF: ".pdf", "image/png": ".png", "image/jpeg": ".jpg"}
NAME_MAX = 160
_UNSAFE_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
def sniff(head: bytes) -> str | None:
"""The mime type from the first bytes, or None when it is not a type Packet Station stores."""
for magic, mime in MAGIC:
if head.startswith(magic):
return mime
return None
def safe_name(name: Any) -> str | None:
"""The original file name, cleaned: no path, no separators or reserved characters, at most 160 characters."""
text = clean_text(name, 1000)
text = text.replace("\\", "/").rsplit("/", 1)[-1]
text = _UNSAFE_NAME.sub("_", text).strip(" .")
return text[:NAME_MAX] or None
def storage_key(asset_id: uuid.UUID | str) -> str:
s = str(asset_id)
return f"{s[:2]}/{s}"
def path_of(settings: Settings, key: str) -> Path:
root = Path(settings.assets_dir).resolve()
p = (root / key).resolve()
if not p.is_relative_to(root):
raise ApiError(404, "not_found", "No such file.")
return p
def pdf_pages(path: Path) -> int:
"""Page count through pypdf; 422 bad_pdf when it does not parse or has no pages."""
from pypdf import PdfReader
from pypdf.errors import PyPdfError
try:
n = len(PdfReader(str(path)).pages)
except (PyPdfError, ValueError, KeyError, TypeError, OSError, IndexError, AttributeError) as e:
log.info("upload is not a readable PDF: %s", type(e).__name__)
raise ApiError(422, "bad_pdf", "That PDF could not be read.") from None
if n < 1:
raise ApiError(422, "bad_pdf", "That PDF has no pages.")
return n
def public(row: dict[str, Any]) -> dict[str, Any]:
return {
"id": str(row["id"]),
"mime": row["mime"],
"size": int(row["size"]),
"sha256": bytes(row["sha256"]).hex(),
"pages": row["pages"],
"original_name": row["original_name"],
"url": f"/_blob/{row['id']}",
}
def get(conn: Any, asset_id: Any) -> dict[str, Any] | None:
try:
aid = uuid.UUID(str(asset_id))
except ValueError:
return None
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute("SELECT * FROM assets WHERE id = %s", (aid,)).fetchone()
return dict(r) if r else None
def _fsync_dir(path: Path) -> None:
if os.name != "posix":
return
fd = os.open(str(path), os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
def store_upload(
conn: Any,
settings: Settings,
stream: BinaryIO,
*,
original_name: Any,
user: Any,
packet_id: int | None = None,
source: str = "upload",
source_ref: Any = None,
actor: Any = None,
) -> dict[str, Any]:
"""Store one upload and return its public row. Raises 413 too_large, 415 unsupported_type, 422 bad_pdf.
`user` is the uploader's user id; `actor` (the session Principal) is used for the audit row when given.
"""
root = Path(settings.assets_dir)
root.mkdir(parents=True, exist_ok=True)
limit = int(settings.upload_max_bytes)
fd, tmp_name = tempfile.mkstemp(prefix=".up-", dir=str(root))
tmp = Path(tmp_name)
keep = False
placed: Path | None = None
try:
digest = hashlib.sha256()
size = 0
head = b""
mime: str | None = None
with os.fdopen(fd, "wb") as out:
while True:
chunk = stream.read(CHUNK)
if not chunk:
break
size += len(chunk)
if size > limit:
raise ApiError(413, "too_large", f"The file is over the {limit // (1024 * 1024)} MB limit.")
if mime is None and len(head) < HEAD:
head += chunk[: HEAD - len(head)]
if len(head) >= HEAD:
mime = sniff(head)
if mime is None:
raise ApiError(415, "unsupported_type", "Only PDF, PNG and JPEG files can be stored.")
digest.update(chunk)
out.write(chunk)
out.flush()
os.fsync(out.fileno())
if mime is None:
mime = sniff(head)
if mime is None or size == 0:
raise ApiError(415, "unsupported_type", "Only PDF, PNG and JPEG files can be stored.")
sha = digest.digest()
with conn.cursor(row_factory=dict_row) as cur:
existing = cur.execute(
"SELECT * FROM assets WHERE sha256 = %s AND size = %s ORDER BY uploaded_at LIMIT 1", (sha, size)
).fetchone()
if existing is not None:
return public(dict(existing))
pages = pdf_pages(tmp) if mime == PDF else None
aid = uuid.uuid4()
key = storage_key(aid)
final = path_of(settings, key)
final.parent.mkdir(parents=True, exist_ok=True)
os.replace(tmp, final)
keep = True
placed = final
_fsync_dir(final.parent)
name = safe_name(original_name)
with conn.transaction():
with conn.cursor(row_factory=dict_row) as cur:
row = cur.execute(
"INSERT INTO assets (id, sha256, size, mime, original_name, storage_key, source, source_ref, pages,"
" uploaded_by, packet_id) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING *",
(aid, sha, size, mime, name, key, source, source_ref, pages, user, packet_id),
).fetchone()
audit.append(
conn,
action="asset.uploaded",
user_id=user,
session_id=getattr(actor, "session_id", None),
role_snapshot=getattr(actor, "role_snapshot", None),
subject_type="asset",
subject_id=str(aid),
packet_id=packet_id,
detail={"mime": mime, "size": size, "pages": pages, "sha256": sha.hex()},
ip=getattr(actor, "ip", None),
)
return public(dict(row))
except BaseException:
if placed is not None: # the row was not written: do not leave an orphan file
with contextlib.suppress(OSError):
placed.unlink()
raise
finally:
if not keep:
with contextlib.suppress(OSError):
tmp.unlink()
def open_blob(settings: Settings, row: dict[str, Any]) -> Path:
"""The stored file of an asset row; 404 when it is missing on disk."""
p = path_of(settings, str(row["storage_key"]))
if not p.is_file():
log.error("asset %s is missing on disk", row["id"])
raise ApiError(404, "not_found", "No such file.")
return p
def download_name(row: dict[str, Any]) -> str:
"""A safe file name for Content-Disposition (ASCII only, quotes removed)."""
name = row.get("original_name") or ""
name = name.encode("ascii", "ignore").decode("ascii").replace('"', "").replace(";", "_").strip()
if not name:
name = str(row["id"]) + EXT.get(str(row["mime"]), "")
return name
+126
View File
@@ -0,0 +1,126 @@
"""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 keys are the old keys, so a checklist stored as {key: {done, at, by, stamp}} reads the same.
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from typing import Any
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}
def checklist_for(weld: bool, drawing: str | None, rev: str | None, wps: str | None) -> list[dict[str, str]]:
"""The stage checklist for a department, as a flat list of {group, key, text} in display order."""
dwg = f"Drawing {drawing or ''} Rev {rev or NO_REV} matches the packet".replace(" ", " ")
if not weld:
g1, g2 = "Before starting", "When done"
return [
_item(g1, "dwg", dwg),
_item(g1, "prev", "Previous department's work complete and released"),
_item(g1, "mat", "Material and parts on hand match the packet"),
_item(g2, "work", "Work completed per drawing and procedure"),
_item(g2, "insp", "Self-inspection done; no damage to finished surfaces"),
_item(g2, "qty", "Completed quantity matches each work order"),
]
g1, g2 = "Before welding", "After welding"
return [
_item(g1, "dwg", dwg),
_item(g1, "heat", "Heat numbers on material match the Materials tab"),
_item(g1, "wps", f"WPS reviewed: {wps or 'per drawing'}"),
_item(g1, "fit", "Fit-up and dimensions checked against the drawing"),
_item(g1, "pre", "Preheat and interpass temperature per WPS"),
_item(g2, "vt", "Visual inspection of all welds acceptable"),
_item(g2, "stamp", "Welds stamped with welder ID"),
_item(g2, "clean", "Slag and spatter removed"),
_item(g2, "qty", "Completed quantity matches each work order"),
]
def qc_items_for(
drawing: str | None, rev: str | None, wps: str | None, welder_stamp: str | None
) -> list[dict[str, str]]:
"""The Q.C. inspection items, as a flat list of {group, key, text}."""
g1, g2 = "Data book package", "Physical spool"
stamp = f"[{welder_stamp}] " if welder_stamp else ""
return [
_item(g1, "db_heats", "Heat number recorded for every BOM item"),
_item(g1, "db_mtr", "MTRs on file and match the heat numbers"),
_item(g1, "db_weldmap", "Weld map complete: every weld identified"),
_item(g1, "db_wps", f"Welds made to {wps or 'the WPS'}; welder {stamp}qualified"),
_item(g1, "db_ck", "Stall checklist and weld record complete"),
_item(g2, "ph_dim", f"Dimensions verified against {drawing or 'the drawing'} Rev {rev or NO_REV}"),
_item(g2, "ph_heat", "Heat numbers on material match the heat map"),
_item(g2, "ph_stamp", "Welds stamped with welder ID"),
_item(g2, "ph_clean", "Clean: no slag, spatter or arc strikes"),
_item(g2, "ph_qty", "Quantity matches the work orders"),
]
def checklist_keys(weld: bool) -> list[str]:
return [i["key"] for i in checklist_for(weld, None, None, None)]
def qc_keys() -> list[str]:
return [i["key"] for i in qc_items_for(None, None, None, None)]
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) -> 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))
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]]
) -> 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).
"""
del packet
q = qc or {}
checks = q.get("checks") or {}
missing: list[str] = []
open_items = sum(1 for k in qc_keys() 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
+111
View File
@@ -0,0 +1,111 @@
"""Shared SQL for the packet child rows: guarded row updates, the warehouse stage history, and the departments a
packet touched (who verifies a change order).
Every function here runs inside `packet_write`, so the packet row is already locked FOR UPDATE and child writes on
the same packet serialise on it.
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from typing import Any
from psycopg import sql
from psycopg.rows import dict_row
from . import packets
# Tables update_row may touch. The column names come from the handlers' fixed field lists, never from a request.
TABLES = frozenset(
{"bom_lines", "bom_shorts", "welds", "heat_tags", "packet_stages", "qc_inspections", "change_orders"}
)
# Statuses past the warehouse: the packet has been in its department.
IN_DEPT = frozenset({"ready", "assigned", "in_progress", "review", "qc", "complete", "filed"})
QC_STATUSES = frozenset({"qc", "complete", "filed"})
# Departments that never verify a change order: Clerical issues them, PM has no supervisor.
NEVER_VERIFY = frozenset({"CL", "PM"})
def update_row(
conn: Any, table: str, row_id: Any, values: Mapping[str, Any], *, bump: bool = True
) -> dict[str, Any] | None:
"""UPDATE one row by id with the given column values (and row_version + 1 when bump). Returns the new row."""
if table not in TABLES:
raise ValueError(f"update_row: table {table!r} is not allowed")
parts: list[sql.Composable] = [sql.SQL("{} = {}").format(sql.Identifier(k), sql.Placeholder()) for k in values]
if bump:
parts.append(sql.SQL("row_version = row_version + 1"))
if not parts:
raise ValueError("update_row: nothing to set")
query = sql.SQL("UPDATE {} SET {} WHERE id = {} RETURNING *").format(
sql.Identifier(table), sql.SQL(", ").join(parts), sql.Placeholder()
)
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(query, (*values.values(), row_id)).fetchone()
return dict(r) if r is not None else None
def current_wh(conn: Any, packet_id: int) -> dict[str, Any] | None:
return packets.one(
conn, "SELECT *, stage::text AS stage FROM packet_wh WHERE packet_id = %s AND closed_at IS NULL", (packet_id,)
)
def wh_move(
conn: Any,
packet_id: int,
stage: str,
by: Any,
*,
reason: str | None = None,
note: str | None = None,
auto: bool = False,
) -> None:
"""Close the current warehouse stage row and insert the new one."""
conn.execute("UPDATE packet_wh SET closed_at = now() WHERE packet_id = %s AND closed_at IS NULL", (packet_id,))
conn.execute(
"INSERT INTO packet_wh (packet_id, stage, by, reason, note, auto) VALUES (%s, %s::wh_stage, %s, %s, %s, %s)",
(packet_id, stage, by, reason, note, auto),
)
def co_touched(conn: Any, row: Mapping[str, Any]) -> list[str]:
"""The departments a packet has touched, in the old coTouched() order, without Clerical and PM.
WH once it left draft; its department once it reached the shop; every stage's department; QC once it reached
Q.C. (status qc, complete or filed, a Q.C. history row, or any inspection).
"""
pid = row["id"]
status = str(row["status"])
out: list[str] = []
def add(codes: Iterable[Any]) -> None:
for c in codes:
if c and str(c) not in out:
out.append(str(c))
if status != "draft":
add(["WH"])
if status in IN_DEPT:
add([row["dept_code"]])
add(r[0] for r in conn.execute("SELECT dept_code FROM packet_stages WHERE packet_id = %s ORDER BY id", (pid,)))
reached_qc = status in QC_STATUSES or bool(
conn.execute(
"SELECT EXISTS (SELECT 1 FROM qc_history WHERE packet_id = %s)"
" OR EXISTS (SELECT 1 FROM qc_inspections WHERE packet_id = %s)",
(pid, pid),
).fetchone()[0]
)
if reached_qc:
add(["QC"])
return [c for c in out if c not in NEVER_VERIFY]
def active_depts(conn: Any, codes: Iterable[str]) -> set[str]:
"""The given codes that are active departments."""
want = sorted({str(c) for c in codes})
if not want:
return set()
return {r[0] for r in conn.execute("SELECT code FROM departments WHERE code = ANY(%s) AND active", (want,))}
+787
View File
@@ -0,0 +1,787 @@
"""The packet read model, the policy's PacketState, and the board cards.
`read_packet` is the full answer of GET /api/packets/{id} and of every packet mutation. `packet_state` builds the
policy's view from the same child rows, so a write and a read decide from the same facts. `list_cards` serves the
boards from header columns, v_open_cos and the open short count only.
"""
from __future__ import annotations
import uuid
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from typing import Any
from psycopg.rows import dict_row
from .. import policy
from ..api import deps
from ..api.errors import ApiError
from . import checklists
HAND_FLAG = "Made by hand - check it and send it on"
DRAWING_NEEDED_FLAG = "Drawing needed - looking it up in the vault"
DRAWING_STATES_ATTACHED = ("attached", "hand")
# Keys left out of the sealed content: they change without the packet changing (who can do what, the counter,
# the seal itself, the data book export date) or come from code and AD rather than the packet (checklist texts,
# the flag, the unit and project names, display names).
SEAL_EXCLUDE = (
"can",
"blocked",
"holder",
"version",
"log",
"seal",
"databook_at",
"checklist_items",
"qc_items",
"flag",
)
# ---------------------------------------------------------------- small helpers
def rows(conn: Any, query: str, params: Any = ()) -> list[dict[str, Any]]:
with conn.cursor(row_factory=dict_row) as cur:
return list(cur.execute(query, params).fetchall())
def one(conn: Any, query: str, params: Any = ()) -> dict[str, Any] | None:
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(query, params).fetchone()
return dict(r) if r is not None else None
def uid_text(value: Any) -> str | None:
return None if value is None else str(value)
def load_row(conn: Any, packet_id: int, *, lock: bool = False) -> dict[str, Any] | None:
"""The packets row, or None. With lock=True it is taken FOR UPDATE (inside a transaction)."""
sql = "SELECT *, status::text AS status, source_kind::text AS source_kind, routed_to::text AS routed_to,"
sql += " drawing_state::text AS drawing_state FROM packets WHERE id = %s"
if lock:
sql += " FOR UPDATE"
return one(conn, sql, (packet_id,))
def flag_of(status: str, source_kind: str, flags: Iterable[str] | None, drawing_state: str | None = None) -> str | None:
"""The draft flag shown on cards and the packet page. The old packetFlag(), phase 2 part."""
if status != "draft":
return None
if source_kind == "hand":
return HAND_FLAG
if "drawing_needed" in (flags or ()):
return DRAWING_NEEDED_FLAG
return None
# SQL for the same rule, for the list filter.
FLAG_SQL = "(p.status = 'draft' AND (p.source_kind = 'hand' OR 'drawing_needed' = ANY(p.flags)))"
def policy_principal_for(conn: Any, p: deps.Principal) -> policy.Principal:
"""The policy Principal for a session: its roles, the user's station limits and the session kind."""
limits = [r[0] for r in conn.execute("SELECT station_id FROM user_station_limits WHERE user_id = %s", (p.user_id,))]
return policy.principal_from_roles(str(p.user_id), p.roles, station_limits=limits, session_kind=p.kind)
def co_hold_why(open_cos: Iterable[Mapping[str, Any]], dept_names: Mapping[str, str]) -> str:
parts: list[str] = []
for co in open_cos:
if str(co.get("review")) == "pending":
parts.append(f"Change order {co['co_no']} is on hold.")
else:
names = ", ".join(dept_names.get(c, c) for c in co.get("pending_depts") or [])
parts.append(f"Change order {co['co_no']} is waiting for verification by {names}.")
return " ".join(parts)
def dept_names(conn: Any) -> dict[str, str]:
return {r[0]: r[1] for r in conn.execute("SELECT code, name FROM departments")}
# ---------------------------------------------------------------- the child rows
@dataclass
class Children:
dept: dict[str, Any]
station: dict[str, Any] | None
work_orders: list[dict[str, Any]] = field(default_factory=list)
pages: list[dict[str, Any]] = field(default_factory=list)
bom: list[dict[str, Any]] = field(default_factory=list)
shorts: dict[int, dict[str, Any]] = field(default_factory=dict) # bom_line_id -> open short
receipts: dict[int, list[dict[str, Any]]] = field(default_factory=dict) # short id -> receipts
welds: list[dict[str, Any]] = field(default_factory=list)
heat_tags: list[dict[str, Any]] = field(default_factory=list)
heat_table: list[dict[str, Any]] = field(default_factory=list)
stages: list[dict[str, Any]] = field(default_factory=list)
wh: dict[str, Any] | None = None
qc: dict[str, Any] | None = None
qc_history: list[dict[str, Any]] = field(default_factory=list)
change_orders: list[dict[str, Any]] = field(default_factory=list)
co_items: dict[int, list[dict[str, Any]]] = field(default_factory=dict)
co_required: dict[int, list[str]] = field(default_factory=dict)
co_verified: dict[int, list[dict[str, Any]]] = field(default_factory=dict)
open_cos: list[dict[str, Any]] = field(default_factory=list)
docs: list[dict[str, Any]] = field(default_factory=list)
seal: dict[str, Any] | None = None
dept_names: dict[str, str] = field(default_factory=dict)
@property
def open_stage(self) -> dict[str, Any] | None:
return next((s for s in self.stages if s["closed_at"] is None), None)
def load_children(conn: Any, row: Mapping[str, Any], *, full: bool = True) -> Children:
"""Every child row the read model and the policy need. full=False skips what only the read model shows."""
pid = row["id"]
dept = one(conn, "SELECT code, name, weld FROM departments WHERE code = %s", (row["dept_code"],)) or {
"code": row["dept_code"],
"name": row["dept_code"],
"weld": False,
}
station = (
one(conn, "SELECT id, name FROM stations WHERE id = %s", (row["station_id"],)) if row["station_id"] else None
)
ch = Children(dept=dept, station=station)
ch.dept_names = dept_names(conn)
ch.pages = rows(conn, "SELECT * FROM packet_pages WHERE packet_id = %s ORDER BY n", (pid,))
ch.bom = rows(conn, "SELECT * FROM bom_lines WHERE packet_id = %s ORDER BY item", (pid,))
shorts = rows(conn, "SELECT * FROM bom_shorts WHERE packet_id = %s AND closed_at IS NULL", (pid,))
ch.shorts = {s["bom_line_id"]: s for s in shorts}
ch.welds = rows(conn, "SELECT * FROM welds WHERE packet_id = %s AND removed_at IS NULL ORDER BY weld_no", (pid,))
ch.stages = rows(conn, "SELECT * FROM packet_stages WHERE packet_id = %s ORDER BY id", (pid,))
ch.qc = one(conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (pid,))
ch.open_cos = rows(
conn,
"SELECT co_id, co_no, review::text AS review, hold, pending_depts FROM v_open_cos WHERE packet_id = %s"
" ORDER BY co_no",
(pid,),
)
if not full:
return ch
ch.work_orders = rows(conn, "SELECT * FROM packet_work_orders WHERE packet_id = %s ORDER BY sort, id", (pid,))
if shorts:
for r in rows(
conn,
"SELECT * FROM short_receipts WHERE short_id = ANY(%s) ORDER BY id",
([s["id"] for s in shorts],),
):
ch.receipts.setdefault(r["short_id"], []).append(r)
ch.heat_tags = rows(
conn, "SELECT * FROM heat_tags WHERE packet_id = %s AND removed_at IS NULL ORDER BY tag_no", (pid,)
)
ch.heat_table = rows(conn, "SELECT * FROM heat_table_placements WHERE packet_id = %s ORDER BY page", (pid,))
ch.wh = one(
conn, "SELECT *, stage::text AS stage FROM packet_wh WHERE packet_id = %s AND closed_at IS NULL", (pid,)
)
ch.qc_history = rows(conn, "SELECT * FROM qc_history WHERE packet_id = %s ORDER BY id", (pid,))
ch.change_orders = rows(
conn,
"SELECT *, origin::text AS origin, review::text AS review FROM change_orders WHERE packet_id = %s"
" ORDER BY co_no",
(pid,),
)
co_ids = [c["id"] for c in ch.change_orders]
if co_ids:
for r in rows(conn, "SELECT * FROM co_items WHERE co_id = ANY(%s) ORDER BY id", (co_ids,)):
ch.co_items.setdefault(r["co_id"], []).append(r)
for r in rows(
conn, "SELECT co_id, dept_code FROM co_required_depts WHERE co_id = ANY(%s) ORDER BY dept_code", (co_ids,)
):
ch.co_required.setdefault(r["co_id"], []).append(r["dept_code"])
for r in rows(conn, "SELECT * FROM co_verifications WHERE co_id = ANY(%s) ORDER BY at", (co_ids,)):
ch.co_verified.setdefault(r["co_id"], []).append(r)
ch.docs = rows(
conn, "SELECT * FROM packet_docs WHERE packet_id = %s AND removed_at IS NULL ORDER BY added_at, id", (pid,)
)
ch.seal = one(conn, "SELECT * FROM packet_seals WHERE packet_id = %s ORDER BY id DESC LIMIT 1", (pid,))
return ch
def state_from(row: Mapping[str, Any], ch: Children) -> policy.PacketState:
weld = bool(ch.dept.get("weld"))
live_bom = [b for b in ch.bom if not b["na"]]
stage = ch.open_stage
return policy.PacketState(
id=int(row["id"]),
status=str(row["status"]),
dept=str(row["dept_code"]),
station_id=row["station_id"],
welder_id=uid_text(row["welder_id"]),
wh_skipped=bool(row["wh_skipped"]),
routed_to=row["routed_to"],
vclosed=row["vclosed_at"] is not None,
removed=row["removed_at"] is not None,
source_kind=str(row["source_kind"]),
weld_dept=weld,
co_hold=bool(ch.open_cos),
co_hold_why=co_hold_why(ch.open_cos, ch.dept_names),
open_shorts=len(ch.shorts),
drawing_attached=any(pg["type"] == "drawing" and pg["void_at"] is None for pg in ch.pages),
heat_map=row["heat_map"],
bom_total=len(live_bom),
bom_ready=sum(1 for b in live_bom if b["pulled"] or b["id"] in ch.shorts),
welds=len(ch.welds),
checklist_complete=stage is not None and checklists.checklist_complete(stage["checklist"], weld),
filler_set=stage is not None and bool((stage["filler"] or "").strip()),
qc_missing=tuple(checklists.qc_missing(row, ch.qc, ch.welds)),
has_qc_sent=ch.qc is not None,
)
def packet_state(conn: Any, row: Mapping[str, Any]) -> policy.PacketState:
"""The policy's view of a packet row, from its child rows."""
return state_from(row, load_children(conn, row, full=False))
def holder_text(state: policy.PacketState, ch: Children) -> str:
return policy.holder_name(state, ch.dept.get("name"), (ch.station or {}).get("name"))
# ---------------------------------------------------------------- shaping
class People:
"""Display names for who references. In seal mode a reference is the id only."""
def __init__(self, conn: Any, ids: Iterable[Any], *, seal: bool = False):
self.seal = seal
want = sorted({str(i) for i in ids if i is not None})
self.names: dict[str, str] = {}
if want and not seal:
for r in conn.execute(
"SELECT id, display_name FROM users WHERE id = ANY(%s)", ([uuid.UUID(i) for i in want],)
):
self.names[str(r[0])] = r[1]
def ref(self, uid: Any) -> dict[str, Any] | None:
if uid is None:
return None
if self.seal:
return {"id": str(uid)}
return {"id": str(uid), "display_name": self.names.get(str(uid))}
def _station_ref(conn: Any, station_id: Any, cache: dict[Any, dict[str, Any] | None]) -> dict[str, Any] | None:
if station_id is None:
return None
if station_id not in cache:
cache[station_id] = one(conn, "SELECT id, name FROM stations WHERE id = %s", (station_id,))
return cache[station_id]
def _stage(conn: Any, s: Mapping[str, Any], people: People, cache: dict[Any, Any], *, closed: bool) -> dict[str, Any]:
welder = people.ref(s["welder_id"])
if welder is not None:
welder["stamp"] = s["welder_stamp"]
out = {
"id": s["id"],
"dept_code": s["dept_code"],
"station": _station_ref(conn, s["station_id"], cache),
"welder": welder,
"checklist": s["checklist"] or {},
"filler": s["filler"],
"filler_lot": s["filler_lot"],
"preheat": s["preheat"],
"notes": s["notes"],
"clock": {
"elapsed_ms": int(s["clock_elapsed_ms"] or 0),
"running_since": s["clock_running_since"],
"started_at": s["clock_started_at"],
},
"opened_at": s["opened_at"],
"row_version": s["row_version"],
}
if closed:
out["closed_at"] = s["closed_at"]
return out
def _qc(q: Mapping[str, Any], people: People) -> dict[str, Any]:
keys = (
"id",
"sent_at",
"book",
"spool",
"location",
"sent_note",
"nde_method",
"nde_result",
"nde_report",
"hardness",
"nace",
"inspector",
"stamp",
"notes",
"result",
"result_at",
"result_note",
"row_version",
)
out = {k: q[k] for k in keys}
out["sent_by"] = people.ref(q["sent_by"])
out["result_by"] = people.ref(q["result_by"])
out["checks"] = q["checks"] or {}
out["weld_exam"] = q["weld_exam"] or {}
return out
def _who_ids(row: Mapping[str, Any], ch: Children) -> list[Any]:
ids: list[Any] = [row["welder_id"], row["for_sup_id"], row["vclosed_by"], row["removed_by"]]
ids += [w["welder_id"] for w in ch.welds]
ids += [s["welder_id"] for s in ch.stages]
if ch.wh:
ids.append(ch.wh["by"])
if ch.qc:
ids += [ch.qc["sent_by"], ch.qc["result_by"]]
ids += [d["added_by"] for d in ch.docs]
for c in ch.change_orders:
ids += [c["issued_by"], c["approved_by"], c["rejected_by"]]
for vs in ch.co_verified.values():
ids += [v["by"] for v in vs]
for rs in ch.receipts.values():
ids += [r["by"] for r in rs]
return ids
def _unit(conn: Any, unit_id: int, *, seal: bool) -> dict[str, Any]:
if seal:
return {"id": unit_id}
u = one(
conn,
"SELECT u.id, u.lot, u.name, u.number, u.due_effective, p.id AS project_id, p.number AS project_number,"
" p.name AS project_name, p.customer, p.po, p.status::text AS project_status"
" FROM v_units u JOIN projects p ON p.id = u.project_id WHERE u.id = %s",
(unit_id,),
)
if u is None:
return {"id": unit_id, "number": None, "lot": None, "name": None, "project": None, "due_effective": None}
return {
"id": u["id"],
"number": u["number"],
"lot": u["lot"],
"name": u["name"],
"project": {
"id": u["project_id"],
"number": u["project_number"],
"name": u["project_name"],
"customer": u["customer"],
"po": u["po"],
"status": u["project_status"],
},
"due_effective": u["due_effective"],
}
def build(
conn: Any,
row: Mapping[str, Any],
pp: policy.Principal | None,
*,
include_children: bool = True,
seal: bool = False,
) -> dict[str, Any]:
"""The read model of one loaded packets row. pp None (seal mode) leaves out can, blocked and holder."""
ch = load_children(conn, row, full=True)
state = state_from(row, ch)
people = People(conn, _who_ids(row, ch), seal=seal)
cache: dict[Any, Any] = {}
welder = people.ref(row["welder_id"])
if welder is not None:
welder["stamp"] = row["welder_stamp"]
weld = bool(ch.dept.get("weld"))
out: dict[str, Any] = {
"id": row["id"],
"version": row["version"],
"unit": _unit(conn, row["unit_id"], seal=seal),
"dept": {"code": ch.dept["code"], "name": ch.dept["name"], "weld": weld},
"op": row["op"],
"title": row["title"],
"customer": row["customer"],
"drawing": row["drawing"],
"rev": row["rev"],
"wps": row["wps"],
"status": row["status"],
"status_at": row["status_at"],
"station": ch.station,
"welder": welder,
"for_sup": people.ref(row["for_sup_id"]),
"heat_map": row["heat_map"],
"wh_skipped": row["wh_skipped"],
"wh_sent_at": row["wh_sent_at"],
"routed_to": row["routed_to"],
"return_note": row["return_note"],
"vclosed": None
if row["vclosed_at"] is None
else {"at": row["vclosed_at"], "by": people.ref(row["vclosed_by"])},
"databook_at": row["databook_at"],
"source_kind": row["source_kind"],
"source": row["source"] or {},
"drawing_state": row["drawing_state"],
"flags": list(row["flags"] or []),
"visual": row["visual"] or {},
"removed": None
if row["removed_at"] is None
else {"at": row["removed_at"], "by": people.ref(row["removed_by"]), "reason": row["removed_reason"]},
"open_cos": [c["co_no"] for c in ch.open_cos],
"open_shorts": len(ch.shorts),
}
if include_children:
out["work_orders"] = [
{
k: w[k]
for k in (
"id",
"wo_base",
"lot",
"split",
"sub",
"seq",
"qty",
"target_hrs",
"op",
"description",
"visual_status",
"gone_at",
"row_version",
)
}
for w in ch.work_orders
]
out["pages"] = [
{
"n": pg["n"],
"type": pg["type"],
"asset_id": uid_text(pg["asset_id"]),
"pn": pg["pn"],
"dwg": pg["dwg"],
"rev": pg["rev"],
"void": None
if pg["void_at"] is None
else {"co_id": pg["void_co_id"], "at": pg["void_at"], "new_n": pg["void_new_n"]},
"url": f"/_blob/{pg['asset_id']}" if pg["asset_id"] else None,
}
for pg in ch.pages
]
out["bom"] = [_bom(b, ch, people) for b in ch.bom]
out["welds"] = [_weld(w, people) for w in ch.welds]
out["heat_tags"] = [
{k: t[k] for k in ("tag_no", "page", "item", "fx", "fy", "row_version")} for t in ch.heat_tags
]
out["heat_table"] = [{k: h[k] for k in ("page", "x", "y", "s")} for h in ch.heat_table]
st = ch.open_stage
out["stage"] = _stage(conn, st, people, cache, closed=False) if st else None
out["stages"] = [_stage(conn, s, people, cache, closed=True) for s in ch.stages if s["closed_at"] is not None]
out["wh"] = (
None
if ch.wh is None
else {
"stage": ch.wh["stage"],
"by": people.ref(ch.wh["by"]),
"at": ch.wh["at"],
"reason": ch.wh["reason"],
"note": ch.wh["note"],
"auto": ch.wh["auto"],
}
)
out["qc"] = _qc(ch.qc, people) if ch.qc else None
out["qc_history"] = [
{k: h[k] for k in ("id", "at", "result", "inspector", "note", "dept_code")} for h in ch.qc_history
]
out["change_orders"] = [_co(c, ch, people) for c in ch.change_orders]
out["docs"] = [
{
"id": d["id"],
"asset_id": str(d["asset_id"]),
"kind": d["kind"],
"title": d["title"],
"added_at": d["added_at"],
"added_by": people.ref(d["added_by"]),
"url": f"/_blob/{d['asset_id']}",
}
for d in ch.docs
]
out["seal"] = (
None
if ch.seal is None
else {"sealed_at": ch.seal["sealed_at"], "key_id": ch.seal["key_id"], "broken_at": ch.seal["broken_at"]}
)
out["checklist_items"] = checklists.checklist_for(weld, row["drawing"], row["rev"], row["wps"])
out["qc_items"] = checklists.qc_items_for(row["drawing"], row["rev"], row["wps"], row["welder_stamp"])
out["flag"] = flag_of(row["status"], row["source_kind"], row["flags"], row["drawing_state"])
if pp is not None:
allowed, blocked = policy.can(pp, state)
out["can"] = [str(a) for a in allowed]
out["blocked"] = {str(a): r for a, r in blocked.items()}
out["holder"] = holder_text(state, ch)
if seal:
for k in SEAL_EXCLUDE:
out.pop(k, None)
return out
def _bom(b: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
keys = (
"id",
"item",
"qty",
"part_id",
"description",
"dwg_ref",
"cl_item",
"length",
"material",
"heat",
"na",
"issued",
"loc",
"pulled",
"src",
"row_version",
)
out = {k: b[k] for k in keys}
out["mtr_asset_id"] = uid_text(b["mtr_asset_id"])
s = ch.shorts.get(b["id"])
out["short"] = (
None
if s is None
else {
"id": s["id"],
"qty": s["qty"],
"qty_open": s["qty_open"],
"reason": s["reason"],
"ref": s["ref"],
"eta": s["eta"],
"note": s["note"],
"opened_at": s["opened_at"],
"receipts": [
{
"id": r["id"],
"qty": r["qty"],
"heat": r["heat"],
"note": r["note"],
"at": r["at"],
"by": people.ref(r["by"]),
}
for r in ch.receipts.get(s["id"], [])
],
"row_version": s["row_version"],
}
)
return out
def _weld(w: Mapping[str, Any], people: People) -> dict[str, Any]:
keys = ("weld_no", "page", "fx", "fy", "wo_base", "piece", "type", "size", "process", "stamp", "at", "row_version")
out = {k: w[k] for k in keys}
out["items"] = list(w["items"] or [])
out["welder"] = people.ref(w["welder_id"])
return out
def _co(c: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
keys = (
"id",
"co_no",
"origin",
"review",
"minor",
"hold",
"ecn",
"reason",
"issued_at",
"approved_at",
"rejected_at",
"rejected_reason",
"row_version",
)
out = {k: c[k] for k in keys}
out["issued_by"] = people.ref(c["issued_by"])
out["approved_by"] = people.ref(c["approved_by"])
out["rejected_by"] = people.ref(c["rejected_by"])
out["items"] = [
{k: i[k] for k in ("old_page", "old_dwg", "old_rev", "new_page", "new_dwg", "new_rev", "added")}
for i in ch.co_items.get(c["id"], [])
]
out["required"] = ch.co_required.get(c["id"], [])
out["verified"] = {
v["dept_code"]: {"by": people.ref(v["by"]), "at": v["at"], "note": v["note"]}
for v in ch.co_verified.get(c["id"], [])
}
return out
def read_packet(
conn: Any, packet_id: int, policy_principal: policy.Principal | None, *, include_children: bool = True
) -> dict[str, Any]:
"""The read model, or 404 not_found. A removed packet reads fine, with `removed` set."""
row = load_row(conn, packet_id)
if row is None:
raise ApiError(404, "not_found", "No such packet.")
return build(conn, row, policy_principal, include_children=include_children)
def seal_content(conn: Any, row: Mapping[str, Any]) -> dict[str, Any]:
"""What a filing seal covers: the read model without the volatile and derived keys (SEAL_EXCLUDE)."""
return build(conn, row, None, include_children=True, seal=True)
# ---------------------------------------------------------------- board cards
CARD_SQL = """
SELECT p.id, p.version, p.unit_id, u.lot, pr.number || u.lot AS unit_number, pr.id AS project_id,
pr.number AS project_number, pr.name AS project_name, p.dept_code, d.name AS dept_name, d.weld,
p.op, p.title, p.customer, p.drawing, p.rev, p.status::text AS status, p.status_at, p.station_id,
st.name AS station_name, p.welder_id, wu.display_name AS welder_name, p.welder_stamp,
p.routed_to::text AS routed_to, p.vclosed_at, p.vclosed_by, vu.display_name AS vclosed_name,
p.source_kind::text AS source_kind, p.flags, p.drawing_state::text AS drawing_state, p.wh_skipped,
p.heat_map, p.removed_at, coalesce(u.due, pr.due) AS due_effective, p.updated_at,
(SELECT w.stage::text FROM packet_wh w WHERE w.packet_id = p.id AND w.closed_at IS NULL) AS wh_stage,
(SELECT count(*) FROM bom_shorts s WHERE s.packet_id = p.id AND s.closed_at IS NULL) AS open_shorts,
(SELECT count(*) FROM packet_work_orders wo WHERE wo.packet_id = p.id) AS wo_count,
(SELECT coalesce(json_agg(json_build_object('co_no', c.co_no, 'review', c.review,
'pending_depts', c.pending_depts) ORDER BY c.co_no), '[]')
FROM v_open_cos c WHERE c.packet_id = p.id) AS open_co_rows
FROM packets p
JOIN units u ON u.id = p.unit_id
JOIN projects pr ON pr.id = u.project_id
JOIN departments d ON d.code = p.dept_code
LEFT JOIN stations st ON st.id = p.station_id
LEFT JOIN users wu ON wu.id = p.welder_id
LEFT JOIN users vu ON vu.id = p.vclosed_by
"""
FLAG_ON = ("1", "true", "yes", "any", "flagged")
FLAG_OFF = ("0", "false", "no", "none")
def _like(word: str) -> str:
return "%" + word.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
def card_state(r: Mapping[str, Any], names: Mapping[str, str]) -> policy.PacketState:
"""PacketState from header columns and counts. Child-row facts the board does not load pass (the packet
page decides them): checklist, welds, filler, BOM pulled, Q.C. items."""
cos = r["open_co_rows"] or []
return policy.PacketState(
id=int(r["id"]),
status=str(r["status"]),
dept=str(r["dept_code"]),
station_id=r["station_id"],
welder_id=uid_text(r["welder_id"]),
wh_skipped=bool(r["wh_skipped"]),
routed_to=r["routed_to"],
vclosed=r["vclosed_at"] is not None,
removed=r["removed_at"] is not None,
source_kind=str(r["source_kind"]),
weld_dept=bool(r["weld"]),
co_hold=bool(cos),
co_hold_why=co_hold_why(cos, names),
open_shorts=int(r["open_shorts"] or 0),
drawing_attached=r["drawing_state"] in DRAWING_STATES_ATTACHED,
heat_map=r["heat_map"],
bom_total=0,
bom_ready=0,
welds=1,
checklist_complete=True,
filler_set=True,
qc_missing=(),
has_qc_sent=r["status"] == "qc",
)
def list_cards(conn: Any, policy_principal: policy.Principal, filters: Mapping[str, Any]) -> list[dict[str, Any]]:
"""The board cards. Filters: status (comma list), dept, unit_id, project_id, q, flag, limit, removed."""
where: list[str] = []
params: list[Any] = []
if not filters.get("removed"):
where.append("p.removed_at IS NULL")
status = [s.strip() for s in str(filters.get("status") or "").split(",") if s.strip()]
if status:
where.append("p.status::text = ANY(%s)")
params.append(status)
if filters.get("dept"):
where.append("p.dept_code = %s")
params.append(str(filters["dept"]))
if filters.get("unit_id") is not None:
where.append("p.unit_id = %s")
params.append(int(filters["unit_id"]))
if filters.get("project_id") is not None:
where.append("u.project_id = %s")
params.append(int(filters["project_id"]))
flag = str(filters.get("flag") or "").strip().lower()
if flag in FLAG_ON:
where.append(FLAG_SQL)
elif flag in FLAG_OFF:
where.append("NOT " + FLAG_SQL)
elif flag == "hand":
where.append("(p.status = 'draft' AND p.source_kind = 'hand')")
for word in str(filters.get("q") or "").split()[:8]:
pat = _like(word)
where.append(
"(pr.number ILIKE %s OR pr.name ILIKE %s OR (pr.number || u.lot) ILIKE %s OR u.lot ILIKE %s"
" OR p.drawing ILIKE %s OR p.customer ILIKE %s OR pr.customer ILIKE %s OR pr.po ILIKE %s"
" OR p.title ILIKE %s OR EXISTS (SELECT 1 FROM packet_work_orders wo WHERE wo.packet_id = p.id"
" AND wo.wo_base ILIKE %s))"
)
params += [pat] * 10
sql = CARD_SQL + (" WHERE " + " AND ".join(where) if where else "")
sql += " ORDER BY p.updated_at DESC, p.id DESC LIMIT %s"
params.append(int(filters.get("limit") or 500))
names = dept_names(conn)
transitions = {str(a) for a in policy.TRANSITIONS}
out: list[dict[str, Any]] = []
for r in rows(conn, sql, params):
state = card_state(r, names)
allowed, blocked = policy.can(policy_principal, state)
welder = (
None
if r["welder_id"] is None
else {"id": str(r["welder_id"]), "display_name": r["welder_name"], "stamp": r["welder_stamp"]}
)
out.append(
{
"id": r["id"],
"version": r["version"],
"unit": {"id": r["unit_id"], "number": r["unit_number"]},
"project": {"id": r["project_id"], "number": r["project_number"], "name": r["project_name"]},
"dept": {"code": r["dept_code"], "name": r["dept_name"]},
"op": r["op"],
"title": r["title"],
"customer": r["customer"],
"drawing": r["drawing"],
"rev": r["rev"],
"status": r["status"],
"status_at": r["status_at"],
"station": None if r["station_id"] is None else {"id": r["station_id"], "name": r["station_name"]},
"welder": welder,
"routed_to": r["routed_to"],
"vclosed": None
if r["vclosed_at"] is None
else {
"at": r["vclosed_at"],
"by": None
if r["vclosed_by"] is None
else {"id": str(r["vclosed_by"]), "display_name": r["vclosed_name"]},
},
"source_kind": r["source_kind"],
"flag": flag_of(r["status"], r["source_kind"], r["flags"], r["drawing_state"]),
"open_cos": [c["co_no"] for c in r["open_co_rows"] or []],
"open_shorts": int(r["open_shorts"] or 0),
"wh_stage": r["wh_stage"],
"due_effective": r["due_effective"],
"updated_at": r["updated_at"],
"holder": policy.holder_name(state, r["dept_name"], r["station_name"]),
"can": [str(a) for a in allowed if str(a) in transitions],
"blocked": {str(a): v for a, v in blocked.items() if str(a) in transitions},
"wo_count": int(r["wo_count"] or 0),
"removed": r["removed_at"] is not None,
}
)
return out
+704
View File
@@ -0,0 +1,704 @@
"""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 hashlib
import hmac
import logging
from collections.abc import Iterator, Mapping, Sequence
from contextlib import contextmanager
from dataclasses import dataclass, field
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
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."
# 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 the shop",
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 the 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 = ""
@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]
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 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)})
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"]),
)
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,
),
)
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 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"])
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:
_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))
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:
_stage_update(w, "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)
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:
_set_result(w, _open_inspection(w), "rejected", reason)
_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:
key = _seal_key(w)
row = packets.load_row(w.conn, w.id)
assert row is not None
content = packets.seal_content(w.conn, row)
w.conn.execute(
"INSERT INTO packet_seals (packet_id, hash, key_id, sealed_by, signature_id) VALUES (%s, %s, %s, %s, %s)",
(w.id, seal_hash(key, content), seal_key_id(key), w.user_id, w.signature.id if w.signature else None),
)
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 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"],
}
return {"seal": shape(current) if current else None, "valid": valid, "seals": [shape(s) for s in seals]}
+876
View File
@@ -0,0 +1,876 @@
"""Projects and units: numbers, lots, aliases, renumbering, field CAS, soft delete and packet counts.
Ports the old projects.js rules. A unit's number is its project's number plus its lot. Every old number
of a project or unit stays in project_aliases, so a lookup by any number still finds it. Packets point at
units by id, so a renumber never touches a packet. Numbers and lots are text: never cast them.
"""
from __future__ import annotations
import re
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from datetime import date
from typing import Any
from psycopg import errors as pgerr
from psycopg.rows import dict_row
from .. import audit, events
from ..api.errors import ApiError
from ..text import LIMITS, RX_LOT, RX_PROJECT, clean_opt, clean_text
STATUSES = ("open", "hold", "closed")
LIST_FILTERS = ("active", "open", "hold", "closed", "all")
SHOP_STATUSES = ("ready", "assigned", "in_progress", "review")
COUNT_KEYS = ("draft", "warehouse", "shop", "qc", "complete", "filed", "total")
MAX_UNITS_AT_ONCE = 200
MAX_WORDS = 8
LIST_LIMIT = 500
PROJECT_FIELDS = ("name", "customer", "po", "due", "status", "notes")
UNIT_FIELDS = ("name", "due", "status", "notes")
_DIGITS = re.compile(r"^[0-9]+$")
_ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
COUNTS_SQL = (
"count(*) FILTER (WHERE k.status = 'draft') AS c_draft,"
" count(*) FILTER (WHERE k.status = 'warehouse') AS c_warehouse,"
" count(*) FILTER (WHERE k.status IN ('ready', 'assigned', 'in_progress', 'review')) AS c_shop,"
" count(*) FILTER (WHERE k.status = 'qc') AS c_qc,"
" count(*) FILTER (WHERE k.status = 'complete') AS c_complete,"
" count(*) FILTER (WHERE k.status = 'filed') AS c_filed,"
" count(*) AS c_total"
)
PROJECT_SELECT = (
"SELECT p.*," # noqa: S608
" (SELECT count(*) FROM units u WHERE u.project_id = p.id AND u.removed_at IS NULL) AS unit_count,"
" c.*"
" FROM projects p"
" LEFT JOIN LATERAL (SELECT " + COUNTS_SQL + " FROM packets k JOIN units u ON u.id = k.unit_id"
" WHERE u.project_id = p.id AND k.removed_at IS NULL) c ON true"
)
UNIT_SELECT = (
"SELECT v.*, c.* FROM v_units v" # noqa: S608
" LEFT JOIN LATERAL (SELECT " + COUNTS_SQL + " FROM packets k"
" WHERE k.unit_id = v.id AND k.removed_at IS NULL) c ON true"
)
@dataclass(frozen=True)
class Who:
"""Who is writing, for the audit row. it_reason is set only when an admin acts outside their roles."""
user_id: Any
session_id: Any = None
role_snapshot: str | None = None
ip: str | None = None
it_reason: str | None = None
# ---------------------------------------------------------------- normalisation
def normalize_number(raw: Any) -> str:
"""A project number: cleaned, upper-cased, RX_PROJECT. 422 invalid_number otherwise."""
n = clean_text(raw, 200).upper()
if not RX_PROJECT.match(n):
raise ApiError(
422,
"invalid_number",
"A project number is 1 to 20 letters, digits or dashes, starting with a letter or digit.",
)
return n
def normalize_lot(raw: Any) -> str:
"""A unit lot: upper-cased, digits zero-padded to 3. `"7"` -> `"007"`, `"7a"` -> `"7A"`. 422 invalid_lot."""
lot = clean_text(raw, 40).upper()
if _DIGITS.match(lot) and len(lot) < 3:
lot = lot.zfill(3)
if not RX_LOT.match(lot) or (_DIGITS.match(lot) and len(lot) != 3):
raise ApiError(422, "invalid_lot", f"Lot {lot or '(blank)'}: 1 to 3 letters or digits, numbers as 001 to 999.")
return lot
def lot_input(project_number: str, raw: Any) -> str:
"""A lot as typed: a full unit number that starts with the project number is cut down to its lot."""
text = clean_text(raw, 40).upper()
pn = project_number.upper()
if len(text) > 3 and text.startswith(pn) and len(text) > len(pn):
text = text[len(pn) :]
return normalize_lot(text)
def parse_due(raw: Any) -> date | None:
if raw is None or (isinstance(raw, str) and not raw.strip()):
return None
if isinstance(raw, date):
return raw
text = str(raw).strip()
try:
if not _ISO_DATE.match(text):
raise ValueError(text)
return date.fromisoformat(text)
except ValueError:
raise ApiError(422, "invalid_date", "Give the due date as YYYY-MM-DD.") from None
def parse_status(raw: Any) -> str:
s = clean_text(raw, 20).lower()
if s not in STATUSES:
raise ApiError(422, "invalid_status", "Status is open, hold or closed.")
return s
def clean_reason(raw: Any) -> str:
text = clean_text(raw, LIMITS["reason"])
if not text:
raise ApiError(400, "reason_required", "Give a reason.")
return text
def _like(word: str) -> str:
return "%" + word.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
def _words(q: str | None) -> list[str]:
return [w for w in clean_text(q, 200).split() if w][:MAX_WORDS]
def _iso(v: Any) -> Any:
return v.isoformat() if isinstance(v, date) else v
def _counts(r: Mapping[str, Any]) -> dict[str, int]:
return {k: int(r.get("c_" + k) or 0) for k in COUNT_KEYS}
def _removed(r: Mapping[str, Any]) -> dict[str, Any] | None:
if r.get("removed_at") is None:
return None
by = r.get("removed_by")
return {"at": r["removed_at"], "by": str(by) if by else None, "reason": r.get("removed_reason")}
# ---------------------------------------------------------------- shapes
def project_out(r: Mapping[str, Any]) -> dict[str, Any]:
return {
"id": r["id"],
"number": r["number"],
"name": r["name"],
"customer": r["customer"],
"po": r["po"],
"due": r["due"],
"status": r["status"],
"notes": r["notes"],
"source": r["source"],
"needs_details": r["needs_details"],
"units": int(r.get("unit_count") or 0),
"packets": _counts(r),
"version": r["version"],
"created_at": r["created_at"],
"updated_at": r["updated_at"],
"removed": _removed(r),
}
def unit_out(r: Mapping[str, Any]) -> dict[str, Any]:
status = r["status"]
return {
"id": r["id"],
"project_id": r["project_id"],
"number": r["number"],
"lot": r["lot"],
"name": r["name"],
"due": r["due"],
"due_effective": r["due_effective"],
"status": status,
"status_effective": "closed" if r["project_status"] == "closed" else status,
"notes": r["notes"],
"packets": _counts(r),
"version": r["version"],
"created_at": r["created_at"],
"updated_at": r["updated_at"],
"removed": _removed(r),
}
# ---------------------------------------------------------------- lookups
def owners(conn: Any, number: str) -> list[dict[str, Any]]:
"""Everything that answers to `number`: live project and unit numbers first, then aliases of live ones."""
n = clean_text(number, 40).upper()
if not n:
return []
with conn.cursor(row_factory=dict_row) as cur:
rows: list[dict[str, Any]] = cur.execute(
"SELECT * FROM ("
" SELECT 0 AS rank, 'project' AS kind, p.id, p.id AS project_id, p.number, false AS alias"
" FROM projects p WHERE upper(p.number) = %(n)s AND p.removed_at IS NULL"
" UNION ALL"
" SELECT 1, 'unit', u.id, u.project_id, p.number || u.lot, false"
" FROM units u JOIN projects p ON p.id = u.project_id"
" WHERE upper(p.number || u.lot) = %(n)s AND u.removed_at IS NULL AND p.removed_at IS NULL"
" UNION ALL"
" SELECT 2, CASE WHEN a.unit_id IS NULL THEN 'project' ELSE 'unit' END, coalesce(a.unit_id, a.project_id),"
" a.project_id, CASE WHEN a.unit_id IS NULL THEN p.number ELSE p.number || u.lot END, true"
" FROM project_aliases a JOIN projects p ON p.id = a.project_id AND p.removed_at IS NULL"
" LEFT JOIN units u ON u.id = a.unit_id"
" WHERE a.alias = %(n)s AND (a.unit_id IS NULL OR u.removed_at IS NULL)"
") x ORDER BY rank, id",
{"n": n},
).fetchall()
return rows
def lookup(conn: Any, number: str) -> dict[str, Any] | None:
"""`{kind, id, project_id, number, alias}` for a current or old number, or None."""
found = owners(conn, number)
if not found:
return None
o = found[0]
return {"kind": o["kind"], "id": o["id"], "project_id": o["project_id"], "number": o["number"], "alias": o["alias"]}
def _taken(o: Mapping[str, Any]) -> ApiError:
if o["alias"]:
msg = f"That number is an old number of {o['number']}. Pick another number."
elif o["kind"] == "unit":
msg = f"{o['number']} already exists as a unit."
else:
msg = f"{o['number']} already exists as a project."
return ApiError(409, "number_taken", msg, extra={"taken": {"kind": o["kind"], "id": o["id"], "alias": o["alias"]}})
def assert_free(conn: Any, number: str, *, own_project: int | None = None, own_unit: int | None = None) -> None:
"""409 number_taken unless nothing answers to `number`, or only the entity's own old number does."""
for o in owners(conn, number):
if o["alias"] and own_unit is not None and o["kind"] == "unit" and o["id"] == own_unit:
continue
if o["alias"] and own_project is not None and o["kind"] == "project" and o["id"] == own_project:
continue
raise _taken(o)
def next_lot(conn: Any, project_id: int, number: str) -> str | None:
"""The old nextUnitNumber: highest numeric lot plus one, skipping numbers already used (old ones too)."""
rows = conn.execute(
"SELECT lot FROM units WHERE project_id = %s AND removed_at IS NULL AND lot ~ '^[0-9]{3}$'", (project_id,)
).fetchall()
n = max((int(r[0]) for r in rows), default=0) + 1
while n <= 999:
lot = f"{n:03d}"
if not owners(conn, number + lot):
return lot
n += 1
return None
def _unique_taken(e: Exception) -> ApiError:
return ApiError(409, "number_taken", "That number was just taken. Pick another number.")
# ---------------------------------------------------------------- reads
def get_project(conn: Any, project_id: int, *, removed: bool = False) -> dict[str, Any] | None:
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(PROJECT_SELECT + " WHERE p.id = %s", (project_id,)).fetchone()
if r is None or (r["removed_at"] is not None and not removed):
return None
return project_out(r)
def _project_or_404(conn: Any, project_id: int) -> dict[str, Any]:
out = get_project(conn, project_id)
if out is None:
raise ApiError(404, "not_found", "No such project.")
return out
def aliases_of(conn: Any, *, project_id: int | None = None, unit_id: int | None = None) -> list[dict[str, Any]]:
with conn.cursor(row_factory=dict_row) as cur:
if unit_id is not None:
rows = cur.execute(
"SELECT alias, kind, unit_id, created_at FROM project_aliases WHERE unit_id = %s"
" ORDER BY created_at, alias",
(unit_id,),
).fetchall()
else:
rows = cur.execute(
"SELECT alias, kind, unit_id, created_at FROM project_aliases WHERE project_id = %s"
" ORDER BY created_at, alias",
(project_id,),
).fetchall()
return [dict(r) for r in rows]
def project_detail(conn: Any, project_id: int) -> dict[str, Any]:
out = _project_or_404(conn, project_id)
with conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute(
UNIT_SELECT + " WHERE v.project_id = %s AND v.removed_at IS NULL ORDER BY v.lot", (project_id,)
).fetchall()
out["units"] = [unit_out(r) for r in rows]
out["unit_count"] = len(rows)
out["aliases"] = aliases_of(conn, project_id=project_id)
out["next_lot"] = next_lot(conn, project_id, out["number"])
return out
def list_projects(conn: Any, *, q: str | None = None, status: str = "active") -> list[dict[str, Any]]:
if status not in LIST_FILTERS:
raise ApiError(422, "invalid_status", "Status filter is active, open, hold, closed or all.")
where = ["p.removed_at IS NULL"]
params: list[Any] = []
if status == "active":
where.append("p.status <> 'closed'")
elif status != "all":
where.append("p.status = %s")
params.append(status)
for w in _words(q):
where.append(
"(p.number ILIKE %s OR p.name ILIKE %s OR p.customer ILIKE %s OR p.po ILIKE %s"
" OR EXISTS (SELECT 1 FROM project_aliases a WHERE a.project_id = p.id AND a.alias ILIKE %s))"
)
pat = _like(w)
params.extend([pat, pat, pat, pat, pat])
sql = PROJECT_SELECT + " WHERE " + " AND ".join(where) + f" ORDER BY p.number LIMIT {LIST_LIMIT}"
with conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute(sql, params).fetchall()
return [project_out(r) for r in rows]
def get_unit(conn: Any, unit_id: int, *, removed: bool = False) -> dict[str, Any] | None:
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(UNIT_SELECT + " WHERE v.id = %s", (unit_id,)).fetchone()
if r is None or (r["removed_at"] is not None and not removed):
return None
return unit_out(r)
def _unit_or_404(conn: Any, unit_id: int) -> dict[str, Any]:
out = get_unit(conn, unit_id)
if out is None:
raise ApiError(404, "not_found", "No such unit.")
return out
def unit_detail(conn: Any, unit_id: int) -> dict[str, Any]:
out = _unit_or_404(conn, unit_id)
p = get_project(conn, out["project_id"])
if p is None:
raise ApiError(404, "not_found", "No such unit.")
out["project"] = {k: p[k] for k in ("id", "number", "name", "customer", "po", "due", "status", "version")}
out["aliases"] = aliases_of(conn, unit_id=unit_id)
return out
def list_units(
conn: Any, *, project_id: int | None = None, q: str | None = None, status: str = "active"
) -> list[dict[str, Any]]:
"""Units for the picker's second step. Status applies to the effective status (a closed project closes all)."""
if status not in LIST_FILTERS:
raise ApiError(422, "invalid_status", "Status filter is active, open, hold, closed or all.")
eff = "(CASE WHEN v.project_status = 'closed' THEN 'closed' ELSE v.status::text END)"
where = [
"v.removed_at IS NULL",
"EXISTS (SELECT 1 FROM projects p WHERE p.id = v.project_id AND p.removed_at IS NULL)",
]
params: list[Any] = []
if project_id is not None:
where.append("v.project_id = %s")
params.append(project_id)
if status == "active":
where.append(f"{eff} <> 'closed'")
elif status != "all":
where.append(f"{eff} = %s")
params.append(status)
for w in _words(q):
where.append(
"(v.number ILIKE %s OR v.lot ILIKE %s OR v.name ILIKE %s"
" OR EXISTS (SELECT 1 FROM project_aliases a WHERE a.unit_id = v.id AND a.alias ILIKE %s))"
)
pat = _like(w)
params.extend([pat, pat, pat, pat])
sql = UNIT_SELECT + " WHERE " + " AND ".join(where) + f" ORDER BY v.project_number, v.lot LIMIT {LIST_LIMIT}"
with conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute(sql, params).fetchall()
return [unit_out(r) for r in rows]
# ---------------------------------------------------------------- writes: helpers
def _audit(conn: Any, who: Who, action: str, subject_type: str, subject_id: Any, detail: Any) -> None:
audit.append(
conn,
action=action,
user_id=who.user_id,
session_id=who.session_id,
role_snapshot=who.role_snapshot,
subject_type=subject_type,
subject_id=subject_id,
detail=detail,
it_reason=who.it_reason,
ip=who.ip,
)
def _publish(conn: Any, project_id: int, unit_id: int | None = None, action: str = "") -> None:
payload: dict[str, Any] = {"event": "changed", "kind": "project", "project_id": project_id, "action": action}
if unit_id is not None:
payload["unit_id"] = unit_id
events.publish(conn, payload)
def _lock_project(conn: Any, project_id: int) -> dict[str, Any]:
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(
"SELECT * FROM projects WHERE id = %s AND removed_at IS NULL FOR UPDATE", (project_id,)
).fetchone()
if r is None:
raise ApiError(404, "not_found", "No such project.")
return dict(r)
def _lock_unit(conn: Any, unit_id: int) -> tuple[dict[str, Any], dict[str, Any]]:
"""Lock the unit's project, then the unit (always in that order). Returns (unit, project)."""
row = conn.execute("SELECT project_id FROM units WHERE id = %s AND removed_at IS NULL", (unit_id,)).fetchone()
if row is None:
raise ApiError(404, "not_found", "No such unit.")
try:
project = _lock_project(conn, int(row[0]))
except ApiError:
raise ApiError(404, "not_found", "No such unit.") from None
with conn.cursor(row_factory=dict_row) as cur:
u = cur.execute("SELECT * FROM units WHERE id = %s AND removed_at IS NULL FOR UPDATE", (unit_id,)).fetchone()
if u is None:
raise ApiError(404, "not_found", "No such unit.")
return dict(u), project
def _cmp(v: Any) -> Any:
if v is None:
return None
if isinstance(v, date):
return v.isoformat()
text = str(v).strip()
return text or None
def _norm_field(key: str, value: Any, *, unit: bool) -> Any:
if key == "due":
return parse_due(value)
if key == "status":
return parse_status(value)
if key == "name":
return clean_opt(value, LIMITS["unit_name"] if unit else LIMITS["project_name"])
if key == "customer":
return clean_opt(value, LIMITS["customer"])
if key == "po":
return clean_opt(value, LIMITS["po"])
if key == "notes":
return clean_opt(value, LIMITS["notes"])
raise ApiError(422, "invalid_field", f"{key} cannot be changed here.")
def field_cas(
row: Mapping[str, Any], values: Mapping[str, Any], base: Mapping[str, Any], fields: Iterable[str], *, unit: bool
) -> dict[str, Any]:
"""The field-level CAS: returns {key: new value} for the keys that change; 409 field_conflict on a stale base.
A key whose base differs from the current value conflicts, unless the new value already equals the current one.
Keys without a base entry are written without a check.
"""
allowed = set(fields)
bad = [k for k in values if k not in allowed]
if bad:
raise ApiError(422, "invalid_field", f"{', '.join(sorted(bad))} cannot be changed here.")
new = {k: _norm_field(k, v, unit=unit) for k, v in values.items()}
conflicts = {
k: _iso(row[k]) for k in new if k in base and _cmp(base[k]) != _cmp(row[k]) and _cmp(new[k]) != _cmp(row[k])
}
if conflicts:
raise ApiError(
409,
"field_conflict",
"Someone else changed this since you opened it. Check the new values.",
extra={"current": conflicts},
)
return {k: v for k, v in new.items() if _cmp(v) != _cmp(row[k])}
def _change_detail(row: Mapping[str, Any], changes: Mapping[str, Any]) -> dict[str, Any]:
out: dict[str, Any] = {}
for k, v in changes.items():
out[k] = {"changed": True} if k == "notes" else {"from": _iso(row[k]), "to": _iso(v)}
return out
# ---------------------------------------------------------------- writes: projects
def create_project(conn: Any, who: Who, data: Mapping[str, Any]) -> dict[str, Any]:
number = normalize_number(data.get("number"))
name = clean_opt(data.get("name"), LIMITS["project_name"])
customer = clean_opt(data.get("customer"), LIMITS["customer"])
po = clean_opt(data.get("po"), LIMITS["po"])
notes = clean_opt(data.get("notes"), LIMITS["notes"])
due = parse_due(data.get("due"))
status = parse_status(data.get("status") or "open")
try:
with conn.transaction():
conn.execute("SELECT pg_advisory_xact_lock(7312)") # one number allocation at a time
assert_free(conn, number)
pid = conn.execute(
"INSERT INTO projects (number, name, customer, customer_src, po, po_src, due, status, notes, source,"
" created_by) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'hand', %s) RETURNING id",
(
number,
name,
customer,
"hand" if customer else None,
po,
"hand" if po else None,
due,
status,
notes,
who.user_id,
),
).fetchone()[0]
_audit(
conn,
who,
"project.created",
"project",
pid,
{"number": number, "name": name, "customer": customer, "po": po, "due": _iso(due), "status": status},
)
_publish(conn, pid, action="project.created")
except pgerr.UniqueViolation as e:
raise _unique_taken(e) from None
return _project_or_404(conn, pid)
def update_project(
conn: Any, who: Who, project_id: int, values: Mapping[str, Any], base: Mapping[str, Any]
) -> dict[str, Any]:
with conn.transaction():
row = _lock_project(conn, project_id)
changes = field_cas(row, values, base, PROJECT_FIELDS, unit=False)
if changes:
cols = dict(changes)
if "customer" in cols:
cols["customer_src"] = "hand" if cols["customer"] else None
if "po" in cols:
cols["po_src"] = "hand" if cols["po"] else None
sets = ", ".join(f"{k} = %s" for k in cols)
conn.execute(
f"UPDATE projects SET {sets}, version = version + 1, updated_at = now() WHERE id = %s", # noqa: S608
[*cols.values(), project_id],
)
_audit(
conn,
who,
"project.updated",
"project",
project_id,
{"number": row["number"], "fields": _change_detail(row, changes)},
)
_publish(conn, project_id, action="project.updated")
return _project_or_404(conn, project_id)
def renumber_project(conn: Any, who: Who, project_id: int, number: Any, reason: Any) -> dict[str, Any]:
new = normalize_number(number)
why = clean_reason(reason)
try:
with conn.transaction():
conn.execute("SELECT pg_advisory_xact_lock(7312)")
row = _lock_project(conn, project_id)
old = row["number"]
if new == old.upper():
raise ApiError(400, "same_number", f"The project is already {old}.")
units = conn.execute(
"SELECT id, lot FROM units WHERE project_id = %s AND removed_at IS NULL ORDER BY lot FOR UPDATE",
(project_id,),
).fetchall()
assert_free(conn, new, own_project=project_id)
for uid, lot in units:
for o in owners(conn, new + lot):
own_live = o["kind"] == "unit" and not o["alias"] and o["project_id"] == project_id
own_old = o["kind"] == "unit" and o["alias"] and o["id"] == uid
if not (own_live or own_old):
raise _taken(o)
old_numbers = {old.upper(): None, **{(old + lot).upper(): uid for uid, lot in units}}
new_numbers = [new, *[new + lot for _, lot in units]]
if set(old_numbers) & set(new_numbers):
raise ApiError(409, "number_taken", "The new numbers overlap the old ones. Pick another number.")
conn.execute(
"DELETE FROM project_aliases WHERE alias = ANY(%s) AND project_id = %s", (new_numbers, project_id)
)
for alias, uid in old_numbers.items():
conn.execute(
"INSERT INTO project_aliases (alias, project_id, unit_id, kind) VALUES (%s, %s, %s, 'renumber')"
" ON CONFLICT (alias) DO UPDATE SET project_id = EXCLUDED.project_id, unit_id = EXCLUDED.unit_id,"
" kind = EXCLUDED.kind, created_at = now()",
(alias, project_id, uid),
)
conn.execute(
"UPDATE projects SET number = %s, version = version + 1, updated_at = now() WHERE id = %s",
(new, project_id),
)
conn.execute(
"INSERT INTO project_renumbers (by, project_id, unit_id, old_number, new_number, reason)"
" VALUES (%s, %s, NULL, %s, %s, %s)",
(who.user_id, project_id, old, new, why),
)
for uid, lot in units:
conn.execute(
"INSERT INTO project_renumbers (by, project_id, unit_id, old_number, new_number, reason)"
" VALUES (%s, %s, %s, %s, %s, %s)",
(who.user_id, project_id, uid, old + lot, new + lot, why),
)
_audit(
conn,
who,
"project.renumbered",
"project",
project_id,
{"from": old, "to": new, "reason": why, "units": len(units)},
)
_publish(conn, project_id, action="project.renumbered")
except pgerr.UniqueViolation as e:
raise _unique_taken(e) from None
return _project_or_404(conn, project_id)
def _live_packets(conn: Any, unit_ids: list[int]) -> int:
if not unit_ids:
return 0
return int(
conn.execute(
"SELECT count(*) FROM packets WHERE unit_id = ANY(%s) AND removed_at IS NULL", (unit_ids,)
).fetchone()[0]
)
def remove_project(conn: Any, who: Who, project_id: int, reason: Any) -> dict[str, Any]:
"""Soft delete with its units. Refused (409 has_packets) while any of its units has a live packet."""
why = clean_reason(reason)
with conn.transaction():
row = _lock_project(conn, project_id)
ids = [
int(r[0])
for r in conn.execute(
"SELECT id FROM units WHERE project_id = %s AND removed_at IS NULL ORDER BY id FOR UPDATE",
(project_id,),
).fetchall()
]
n = _live_packets(conn, ids)
if n:
raise ApiError(
409,
"has_packets",
f"{row['number']} still has {n} packet{'' if n == 1 else 's'}. Move or remove them first.",
extra={"packets": n},
)
conn.execute(
"UPDATE units SET removed_at = now(), removed_by = %s, removed_reason = %s, version = version + 1,"
" updated_at = now() WHERE id = ANY(%s)",
(who.user_id, why, ids),
)
conn.execute(
"UPDATE projects SET removed_at = now(), removed_by = %s, removed_reason = %s, version = version + 1,"
" updated_at = now() WHERE id = %s",
(who.user_id, why, project_id),
)
_audit(
conn,
who,
"project.removed",
"project",
project_id,
{"number": row["number"], "reason": why, "units": ids},
)
_publish(conn, project_id, action="project.removed")
out = get_project(conn, project_id, removed=True)
assert out is not None
return out
# ---------------------------------------------------------------- writes: units
def lots_from_request(
project_number: str,
*,
lot: Any = None,
lots: list[Any] | None = None,
count: int | None = None,
start: Any = None,
suggest: str | None = None,
) -> list[str]:
"""The unit sheet's one-or-several mode: one lot, a list of lots, or `count` lots counting up from `start`."""
if lots:
out = [lot_input(project_number, x) for x in lots]
elif count is not None:
if not 1 <= count <= MAX_UNITS_AT_ONCE:
raise ApiError(422, "invalid_count", f"How many: 1 to {MAX_UNITS_AT_ONCE}.")
first = lot_input(project_number, start) if start not in (None, "") else suggest
if first is None or not _DIGITS.match(first):
raise ApiError(422, "invalid_lot", "The starting lot has to be a number, e.g. 001.")
b = int(first)
if b + count - 1 > 999:
raise ApiError(422, "invalid_lot", "Lots go up to 999. Start lower or add fewer.")
out = [f"{b + i:03d}" for i in range(count)]
elif lot not in (None, ""):
out = [lot_input(project_number, lot)]
else:
if suggest is None:
raise ApiError(422, "invalid_lot", "No free lot left in this project. Give one.")
out = [suggest]
if len(out) > MAX_UNITS_AT_ONCE:
raise ApiError(422, "invalid_count", f"How many: 1 to {MAX_UNITS_AT_ONCE}.")
dup = sorted({x for x in out if out.count(x) > 1})
if dup:
raise ApiError(409, "number_taken", f"Lot {dup[0]} is in the list twice.", extra={"lots": dup})
return out
def create_units(conn: Any, who: Who, project_id: int, data: Mapping[str, Any]) -> list[dict[str, Any]]:
name = clean_opt(data.get("name"), LIMITS["unit_name"])
notes = clean_opt(data.get("notes"), LIMITS["notes"])
due = parse_due(data.get("due"))
status = parse_status(data.get("status") or "open")
try:
with conn.transaction():
conn.execute("SELECT pg_advisory_xact_lock(7312)")
project = _lock_project(conn, project_id)
if project["status"] == "closed":
raise ApiError(409, "project_closed", f"Project {project['number']} is closed. Reopen it to add units.")
pn = project["number"]
wants_suggest = not data.get("lots") and data.get("lot") in (None, "") and data.get("start") in (None, "")
lots = lots_from_request(
pn,
lot=data.get("lot"),
lots=data.get("lots"),
count=data.get("count"),
start=data.get("start"),
suggest=next_lot(conn, project_id, pn) if wants_suggest else None,
)
for lot in lots:
assert_free(conn, pn + lot)
ids: list[int] = []
for lot in lots:
uid = conn.execute(
"INSERT INTO units (project_id, lot, name, due, status, notes, created_by)"
" VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id",
(project_id, lot, name, due, status, notes, who.user_id),
).fetchone()[0]
ids.append(int(uid))
_audit(
conn,
who,
"unit.created",
"unit",
uid,
{"number": pn + lot, "project_id": project_id, "lot": lot, "name": name, "due": _iso(due)},
)
_publish(conn, project_id, action="unit.created")
except pgerr.UniqueViolation as e:
raise _unique_taken(e) from None
with conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute(UNIT_SELECT + " WHERE v.id = ANY(%s) ORDER BY v.lot", (ids,)).fetchall()
return [unit_out(r) for r in rows]
def update_unit(
conn: Any, who: Who, unit_id: int, values: Mapping[str, Any], base: Mapping[str, Any]
) -> dict[str, Any]:
with conn.transaction():
row, project = _lock_unit(conn, unit_id)
changes = field_cas(row, values, base, UNIT_FIELDS, unit=True)
if changes:
sets = ", ".join(f"{k} = %s" for k in changes)
conn.execute(
f"UPDATE units SET {sets}, version = version + 1, updated_at = now() WHERE id = %s", # noqa: S608
[*changes.values(), unit_id],
)
_audit(
conn,
who,
"unit.updated",
"unit",
unit_id,
{"number": project["number"] + row["lot"], "fields": _change_detail(row, changes)},
)
_publish(conn, project["id"], unit_id, action="unit.updated")
return _unit_or_404(conn, unit_id)
def renumber_unit(conn: Any, who: Who, unit_id: int, lot: Any, reason: Any) -> dict[str, Any]:
why = clean_reason(reason)
try:
with conn.transaction():
conn.execute("SELECT pg_advisory_xact_lock(7312)")
row, project = _lock_unit(conn, unit_id)
pn = project["number"]
new_lot = lot_input(pn, lot)
if new_lot == row["lot"]:
raise ApiError(400, "same_number", f"The unit is already {pn + new_lot}.")
old, new = pn + row["lot"], pn + new_lot
assert_free(conn, new, own_unit=unit_id)
conn.execute("DELETE FROM project_aliases WHERE alias = %s AND unit_id = %s", (new.upper(), unit_id))
conn.execute(
"INSERT INTO project_aliases (alias, project_id, unit_id, kind) VALUES (%s, %s, %s, 'unit_renumber')"
" ON CONFLICT (alias) DO UPDATE SET project_id = EXCLUDED.project_id, unit_id = EXCLUDED.unit_id,"
" kind = EXCLUDED.kind, created_at = now()",
(old.upper(), project["id"], unit_id),
)
conn.execute(
"UPDATE units SET lot = %s, version = version + 1, updated_at = now() WHERE id = %s", (new_lot, unit_id)
)
conn.execute(
"INSERT INTO project_renumbers (by, project_id, unit_id, old_number, new_number, reason)"
" VALUES (%s, %s, %s, %s, %s, %s)",
(who.user_id, project["id"], unit_id, old, new, why),
)
_audit(conn, who, "unit.renumbered", "unit", unit_id, {"from": old, "to": new, "reason": why})
_publish(conn, project["id"], unit_id, action="unit.renumbered")
except pgerr.UniqueViolation as e:
raise _unique_taken(e) from None
return _unit_or_404(conn, unit_id)
def remove_unit(conn: Any, who: Who, unit_id: int, reason: Any) -> dict[str, Any]:
why = clean_reason(reason)
with conn.transaction():
row, project = _lock_unit(conn, unit_id)
n = _live_packets(conn, [unit_id])
number = project["number"] + row["lot"]
if n:
raise ApiError(
409,
"has_packets",
f"{number} still has {n} packet{'' if n == 1 else 's'}. Move or remove them first.",
extra={"packets": n},
)
conn.execute(
"UPDATE units SET removed_at = now(), removed_by = %s, removed_reason = %s, version = version + 1,"
" updated_at = now() WHERE id = %s",
(who.user_id, why, unit_id),
)
_audit(conn, who, "unit.removed", "unit", unit_id, {"number": number, "reason": why})
_publish(conn, project["id"], unit_id, action="unit.removed")
out = get_unit(conn, unit_id, removed=True)
assert out is not None
return out