"""Packets: the boards, the read model, the hand draft, header edits, transitions, pages, documents, move, remove, the VISUAL close, the data book date, reopen, the seal and the log. Every write goes through domain.pipeline.packet_write: one transaction, the packet locked first, the policy decides. Every mutating answer is the full read model. """ from __future__ import annotations import uuid from decimal import Decimal from typing import Any, Literal from fastapi import APIRouter, Depends, Query, Request from psycopg.types.json import Jsonb from pydantic import BaseModel, Field from .. import audit, events, policy from ..domain import assets as asset_store from ..domain import packets from ..domain.pipeline import ( Expect, SignBody, apply_field_cas, apply_transition, next_counter, packet_write, require_if_match, verify_seal, ) from ..text import LIMITS, MAX_BODY_BYTES, clean_opt, clean_text, reason_ok from .deps import Db, Principal, current_principal, get_db, settings_of from .errors import ApiError router = APIRouter(prefix="/api/packets", tags=["packets"]) A = policy.Action MSG_FORBIDDEN = "Your access doesn't allow this step." WO_PART_MAX = 40 def body_limit(request: Request) -> None: """413 too_large for a JSON body over 200 000 bytes, by its Content-Length, before it is read.""" length = request.headers.get("content-length") if length is not None and length.strip().isdigit() and int(length) > MAX_BODY_BYTES: raise ApiError(413, "too_large", "The request is too large.") WRITE = [Depends(body_limit)] # ---------------------------------------------------------------- bodies class WorkOrderIn(BaseModel): wo_base: str = Field(min_length=1, max_length=WO_PART_MAX) lot: str = Field(default="", max_length=WO_PART_MAX) split: str = Field(default="", max_length=WO_PART_MAX) sub: str = Field(default="", max_length=WO_PART_MAX) seq: int | None = None qty: Decimal | None = None target_hrs: Decimal | None = None op: str | None = Field(default=None, max_length=200) description: str | None = Field(default=None, max_length=1000) class BomIn(BaseModel): item: int = Field(ge=1, le=100_000) qty: Decimal | None = None part_id: str | None = Field(default=None, max_length=200) description: str | None = Field(default=None, max_length=1000) material: str | None = Field(default=None, max_length=500) length: str | None = Field(default=None, max_length=200) dwg_ref: str | None = Field(default=None, max_length=200) class CreateBody(BaseModel): unit_id: int dept_code: str = Field(max_length=10) op: str | None = Field(default=None, max_length=200) title: str | None = Field(default=None, max_length=1000) customer: str | None = Field(default=None, max_length=500) drawing: str | None = Field(default=None, max_length=200) rev: str | None = Field(default=None, max_length=100) wps: str | None = Field(default=None, max_length=200) heat_map: bool | None = None work_orders: list[WorkOrderIn] = Field(default_factory=list, max_length=300) bom: list[BomIn] = Field(default_factory=list, max_length=1000) it_reason: str | None = Field(default=None, max_length=1000) class PatchBody(BaseModel): set: dict[str, Any] base: dict[str, Any] = Field(default_factory=dict) it_reason: str | None = Field(default=None, max_length=1000) class TransitionBody(BaseModel): action: str = Field(max_length=40) expect: Expect reason: str | None = Field(default=None, max_length=2000) note: str | None = Field(default=None, max_length=2000) dept: str | None = Field(default=None, max_length=10) station_id: int | None = None welder_id: uuid.UUID | None = None stamp: str | None = Field(default=None, max_length=40) routed_to: Literal["databook", "shipping"] | None = None book: bool | None = None spool: bool | None = None location: str | None = Field(default=None, max_length=500) for_sup_id: uuid.UUID | None = None sign: SignBody | None = None it_reason: str | None = Field(default=None, max_length=1000) class WorkOrdersBody(BaseModel): work_orders: list[WorkOrderIn] = Field(max_length=300) it_reason: str | None = Field(default=None, max_length=1000) class PagesBody(BaseModel): asset_id: uuid.UUID type: Literal["drawing"] = "drawing" dwg: str | None = Field(default=None, max_length=200) rev: str | None = Field(default=None, max_length=100) sign: SignBody | None = None it_reason: str | None = Field(default=None, max_length=1000) class ReasonBody(BaseModel): reason: str | None = Field(default=None, max_length=2000) it_reason: str | None = Field(default=None, max_length=1000) class DocBody(BaseModel): asset_id: uuid.UUID kind: str = Field(min_length=1, max_length=100) title: str | None = Field(default=None, max_length=500) it_reason: str | None = Field(default=None, max_length=1000) class MoveBody(BaseModel): unit_id: int reason: str | None = Field(default=None, max_length=2000) it_reason: str | None = Field(default=None, max_length=1000) class VclosedBody(BaseModel): closed: bool it_reason: str | None = Field(default=None, max_length=1000) class ItBody(BaseModel): it_reason: str | None = Field(default=None, max_length=1000) class ReopenBody(BaseModel): reason: str | None = Field(default=None, max_length=2000) sign: SignBody | None = None it_reason: str | None = Field(default=None, max_length=1000) # ---------------------------------------------------------------- validation helpers def shop_dept(conn: Any, code: Any) -> str: """An active shop department code, or 422 invalid_dept.""" if isinstance(code, str): r = conn.execute( "SELECT code FROM departments WHERE code = %s AND kind = 'shop' AND active", (code.strip(),) ).fetchone() if r is not None: return str(r[0]) raise ApiError(422, "invalid_dept", "Choose an active shop department.") def active_user(conn: Any, uid: Any, what: str) -> str: if uid is not None: try: u = uuid.UUID(str(uid)) except ValueError: u = None if u is not None and conn.execute("SELECT 1 FROM users WHERE id = %s AND active", (u,)).fetchone(): return str(u) raise ApiError(422, "invalid_user", f"Choose an active user for the {what}.") def open_unit(conn: Any, unit_id: int) -> dict[str, Any]: """A live unit that is open in a project that is not closed. 404 unknown, 409 unit_closed.""" u = packets.one( conn, "SELECT u.id, u.status::text AS status, u.removed_at, pr.status::text AS project_status," " pr.removed_at AS project_removed, pr.number || u.lot AS number" " FROM units u JOIN projects pr ON pr.id = u.project_id WHERE u.id = %s", (unit_id,), ) if u is None or u["removed_at"] is not None or u["project_removed"] is not None: raise ApiError(404, "not_found", "No such unit.") if u["status"] != "open" or u["project_status"] == "closed": raise ApiError(409, "unit_closed", "That unit is not open. Open it or pick another unit.") return u def clean_work_orders(items: list[WorkOrderIn]) -> list[dict[str, Any]]: """Work order keys stay text (leading zeros kept). Duplicate keys are 422.""" out: list[dict[str, Any]] = [] seen: set[tuple[str, str, str, str]] = set() for i, w in enumerate(items): key = ( clean_text(w.wo_base, WO_PART_MAX).upper(), clean_text(w.lot, WO_PART_MAX).upper(), clean_text(w.split, WO_PART_MAX).upper(), clean_text(w.sub, WO_PART_MAX).upper(), ) if not key[0]: raise ApiError(422, "invalid_request", f"Work order {i + 1} needs a work order number.") if key in seen: raise ApiError(422, "duplicate_work_order", f"Work order {'/'.join(key)} is listed twice.") seen.add(key) out.append( { "wo_base": key[0], "lot": key[1], "split": key[2], "sub": key[3], "seq": w.seq, "qty": w.qty, "target_hrs": w.target_hrs, "op": clean_opt(w.op, LIMITS["op"]), "description": clean_opt(w.description, LIMITS["description"]), "sort": i, } ) return out def insert_work_orders(conn: Any, packet_id: int, wos: list[dict[str, Any]]) -> None: for w in wos: conn.execute( "INSERT INTO packet_work_orders (packet_id, wo_base, lot, split, sub, seq, qty, target_hrs, op," " description, sort) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)", ( packet_id, w["wo_base"], w["lot"], w["split"], w["sub"], w["seq"], w["qty"], w["target_hrs"], w["op"], w["description"], w["sort"], ), ) def clean_bom(items: list[BomIn]) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] seen: set[int] = set() for i, b in enumerate(items): if b.item in seen: raise ApiError(422, "duplicate_item", f"BOM item {b.item} is listed twice.") seen.add(b.item) out.append( { "item": b.item, "qty": b.qty, "part_id": clean_opt(b.part_id, LIMITS["part_id"]), "description": clean_opt(b.description, LIMITS["description"]), "material": clean_opt(b.material, LIMITS["material"]), "length": clean_opt(b.length, LIMITS["length"]), "dwg_ref": clean_opt(b.dwg_ref, LIMITS["drawing"]), "sort": i, } ) return out def _read(d: Db, pp: policy.Principal, packet_id: int) -> dict[str, Any]: return packets.read_packet(d.conn, packet_id, pp) def _pp(d: Db, p: Principal) -> policy.Principal: return packets.policy_principal_for(d.conn, p) # ---------------------------------------------------------------- list and read @router.get("") def list_packets( p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 status: str = Query("", max_length=200), dept: str = Query("", max_length=10), unit_id: int | None = Query(None), project_id: int | None = Query(None), q: str = Query("", max_length=200), flag: str = Query("", max_length=20), co: str = Query("", max_length=20), limit: int = Query(500, ge=1, le=2000), removed: int = Query(0, ge=0, le=1), ) -> dict[str, Any]: filters = { "status": status, "dept": dept.strip() or None, "unit_id": unit_id, "project_id": project_id, "q": clean_text(q, 200), "flag": flag, "co": co.strip().lower() or None, "limit": limit, "removed": bool(removed) and p.is_admin, } return {"packets": packets.list_cards(d.conn, _pp(d, p), filters)} @router.post("", status_code=201, dependencies=WRITE) def create( body: CreateBody, p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: """A hand draft (Clerical; Admin with an IT reason).""" conn = d.conn pp = _pp(d, p) it_reason: str | None = None if "CL" not in pp.support: if not pp.admin: raise ApiError(403, "forbidden", MSG_FORBIDDEN) it_reason = reason_ok(body.it_reason) if it_reason is None: raise ApiError(400, "it_reason_required", "Give an IT reason of at least 8 characters.") dept = shop_dept(conn, body.dept_code) wos = clean_work_orders(body.work_orders) bom = clean_bom(body.bom) with conn.transaction(): open_unit(conn, body.unit_id) pid = int( conn.execute( "INSERT INTO packets (unit_id, dept_code, op, title, customer, drawing, rev, wps, heat_map," " source_kind, source, drawing_state, flags, created_by)" " VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'hand', %s, 'none', %s, %s) RETURNING id", ( body.unit_id, dept, clean_opt(body.op, LIMITS["op"]), clean_opt(body.title, LIMITS["title"]), clean_opt(body.customer, LIMITS["customer"]), clean_opt(body.drawing, LIMITS["drawing"]), clean_opt(body.rev, LIMITS["rev"]), clean_opt(body.wps, LIMITS["wps"]), body.heat_map, Jsonb({"kind": "hand"}), ["hand"], p.user_id, ), ).fetchone()[0] ) insert_work_orders(conn, pid, wos) for b in bom: conn.execute( "INSERT INTO bom_lines (packet_id, item, qty, part_id, description, material, length, dwg_ref, src," " sort) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'hand', %s)", ( pid, b["item"], b["qty"], b["part_id"], b["description"], b["material"], b["length"], b["dwg_ref"], b["sort"], ), ) conn.execute( "INSERT INTO packet_log (packet_id, by, by_name, role, action, it_reason) VALUES (%s, %s, %s, %s, %s, %s)", (pid, p.user_id, p.user["display_name"], p.role_snapshot, "Packet created by hand", it_reason), ) audit.append( conn, action="packet.created", user_id=p.user_id, session_id=p.session_id, role_snapshot=p.role_snapshot, subject_type="packet", subject_id=pid, packet_id=pid, detail={"source": "hand", "unit_id": body.unit_id, "dept": dept, "work_orders": len(wos), "bom": len(bom)}, it_reason=it_reason, ip=p.ip, ) events.publish(conn, {"event": "changed", "packet_id": pid, "version": 1, "status": "draft", "dept": dept}) return _read(d, pp, pid) @router.get("/{packet_id}") def read( packet_id: int, p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: return _read(d, _pp(d, p), packet_id) # ---------------------------------------------------------------- header edits HEADER_FIELDS: dict[str, tuple[policy.Action, str]] = { "title": (A.EDIT_HEADER, "title"), "customer": (A.EDIT_HEADER, "customer"), "wps": (A.EDIT_HEADER, "wps"), "op": (A.EDIT_HEADER, "op"), "heat_map": (A.EDIT_HEADER, ""), "for_sup_id": (A.EDIT_HEADER, ""), "drawing": (A.EDIT_DRAWING_REF, "drawing"), "rev": (A.EDIT_DRAWING_REF, "rev"), "dept_code": (A.EDIT_DEPT, ""), } ACTION_ORDER = (A.EDIT_HEADER, A.EDIT_DEPT, A.EDIT_DRAWING_REF) def _clean_header(conn: Any, key: str, value: Any) -> Any: if key == "heat_map": if value is None or isinstance(value, bool): return value raise ApiError(422, "invalid_request", "heat_map must be true, false or null.") if key == "for_sup_id": return None if value in (None, "") else active_user(conn, value, "supervisor") if key == "dept_code": return shop_dept(conn, value) if value is not None and not isinstance(value, str): raise ApiError(422, "invalid_request", f"{key} must be text.") return clean_opt(value, LIMITS[HEADER_FIELDS[key][1]]) @router.patch("/{packet_id}", dependencies=WRITE) def patch_header( packet_id: int, body: PatchBody, request: Request, p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: if not body.set: raise ApiError(422, "invalid_request", "Nothing to change.") unknown = sorted(k for k in body.set if k not in HEADER_FIELDS) if unknown: raise ApiError(422, "invalid_request", f"These fields cannot be changed here: {', '.join(unknown)}.") conn = d.conn pp = _pp(d, p) wanted = {HEADER_FIELDS[k][0] for k in body.set} actions = [a for a in ACTION_ORDER if a in wanted] with packet_write( d, p, packet_id, actions[0], request=request, it_reason=body.it_reason, also=actions[1:], policy_principal=pp ) as w: cleaned = {k: _clean_header(conn, k, v) for k, v in body.set.items()} row = w.packet current = {k: (packets.uid_text(row[k]) if k == "for_sup_id" else row[k]) for k in HEADER_FIELDS} changes = apply_field_cas(current, cleaned, body.base) if not changes: w.skip() else: cols = ", ".join(f"{k} = %s" for k in changes) conn.execute(f"UPDATE packets SET {cols} WHERE id = %s", (*changes.values(), packet_id)) # noqa: S608 w.changed(*changes) if set(changes) == {"heat_map"}: w.log("Heat map required" if changes["heat_map"] else "Heat map not required") else: w.note = ", ".join(sorted(changes)) return _read(d, pp, packet_id) # ---------------------------------------------------------------- transitions # The request fields each transition carries. Anything else in the body is ignored. TRANSITION_FIELDS: dict[policy.Action, tuple[str, ...]] = { A.SEND_TO_WAREHOUSE: ("note",), A.RELEASE_TO_SHOP: ("dept", "for_sup_id", "note"), A.SEND_DRAFT_TO_QC: ("note",), A.WH_SEND_BACK: ("reason",), A.RETURN_TO_CLERICAL: ("reason",), A.WH_RELEASE: ("note",), A.SEND_BACK_TO_WAREHOUSE: ("reason",), A.ASSIGN: ("station_id", "welder_id", "stamp"), A.UNASSIGN: ("note",), A.PULL_BACK: ("note",), A.START: ("note",), A.SUBMIT: ("note",), A.RETURN_TO_STATION: ("reason",), A.SEND_TO_QC: ("book", "spool", "location", "note"), A.ROUTE_FROM_REVIEW: ("dept", "note"), A.QC_REJECT: ("reason",), A.QC_ACCEPT: ("routed_to", "note"), A.QC_ACCEPT_AND_ROUTE: ("dept", "note"), A.FILE: ("note",), A.ADMIN_REOPEN: ("reason",), } DEPT_ACTIONS = (A.RELEASE_TO_SHOP, A.ROUTE_FROM_REVIEW, A.QC_ACCEPT_AND_ROUTE) def _transition_extra(action: policy.Action, body: TransitionBody) -> dict[str, Any]: extra: dict[str, Any] = {} for name in TRANSITION_FIELDS[action]: value = getattr(body, name) if value is None: continue extra[name] = str(value) if isinstance(value, uuid.UUID) else value return extra @router.post("/{packet_id}/transitions", dependencies=WRITE) def transition( packet_id: int, body: TransitionBody, request: Request, p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: try: action = A(body.action) except ValueError: action = None if action is None or action not in TRANSITION_FIELDS: raise ApiError(422, "invalid_action", "That is not a packet step.") conn = d.conn pp = _pp(d, p) extra = _transition_extra(action, body) if action in DEPT_ACTIONS and "dept" in extra: extra["dept"] = shop_dept(conn, extra["dept"]) if "for_sup_id" in extra: extra["for_sup_id"] = active_user(conn, extra["for_sup_id"], "supervisor") content = {"action": str(action), "packet_id": packet_id, "expect": body.expect.model_dump(), **extra} with packet_write( d, p, packet_id, action, request=request, extra=extra, it_reason=body.it_reason, sign=body.sign, expect=body.expect, sign_content=content, policy_principal=pp, ) as w: if action is A.ASSIGN: ok = conn.execute( "SELECT 1 FROM stations WHERE id = %s AND dept_code = %s AND active", (extra["station_id"], w.state.dept), ).fetchone() if ok is None: raise ApiError(422, "invalid_station", "Choose a station in this department.") active_user(conn, extra["welder_id"], "welder") apply_transition(w) return _read(d, pp, packet_id) @router.post("/{packet_id}/reopen", dependencies=WRITE) def reopen( packet_id: int, body: ReopenBody, request: Request, p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: pp = _pp(d, p) extra = {} if body.reason is None else {"reason": body.reason} content = {"action": str(A.ADMIN_REOPEN), "packet_id": packet_id, "reason": reason_ok(body.reason)} with packet_write( d, p, packet_id, A.ADMIN_REOPEN, request=request, extra=extra, it_reason=body.it_reason, sign=body.sign, sign_content=content, policy_principal=pp, ) as w: apply_transition(w) return _read(d, pp, packet_id) # ---------------------------------------------------------------- work orders @router.put("/{packet_id}/work-orders", dependencies=WRITE) def put_work_orders( packet_id: int, body: WorkOrdersBody, request: Request, p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: if_match = require_if_match(request) wos = clean_work_orders(body.work_orders) pp = _pp(d, p) with packet_write( d, p, packet_id, A.EDIT_WOS, request=request, it_reason=body.it_reason, if_match=if_match, policy_principal=pp ) as w: d.conn.execute("DELETE FROM packet_work_orders WHERE packet_id = %s", (packet_id,)) insert_work_orders(d.conn, packet_id, wos) w.changed("work_orders") w.note = f"{len(wos)} work order{'s' if len(wos) != 1 else ''}" return _read(d, pp, packet_id) # ---------------------------------------------------------------- drawing pages @router.post("/{packet_id}/pages", dependencies=WRITE) def attach_pages( packet_id: int, body: PagesBody, request: Request, p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: conn = d.conn asset = asset_store.get(conn, body.asset_id) if asset is None: raise ApiError(404, "not_found", "No such file.") if asset["mime"] != asset_store.PDF or not asset["pages"]: raise ApiError(422, "not_pdf", "Attach a PDF drawing.") pp = _pp(d, p) dwg = clean_opt(body.dwg, LIMITS["drawing"]) rev = clean_opt(body.rev, LIMITS["rev"]) content = { "action": str(A.EDIT_DOCUMENT_SET), "packet_id": packet_id, "asset_id": str(asset["id"]), "sha256": bytes(asset["sha256"]).hex(), "pages": asset["pages"], "dwg": dwg, "rev": rev, } with packet_write( d, p, packet_id, A.EDIT_DOCUMENT_SET, request=request, it_reason=body.it_reason, sign=body.sign, sign_content=content, policy_principal=pp, ) as w: dup = conn.execute( "SELECT 1 FROM packet_pages WHERE packet_id = %s AND asset_id = %s AND void_at IS NULL", (packet_id, asset["id"]), ).fetchone() if dup is not None: raise ApiError(409, "already_attached", "That drawing is already attached.") count = int(asset["pages"]) first = next_counter(conn, packet_id, "page", count) for i in range(count): conn.execute( "INSERT INTO packet_pages (packet_id, n, type, asset_id, pn, dwg, rev)" " VALUES (%s, %s, 'drawing', %s, %s, %s, %s)", (packet_id, first + i, asset["id"], i + 1, dwg or w.packet["drawing"], rev or w.packet["rev"]), ) state = "hand" if w.packet["source_kind"] == "hand" else "attached" conn.execute( "UPDATE packets SET drawing_state = %s::drawing_state, flags = array_remove(flags, 'drawing_needed')" " WHERE id = %s", (state, packet_id), ) w.changed("pages", "drawing_state") name = asset["original_name"] or "drawing" w.note = f"{name}, {count} page{'s' if count != 1 else ''}" w.detail["asset_id"] = str(asset["id"]) return _read(d, pp, packet_id) @router.delete("/{packet_id}/pages/{n}", dependencies=WRITE) def remove_pages( packet_id: int, n: int, request: Request, body: ReasonBody | None = None, p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: body = body or ReasonBody() conn = d.conn pp = _pp(d, p) with packet_write( d, p, packet_id, A.EDIT_DOCUMENT_SET, request=request, it_reason=body.it_reason, require_sign=False, policy_principal=pp, ) as w: if w.packet["status"] != "draft": raise ApiError(403, "wrong_status", "Drawing pages can be removed only while the packet is a draft.") page = packets.one( conn, "SELECT * FROM packet_pages WHERE packet_id = %s AND n = %s AND void_at IS NULL AND co_id IS NULL", (packet_id, n), ) if page is None or page["asset_id"] is None: raise ApiError(404, "not_found", "No such page.") gone = conn.execute( "DELETE FROM packet_pages WHERE packet_id = %s AND asset_id = %s AND void_at IS NULL AND co_id IS NULL", (packet_id, page["asset_id"]), ).rowcount left = conn.execute( "SELECT 1 FROM packet_pages WHERE packet_id = %s AND type = 'drawing' AND void_at IS NULL", (packet_id,) ).fetchone() if left is None: conn.execute("UPDATE packets SET drawing_state = 'none' WHERE id = %s", (packet_id,)) w.changed("pages") w.log( "Drawing removed", ", ".join( x for x in (f"{gone} page{'s' if gone != 1 else ''}", clean_opt(body.reason, LIMITS["reason"])) if x ), ) w.detail["asset_id"] = str(page["asset_id"]) return _read(d, pp, packet_id) # ---------------------------------------------------------------- documents @router.post("/{packet_id}/docs", dependencies=WRITE) def add_doc( packet_id: int, body: DocBody, request: Request, p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: conn = d.conn asset = asset_store.get(conn, body.asset_id) if asset is None: raise ApiError(404, "not_found", "No such file.") kind = clean_text(body.kind, 40) if not kind: raise ApiError(422, "invalid_request", "Say what kind of document this is.") title = clean_opt(body.title, LIMITS["doc_title"]) pp = _pp(d, p) with packet_write(d, p, packet_id, A.DOC_ADD, request=request, it_reason=body.it_reason, policy_principal=pp) as w: doc_id = conn.execute( "INSERT INTO packet_docs (packet_id, asset_id, kind, title, added_by) VALUES (%s, %s, %s, %s, %s)" " RETURNING id", (packet_id, asset["id"], kind, title, p.user_id), ).fetchone()[0] w.changed("docs") w.note = title or kind w.detail.update({"doc_id": int(doc_id), "asset_id": str(asset["id"]), "kind": kind}) return _read(d, pp, packet_id) @router.delete("/{packet_id}/docs/{doc_id}", dependencies=WRITE) def remove_doc( packet_id: int, doc_id: int, request: Request, body: ReasonBody | None = None, p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: body = body or ReasonBody() conn = d.conn pp = _pp(d, p) with packet_write( d, p, packet_id, A.DOC_REMOVE, request=request, it_reason=body.it_reason, policy_principal=pp ) as w: reason = clean_opt(body.reason, LIMITS["reason"]) doc = packets.one( conn, "UPDATE packet_docs SET removed_at = now(), removed_by = %s, removed_reason = %s" " WHERE id = %s AND packet_id = %s AND removed_at IS NULL RETURNING title, kind", (p.user_id, reason, doc_id, packet_id), ) if doc is None: raise ApiError(404, "not_found", "No such document.") w.changed("docs") w.note = ", ".join(x for x in (doc["title"] or doc["kind"], reason) if x) w.detail["doc_id"] = doc_id return _read(d, pp, packet_id) # ---------------------------------------------------------------- move, remove, close, data book @router.post("/{packet_id}/move", dependencies=WRITE) def move( packet_id: int, body: MoveBody, request: Request, p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: conn = d.conn pp = _pp(d, p) extra = {} if body.reason is None else {"reason": body.reason} with packet_write( d, p, packet_id, A.MOVE_UNIT, request=request, extra=extra, it_reason=body.it_reason, policy_principal=pp ) as w: target = open_unit(conn, body.unit_id) old = w.packet["unit_id"] if int(old) == int(body.unit_id): raise ApiError(422, "same_unit", "The packet is already in that unit.") old_number = conn.execute( "SELECT pr.number || u.lot FROM units u JOIN projects pr ON pr.id = u.project_id WHERE u.id = %s", (old,) ).fetchone()[0] conn.execute("UPDATE packets SET unit_id = %s WHERE id = %s", (body.unit_id, packet_id)) w.changed("unit_id") reason = clean_opt(body.reason, LIMITS["reason"]) w.note = f"From {old_number} to {target['number']}" + (f". {reason}" if reason else "") w.detail.update({"from_unit": int(old), "to_unit": int(body.unit_id)}) return _read(d, pp, packet_id) @router.delete("/{packet_id}", dependencies=WRITE) def remove( packet_id: int, request: Request, body: ReasonBody | None = None, p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: body = body or ReasonBody() pp = _pp(d, p) extra = {} if body.reason is None else {"reason": body.reason} with packet_write( d, p, packet_id, A.REMOVE, request=request, extra=extra, it_reason=body.it_reason, policy_principal=pp ) as w: reason = reason_ok(body.reason) d.conn.execute( "UPDATE packets SET removed_at = now(), removed_by = %s, removed_reason = %s WHERE id = %s", (p.user_id, reason, packet_id), ) w.changed("removed") w.note = reason return _read(d, pp, packet_id) @router.post("/{packet_id}/vclosed", dependencies=WRITE) def set_vclosed( packet_id: int, body: VclosedBody, request: Request, p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: pp = _pp(d, p) with packet_write( d, p, packet_id, A.SET_VCLOSED, request=request, it_reason=body.it_reason, policy_principal=pp ) as w: is_closed = w.packet["vclosed_at"] is not None if body.closed == is_closed: w.skip() elif body.closed: d.conn.execute( "UPDATE packets SET vclosed_at = now(), vclosed_by = %s WHERE id = %s", (p.user_id, packet_id) ) w.changed("vclosed") wos = [ r[0] for r in d.conn.execute( "SELECT DISTINCT wo_base FROM packet_work_orders WHERE packet_id = %s ORDER BY 1", (packet_id,) ) ] w.note = ", ".join(wos) or None else: d.conn.execute("UPDATE packets SET vclosed_at = NULL, vclosed_by = NULL WHERE id = %s", (packet_id,)) w.changed("vclosed") w.log("Moved back to ready to close") return _read(d, pp, packet_id) @router.post("/{packet_id}/databook", dependencies=WRITE) def set_databook( packet_id: int, request: Request, body: ItBody | None = None, p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: body = body or ItBody() pp = _pp(d, p) with packet_write( d, p, packet_id, A.SET_DATABOOK_AT, request=request, it_reason=body.it_reason, policy_principal=pp ) as w: d.conn.execute("UPDATE packets SET databook_at = now() WHERE id = %s", (packet_id,)) w.changed("databook_at") return _read(d, pp, packet_id) # ---------------------------------------------------------------- seal and log @router.get("/{packet_id}/seal") def seal( packet_id: int, request: Request, p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: if packets.load_row(d.conn, packet_id) is None: raise ApiError(404, "not_found", "No such packet.") return verify_seal(d.conn, settings_of(request), packet_id) @router.get("/{packet_id}/log") def packet_log( packet_id: int, p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 limit: int = Query(300, ge=1, le=1000), before: int | None = Query(None, ge=1), ) -> list[dict[str, Any]]: conn = d.conn if conn.execute("SELECT 1 FROM packets WHERE id = %s", (packet_id,)).fetchone() is None: raise ApiError(404, "not_found", "No such packet.") sql = "SELECT id, at, by, by_name, role, action, note, it_reason, signature_id FROM packet_log WHERE packet_id = %s" params: list[Any] = [packet_id] if before is not None: sql += " AND id < %s" params.append(before) sql += " ORDER BY id DESC LIMIT %s" params.append(limit) return [ { "id": r["id"], "at": r["at"], "by": None if r["by"] is None else {"id": str(r["by"]), "display_name": r["by_name"]}, "role": r["role"], "action": r["action"], "note": r["note"], "it_reason": r["it_reason"], "signed": r["signature_id"] is not None, } for r in packets.rows(conn, sql, params) ]