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:
@@ -0,0 +1 @@
|
||||
"""The core domain: packets, projects and units, documents, the write pipeline. Pure SQL helpers over one connection."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,))}
|
||||
@@ -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
|
||||
@@ -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]}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user