"""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 datetime import UTC, date, datetime from decimal import Decimal from typing import Any, Literal from zoneinfo import ZoneInfo, ZoneInfoNotFoundError 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 ncr as ncrs from ..domain import packets, search, shop from ..domain import qc as qc_dom 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, RX_STAMP, 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 ncr: str | None = Field(default=None, max_length=100) confirm_open_wos: bool | 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) note: str | None = Field(default=None, max_length=2000) 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 def site_today(request: Request) -> date: """Today in the site time zone (settings.site_tz), once per request.""" try: tz: Any = ZoneInfo(settings_of(request).site_tz) except (ZoneInfoNotFoundError, ValueError): tz = UTC return datetime.now(tz).date() def find_query( q: str = Query("", max_length=200), status: str = Query("", max_length=200), dept: str = Query("", max_length=10), station_id: int | None = Query(None, ge=1, le=2**62), welder_id: str = Query("", max_length=40), flags: str = Query("", max_length=100), flag: str = Query("", max_length=20), sort: str = Query("", max_length=20), mine_screen: str = Query("", max_length=20), mine_station_id: int | None = Query(None, ge=1, le=2**62), ) -> dict[str, Any]: """The find bar's query values, shared by GET /api/packets, the boards and the PM reports.""" return { "q": q, "status": status, "dept": dept, "station_id": station_id, "welder_id": welder_id, "flags": flags, "flag": flag, "sort": sort, "mine_screen": mine_screen, "mine_station_id": mine_station_id, } FIND = Depends(find_query) @router.get("") def list_packets( request: Request, p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 find: dict[str, Any] = FIND, unit_id: int | None = Query(None), project_id: int | None = Query(None), co: str = Query("", max_length=20), facets: int = Query(0, ge=0, le=1), limit: int = Query(500, ge=1, le=2000), removed: int = Query(0, ge=0, le=1), ) -> dict[str, Any]: """The cards for a list. The find bar's filters are applied here; `facets=1` adds the stations and workers among the packets in the project and unit scope.""" pp = _pp(d, p) filters = search.parse_filters( {**find, "unit_id": unit_id, "project_id": project_id}, pp, screen=None, today=site_today(request) ) filters.update({"co": co.strip().lower() or None, "limit": limit, "removed": bool(removed) and p.is_admin}) out: dict[str, Any] = {"packets": packets.list_cards(d.conn, pp, filters)} if facets: out["facets"] = search.facets(d.conn, project_id=project_id, unit_id=unit_id) return out @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", "ncr"), A.QC_ACCEPT: ("routed_to", "note"), A.QC_ACCEPT_AND_ROUTE: ("dept", "note"), A.FILE: ("note", "confirm_open_wos"), 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") if action is A.QC_REJECT: _reject_reason(extra) if "stamp" in extra: # The assign sheet's stamp overrides the welder's own for this assignment; empty means use theirs. stamp = clean_text(extra["stamp"], 40) if stamp and not RX_STAMP.match(stamp): raise ApiError(422, "invalid_stamp", "A stamp is up to 10 letters, digits or dashes.") if stamp: extra["stamp"] = stamp else: del extra["stamp"] 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") shop.welder_check(conn, w.state.dept, str(extra["welder_id"]), int(extra["station_id"])) apply_transition(w) return _read(d, pp, packet_id) def _reject_reason(extra: dict[str, Any]) -> None: """A Q.C. reject takes a reason, an NCR number, or both. The NCR stands in for the reason text: the reason the policy checks is "NCR-2026-0021" alone, or "NCR-2026-0021 · ". Without an NCR the reason needs its usual 8 characters.""" text = clean_text(extra.pop("reason", None), LIMITS["reason"]) raw = clean_text(extra.pop("ncr", None), 100) ncr = ncrs.validate_ncr(raw) if raw else None if ncr: extra["ncr"] = ncr extra["reason"] = f"{ncr} · {text}" if text else ncr elif text: extra["reason"] = text extra["reason_text"] = text @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]: """A report for the data book (NDE, hydrotest, certificates, NCRs). Never a drawing: drawings come from M-Files.""" conn = d.conn kind = clean_text(body.kind, 40) if kind not in qc_dom.DOC_KINDS: raise ApiError(422, "invalid_kind", "Pick a document type from the list.") asset = asset_store.get(conn, body.asset_id) if asset is None: raise ApiError(404, "not_found", "No such file.") if asset["mime"] not in qc_dom.DOC_MIMES: raise ApiError(422, "invalid_asset", "The file must be a PDF, PNG or JPEG.") label = qc_dom.DOC_KINDS[kind] title = clean_opt(body.title, LIMITS["doc_title"]) or label note = clean_opt(body.note, qc_dom.DOC_NOTE_MAX) 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, note, added_by)" " VALUES (%s, %s, %s, %s, %s, %s) RETURNING id", (packet_id, asset["id"], kind, title, note, p.user_id), ).fetchone()[0] w.changed("docs") w.log("Document added", " · ".join(x for x in (label, title, asset.get("original_name")) if x)) w.detail.update({"doc_id": int(doc_id), "asset_id": str(asset["id"]), "kind": kind}) return _read(d, pp, packet_id) DOC_REASON_MIN = 3 @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]: """Take a document out of the data book. The row and the file stay, with who, when and why.""" 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_text(body.reason, LIMITS["reason"]) doc = packets.one( conn, "SELECT * FROM packet_docs WHERE id = %s AND packet_id = %s FOR UPDATE", (doc_id, packet_id) ) if doc is None: raise ApiError(404, "not_found", "No such document.") if doc["removed_at"] is not None: raise ApiError(409, "already_removed", "This document is already out of the data book.") if len(reason) < DOC_REASON_MIN: raise ApiError(400, "reason_required", "Say why it comes out of the data book.") conn.execute( "UPDATE packet_docs SET removed_at = now(), removed_by = %s, removed_reason = %s WHERE id = %s", (p.user_id, reason, doc_id), ) w.changed("docs") w.log("Document taken out of the data book", " · ".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,) ) ] still_open = packets.packet_open_wos(d.conn, w.packet) suffix = "VISUAL still shows open: " + ", ".join(still_open) if still_open else None w.note = " · ".join(x for x in (", ".join(wos), suffix) if x) 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) ]