Files
PacketStation/server/packetstation/api/packets.py
T
spencermandClaude Fable 5.1 00531cb329
ci / frontend (push) Successful in 37s
ci / server (push) Successful in 9m18s
ci / image (push) Successful in 35s
ci / e2e (push) Successful in 2m17s
Phase 6: Q.C. inspection, project folders, packet documents and the digital ITP
The Q.C. screen (KPIs, boards, project folders), the Q.C. tab on the packet
page (check items from the qc_check_items settings row, visual exam per weld,
NDE, hardness with the NACE label, notes), accept with a route and a
signature gated by qc_missing_empty, reject with a reason or an NCR number
closing the inspection into qc_history, Q.C. folder documents per project and
unit with superseding revisions, packet documents as a closed set with
take-out reasons, the digital ITP per unit from the frozen template (line
initials with CAS, unsign, N/A, customer codes, rejects with NCR, block
signatures, sign-off with the PDF hook, admin reopen, the ITP log), hold-point
warnings by department page group, the scoped policy check for unit and
project writes, migration 0007, tests, Playwright scenarios and docs.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-17 20:08:53 -06:00

993 lines
36 KiB
Python

"""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 ncr as ncrs
from ..domain import packets, 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)
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
@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", "ncr"),
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")
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 · <reason>". 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,)
)
]
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)
]