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

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

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 09:31:45 -06:00
co-authored by Claude Fable 5.1
parent 97b0973130
commit 37d9a0cb50
83 changed files with 16490 additions and 55 deletions
+106
View File
@@ -0,0 +1,106 @@
"""Stored files: upload (multipart), metadata, and the same-origin blob route.
The upload only needs a session; the attach endpoints (packet pages, documents) check the policy. The form is read
after the session check, so a request without a session never gets its body spooled.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query, Request, Response
from fastapi.responses import FileResponse
from psycopg.types.json import Jsonb
from starlette.concurrency import run_in_threadpool
from starlette.datastructures import UploadFile
from ..domain import assets
from .deps import Db, Principal, current_principal, get_db, settings_of
from .errors import ApiError
router = APIRouter(prefix="/api/assets", tags=["assets"])
blob_router = APIRouter(tags=["assets"])
# Multipart overhead allowed on top of the file limit before the body is refused unread.
FORM_SLACK = 64 * 1024
@router.post("", status_code=201)
async def upload(
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
s = settings_of(request)
length = request.headers.get("content-length")
if length is not None and length.isdigit() and int(length) > s.upload_max_bytes + FORM_SLACK:
raise ApiError(413, "too_large", f"The file is over the {s.upload_max_bytes // (1024 * 1024)} MB limit.")
form = await request.form(max_files=1, max_fields=8)
try:
file = form.get("file")
if not isinstance(file, UploadFile):
raise ApiError(422, "invalid_request", "Send the file in the 'file' field.")
packet_id: int | None = None
raw_pid = form.get("packet_id")
if isinstance(raw_pid, str) and raw_pid.strip():
if not raw_pid.strip().isdigit():
raise ApiError(422, "invalid_request", "packet_id must be a number.")
packet_id = int(raw_pid.strip())
exists = await run_in_threadpool(
lambda: d.conn.execute("SELECT 1 FROM packets WHERE id = %s", (packet_id,)).fetchone()
)
if exists is None:
raise ApiError(404, "not_found", "No such packet.")
purpose = form.get("purpose")
ref = {"purpose": str(purpose)[:40]} if isinstance(purpose, str) and purpose.strip() else None
upload_file = file
return await run_in_threadpool(
lambda: assets.store_upload(
d.conn,
s,
upload_file.file,
original_name=upload_file.filename,
user=p.user_id,
packet_id=packet_id,
source_ref=None if ref is None else Jsonb(ref),
actor=p,
)
)
finally:
await form.close()
@router.get("/{asset_id}")
def metadata(
asset_id: str,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
row = assets.get(d.conn, asset_id)
if row is None:
raise ApiError(404, "not_found", "No such file.")
return assets.public(row)
@blob_router.get("/_blob/{asset_id}")
def blob(
asset_id: str,
request: Request,
download: int = Query(0, ge=0, le=1),
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> Response:
row = assets.get(d.conn, asset_id)
if row is None:
raise ApiError(404, "not_found", "No such file.")
path = assets.open_blob(settings_of(request), row)
etag = '"' + bytes(row["sha256"]).hex() + '"'
headers = {
"Content-Disposition": f'{"attachment" if download else "inline"}; filename="{assets.download_name(row)}"',
"X-Content-Type-Options": "nosniff",
"Cache-Control": "private, max-age=3600",
"ETag": etag,
}
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers=headers)
return FileResponse(path, media_type=str(row["mime"]), headers=headers)
+9 -6
View File
@@ -15,12 +15,15 @@ router = APIRouter(prefix="/api", tags=["departments"])
def departments(_: Principal = Depends(current_principal), d: Db = Depends(get_db)) -> list[dict[str, Any]]: # noqa: B008
conn = d.conn
rows = conn.execute(
"SELECT code, slug, name, kind::text FROM departments WHERE active ORDER BY sort, code"
"SELECT code, slug, name, kind::text, weld FROM departments WHERE active ORDER BY sort, code"
).fetchall()
stations = conn.execute(
"SELECT dept_code, name FROM stations WHERE active ORDER BY dept_code, sort, name"
"SELECT dept_code, id, name FROM stations WHERE active ORDER BY dept_code, sort, name"
).fetchall()
by_dept: dict[str, list[str]] = {}
for code, name in stations:
by_dept.setdefault(code, []).append(name)
return [{"code": r[0], "slug": r[1], "name": r[2], "kind": r[3], "stations": by_dept.get(r[0], [])} for r in rows]
by_dept: dict[str, list[dict[str, Any]]] = {}
for code, sid, name in stations:
by_dept.setdefault(code, []).append({"id": int(sid), "name": name})
return [
{"code": r[0], "slug": r[1], "name": r[2], "kind": r[3], "weld": bool(r[4]), "stations": by_dept.get(r[0], [])}
for r in rows
]
File diff suppressed because it is too large Load Diff
+945
View File
@@ -0,0 +1,945 @@
"""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),
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,
"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)
]
+176
View File
@@ -0,0 +1,176 @@
"""Projects: list, read, create, field edits, renumber, soft delete; and the number lookup.
Reads need any signed-in session (PM included). Writes need Clerical (clerical or clerical_supervisor);
renumber and delete need clerical_supervisor. An admin outside those roles may do any of them with an
`it_reason` of at least 8 characters, kept on the audit row.
The router has no prefix because it also serves GET /api/lookup/number; every path starts with /api.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, ConfigDict, Field
from ..domain import projects as dom
from ..text import reason_ok
from .deps import Db, Principal, current_principal, get_db, require_password_session
from .errors import ApiError
router = APIRouter(tags=["projects"])
PREFIX = "/api/projects"
CLERICAL = frozenset({"clerical", "clerical_supervisor"})
CLERICAL_SUP = frozenset({"clerical_supervisor"})
# Generous input caps; clean_text cuts every value to its field limit.
TEXT = 4000
def authorize(p: Principal, *, supervisor: bool, it_reason: str | None) -> dom.Who:
"""The write check. Returns who is writing; it_reason is set only for an admin acting outside their roles."""
need = CLERICAL_SUP if supervisor else CLERICAL
if need & set(p.roles):
return dom.Who(user_id=p.user_id, session_id=p.session_id, role_snapshot=p.role_snapshot, ip=p.ip)
if p.is_admin:
require_password_session(p)
why = reason_ok(it_reason)
if why is None:
raise ApiError(400, "it_reason_required", "Give an IT reason of at least 8 characters.")
return dom.Who(
user_id=p.user_id, session_id=p.session_id, role_snapshot=p.role_snapshot, ip=p.ip, it_reason=why
)
if supervisor:
raise ApiError(403, "forbidden", "Only a Clerical Supervisor can do this.")
raise ApiError(403, "forbidden", "Only Clerical can change projects and units.")
class ProjectCreate(BaseModel):
number: str = Field(max_length=200)
name: str | None = Field(None, max_length=TEXT)
customer: str | None = Field(None, max_length=TEXT)
po: str | None = Field(None, max_length=TEXT)
due: str | None = Field(None, max_length=40)
status: str | None = Field(None, max_length=20)
notes: str | None = Field(None, max_length=TEXT)
it_reason: str | None = Field(None, max_length=TEXT)
class FieldCas(BaseModel):
"""`{set, base}`: the new values and what the client last saw for them."""
model_config = ConfigDict(populate_by_name=True)
values: dict[str, Any] = Field(alias="set")
base: dict[str, Any] = Field(default_factory=dict)
it_reason: str | None = Field(None, max_length=TEXT)
class RenumberProject(BaseModel):
number: str = Field(max_length=200)
reason: str | None = Field(None, max_length=TEXT)
it_reason: str | None = Field(None, max_length=TEXT)
class ReasonBody(BaseModel):
reason: str | None = Field(None, max_length=TEXT)
it_reason: str | None = Field(None, max_length=TEXT)
class UnitsCreate(BaseModel):
lot: str | None = Field(None, max_length=40)
lots: list[str] | None = Field(None, max_length=dom.MAX_UNITS_AT_ONCE)
count: int | None = None
start: str | None = Field(None, max_length=40)
name: str | None = Field(None, max_length=TEXT)
due: str | None = Field(None, max_length=40)
status: str | None = Field(None, max_length=20)
notes: str | None = Field(None, max_length=TEXT)
it_reason: str | None = Field(None, max_length=TEXT)
@router.get(PREFIX)
def list_projects(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
q: str = Query("", max_length=200),
status: str = Query("active", max_length=20),
) -> list[dict[str, Any]]:
return dom.list_projects(d.conn, q=q, status=status)
@router.post(PREFIX, status_code=201)
def create_project(
body: ProjectCreate,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
who = authorize(p, supervisor=False, it_reason=body.it_reason)
return dom.create_project(d.conn, who, body.model_dump())
@router.get(PREFIX + "/{project_id}")
def project_detail(
project_id: int,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
return dom.project_detail(d.conn, project_id)
@router.patch(PREFIX + "/{project_id}")
def update_project(
project_id: int,
body: FieldCas,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
who = authorize(p, supervisor=False, it_reason=body.it_reason)
return dom.update_project(d.conn, who, project_id, body.values, body.base)
@router.post(PREFIX + "/{project_id}/renumber")
def renumber_project(
project_id: int,
body: RenumberProject,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
who = authorize(p, supervisor=True, it_reason=body.it_reason)
return dom.renumber_project(d.conn, who, project_id, body.number, body.reason)
@router.delete(PREFIX + "/{project_id}")
def remove_project(
project_id: int,
body: ReasonBody,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
who = authorize(p, supervisor=True, it_reason=body.it_reason)
return dom.remove_project(d.conn, who, project_id, body.reason)
@router.post(PREFIX + "/{project_id}/units", status_code=201)
def create_units(
project_id: int,
body: UnitsCreate,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> list[dict[str, Any]]:
who = authorize(p, supervisor=False, it_reason=body.it_reason)
return dom.create_units(d.conn, who, project_id, body.model_dump())
@router.get("/api/lookup/number")
def lookup_number(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
n: str = Query(..., min_length=1, max_length=40),
) -> dict[str, Any]:
found = dom.lookup(d.conn, n)
if found is None:
raise ApiError(404, "not_found", "No project or unit has that number.")
return found
+77
View File
@@ -0,0 +1,77 @@
"""Units: the picker list, read, field edits, renumber (new lot) and soft delete.
New units are made through POST /api/projects/{id}/units. Same access rules as projects: reads need any
session; edits need Clerical; renumber and delete need clerical_supervisor; admin with an it_reason.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from ..domain import projects as dom
from .deps import Db, Principal, current_principal, get_db
from .projects import TEXT, FieldCas, ReasonBody, authorize
router = APIRouter(prefix="/api/units", tags=["units"])
class RenumberUnit(BaseModel):
lot: str = Field(max_length=40)
reason: str | None = Field(None, max_length=TEXT)
it_reason: str | None = Field(None, max_length=TEXT)
@router.get("")
def list_units(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
project_id: int | None = Query(None),
q: str = Query("", max_length=200),
status: str = Query("active", max_length=20),
) -> list[dict[str, Any]]:
return dom.list_units(d.conn, project_id=project_id, q=q, status=status)
@router.get("/{unit_id}")
def unit_detail(
unit_id: int,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
return dom.unit_detail(d.conn, unit_id)
@router.patch("/{unit_id}")
def update_unit(
unit_id: int,
body: FieldCas,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
who = authorize(p, supervisor=False, it_reason=body.it_reason)
return dom.update_unit(d.conn, who, unit_id, body.values, body.base)
@router.post("/{unit_id}/renumber")
def renumber_unit(
unit_id: int,
body: RenumberUnit,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
who = authorize(p, supervisor=True, it_reason=body.it_reason)
return dom.renumber_unit(d.conn, who, unit_id, body.lot, body.reason)
@router.delete("/{unit_id}")
def remove_unit(
unit_id: int,
body: ReasonBody,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
who = authorize(p, supervisor=True, it_reason=body.it_reason)
return dom.remove_unit(d.conn, who, unit_id, body.reason)