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)
+1
View File
@@ -0,0 +1 @@
"""The core domain: packets, projects and units, documents, the write pipeline. Pure SQL helpers over one connection."""
+231
View File
@@ -0,0 +1,231 @@
"""Stored files. One row per file in `assets`; the bytes live at ASSETS_DIR/<id[:2]>/<id>.
An upload is streamed to a temp file in ASSETS_DIR in chunks (never held in memory whole), hashed as it goes,
typed by its first bytes (not by its name or the browser's claim), fsynced and renamed into place. The same bytes
uploaded twice (same sha256 and size) return the existing row.
"""
from __future__ import annotations
import contextlib
import hashlib
import logging
import os
import re
import tempfile
import uuid
from pathlib import Path
from typing import Any, BinaryIO
from psycopg.rows import dict_row
from .. import audit
from ..api.errors import ApiError
from ..settings import Settings
from ..text import clean_text
log = logging.getLogger(__name__)
CHUNK = 1024 * 1024
HEAD = 16
PDF = "application/pdf"
MAGIC: tuple[tuple[bytes, str], ...] = (
(b"%PDF-", PDF),
(b"\x89PNG\r\n\x1a\n", "image/png"),
(b"\xff\xd8\xff", "image/jpeg"),
)
EXT = {PDF: ".pdf", "image/png": ".png", "image/jpeg": ".jpg"}
NAME_MAX = 160
_UNSAFE_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
def sniff(head: bytes) -> str | None:
"""The mime type from the first bytes, or None when it is not a type Packet Station stores."""
for magic, mime in MAGIC:
if head.startswith(magic):
return mime
return None
def safe_name(name: Any) -> str | None:
"""The original file name, cleaned: no path, no separators or reserved characters, at most 160 characters."""
text = clean_text(name, 1000)
text = text.replace("\\", "/").rsplit("/", 1)[-1]
text = _UNSAFE_NAME.sub("_", text).strip(" .")
return text[:NAME_MAX] or None
def storage_key(asset_id: uuid.UUID | str) -> str:
s = str(asset_id)
return f"{s[:2]}/{s}"
def path_of(settings: Settings, key: str) -> Path:
root = Path(settings.assets_dir).resolve()
p = (root / key).resolve()
if not p.is_relative_to(root):
raise ApiError(404, "not_found", "No such file.")
return p
def pdf_pages(path: Path) -> int:
"""Page count through pypdf; 422 bad_pdf when it does not parse or has no pages."""
from pypdf import PdfReader
from pypdf.errors import PyPdfError
try:
n = len(PdfReader(str(path)).pages)
except (PyPdfError, ValueError, KeyError, TypeError, OSError, IndexError, AttributeError) as e:
log.info("upload is not a readable PDF: %s", type(e).__name__)
raise ApiError(422, "bad_pdf", "That PDF could not be read.") from None
if n < 1:
raise ApiError(422, "bad_pdf", "That PDF has no pages.")
return n
def public(row: dict[str, Any]) -> dict[str, Any]:
return {
"id": str(row["id"]),
"mime": row["mime"],
"size": int(row["size"]),
"sha256": bytes(row["sha256"]).hex(),
"pages": row["pages"],
"original_name": row["original_name"],
"url": f"/_blob/{row['id']}",
}
def get(conn: Any, asset_id: Any) -> dict[str, Any] | None:
try:
aid = uuid.UUID(str(asset_id))
except ValueError:
return None
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute("SELECT * FROM assets WHERE id = %s", (aid,)).fetchone()
return dict(r) if r else None
def _fsync_dir(path: Path) -> None:
if os.name != "posix":
return
fd = os.open(str(path), os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
def store_upload(
conn: Any,
settings: Settings,
stream: BinaryIO,
*,
original_name: Any,
user: Any,
packet_id: int | None = None,
source: str = "upload",
source_ref: Any = None,
actor: Any = None,
) -> dict[str, Any]:
"""Store one upload and return its public row. Raises 413 too_large, 415 unsupported_type, 422 bad_pdf.
`user` is the uploader's user id; `actor` (the session Principal) is used for the audit row when given.
"""
root = Path(settings.assets_dir)
root.mkdir(parents=True, exist_ok=True)
limit = int(settings.upload_max_bytes)
fd, tmp_name = tempfile.mkstemp(prefix=".up-", dir=str(root))
tmp = Path(tmp_name)
keep = False
placed: Path | None = None
try:
digest = hashlib.sha256()
size = 0
head = b""
mime: str | None = None
with os.fdopen(fd, "wb") as out:
while True:
chunk = stream.read(CHUNK)
if not chunk:
break
size += len(chunk)
if size > limit:
raise ApiError(413, "too_large", f"The file is over the {limit // (1024 * 1024)} MB limit.")
if mime is None and len(head) < HEAD:
head += chunk[: HEAD - len(head)]
if len(head) >= HEAD:
mime = sniff(head)
if mime is None:
raise ApiError(415, "unsupported_type", "Only PDF, PNG and JPEG files can be stored.")
digest.update(chunk)
out.write(chunk)
out.flush()
os.fsync(out.fileno())
if mime is None:
mime = sniff(head)
if mime is None or size == 0:
raise ApiError(415, "unsupported_type", "Only PDF, PNG and JPEG files can be stored.")
sha = digest.digest()
with conn.cursor(row_factory=dict_row) as cur:
existing = cur.execute(
"SELECT * FROM assets WHERE sha256 = %s AND size = %s ORDER BY uploaded_at LIMIT 1", (sha, size)
).fetchone()
if existing is not None:
return public(dict(existing))
pages = pdf_pages(tmp) if mime == PDF else None
aid = uuid.uuid4()
key = storage_key(aid)
final = path_of(settings, key)
final.parent.mkdir(parents=True, exist_ok=True)
os.replace(tmp, final)
keep = True
placed = final
_fsync_dir(final.parent)
name = safe_name(original_name)
with conn.transaction():
with conn.cursor(row_factory=dict_row) as cur:
row = cur.execute(
"INSERT INTO assets (id, sha256, size, mime, original_name, storage_key, source, source_ref, pages,"
" uploaded_by, packet_id) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING *",
(aid, sha, size, mime, name, key, source, source_ref, pages, user, packet_id),
).fetchone()
audit.append(
conn,
action="asset.uploaded",
user_id=user,
session_id=getattr(actor, "session_id", None),
role_snapshot=getattr(actor, "role_snapshot", None),
subject_type="asset",
subject_id=str(aid),
packet_id=packet_id,
detail={"mime": mime, "size": size, "pages": pages, "sha256": sha.hex()},
ip=getattr(actor, "ip", None),
)
return public(dict(row))
except BaseException:
if placed is not None: # the row was not written: do not leave an orphan file
with contextlib.suppress(OSError):
placed.unlink()
raise
finally:
if not keep:
with contextlib.suppress(OSError):
tmp.unlink()
def open_blob(settings: Settings, row: dict[str, Any]) -> Path:
"""The stored file of an asset row; 404 when it is missing on disk."""
p = path_of(settings, str(row["storage_key"]))
if not p.is_file():
log.error("asset %s is missing on disk", row["id"])
raise ApiError(404, "not_found", "No such file.")
return p
def download_name(row: dict[str, Any]) -> str:
"""A safe file name for Content-Disposition (ASCII only, quotes removed)."""
name = row.get("original_name") or ""
name = name.encode("ascii", "ignore").decode("ascii").replace('"', "").replace(";", "_").strip()
if not name:
name = str(row["id"]) + EXT.get(str(row["mime"]), "")
return name
+126
View File
@@ -0,0 +1,126 @@
"""The department checklists and the Q.C. checks, ported from the old checklistFor(), qcChecksFor() and qcMissing().
Weld departments get the welding list, the others the plain list. The drawing, rev and WPS are filled into the text.
The keys are the old keys, so a checklist stored as {key: {done, at, by, stamp}} reads the same.
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from typing import Any
NO_REV = "-"
NDE_NONE = ("", "none", "none required")
NDE_OK = ("acceptable", "accept", "accepted")
REJECTS = ("reject", "rejected")
def _item(group: str, key: str, text: str) -> dict[str, str]:
return {"group": group, "key": key, "text": text}
def checklist_for(weld: bool, drawing: str | None, rev: str | None, wps: str | None) -> list[dict[str, str]]:
"""The stage checklist for a department, as a flat list of {group, key, text} in display order."""
dwg = f"Drawing {drawing or ''} Rev {rev or NO_REV} matches the packet".replace(" ", " ")
if not weld:
g1, g2 = "Before starting", "When done"
return [
_item(g1, "dwg", dwg),
_item(g1, "prev", "Previous department's work complete and released"),
_item(g1, "mat", "Material and parts on hand match the packet"),
_item(g2, "work", "Work completed per drawing and procedure"),
_item(g2, "insp", "Self-inspection done; no damage to finished surfaces"),
_item(g2, "qty", "Completed quantity matches each work order"),
]
g1, g2 = "Before welding", "After welding"
return [
_item(g1, "dwg", dwg),
_item(g1, "heat", "Heat numbers on material match the Materials tab"),
_item(g1, "wps", f"WPS reviewed: {wps or 'per drawing'}"),
_item(g1, "fit", "Fit-up and dimensions checked against the drawing"),
_item(g1, "pre", "Preheat and interpass temperature per WPS"),
_item(g2, "vt", "Visual inspection of all welds acceptable"),
_item(g2, "stamp", "Welds stamped with welder ID"),
_item(g2, "clean", "Slag and spatter removed"),
_item(g2, "qty", "Completed quantity matches each work order"),
]
def qc_items_for(
drawing: str | None, rev: str | None, wps: str | None, welder_stamp: str | None
) -> list[dict[str, str]]:
"""The Q.C. inspection items, as a flat list of {group, key, text}."""
g1, g2 = "Data book package", "Physical spool"
stamp = f"[{welder_stamp}] " if welder_stamp else ""
return [
_item(g1, "db_heats", "Heat number recorded for every BOM item"),
_item(g1, "db_mtr", "MTRs on file and match the heat numbers"),
_item(g1, "db_weldmap", "Weld map complete: every weld identified"),
_item(g1, "db_wps", f"Welds made to {wps or 'the WPS'}; welder {stamp}qualified"),
_item(g1, "db_ck", "Stall checklist and weld record complete"),
_item(g2, "ph_dim", f"Dimensions verified against {drawing or 'the drawing'} Rev {rev or NO_REV}"),
_item(g2, "ph_heat", "Heat numbers on material match the heat map"),
_item(g2, "ph_stamp", "Welds stamped with welder ID"),
_item(g2, "ph_clean", "Clean: no slag, spatter or arc strikes"),
_item(g2, "ph_qty", "Quantity matches the work orders"),
]
def checklist_keys(weld: bool) -> list[str]:
return [i["key"] for i in checklist_for(weld, None, None, None)]
def qc_keys() -> list[str]:
return [i["key"] for i in qc_items_for(None, None, None, None)]
def is_done(value: Any) -> bool:
"""A checklist or Q.C. check entry: `true`, or `{"done": true, ...}`."""
if value is True:
return True
return isinstance(value, Mapping) and value.get("done") is True
def checklist_complete(checklist: Mapping[str, Any] | None, weld: bool) -> bool:
"""Every key of the department checklist is done. An empty list counts as complete."""
ck = checklist or {}
return all(is_done(ck.get(k)) for k in checklist_keys(weld))
def _plural(n: int, word: str) -> str:
return f"{n} {word}{'s' if n > 1 else ''}"
def qc_missing(
packet: Mapping[str, Any], qc: Mapping[str, Any] | None, welds: Iterable[Mapping[str, Any]]
) -> list[str]:
"""What Q.C. still has to record before an accept. The old qcMissing().
`packet` is unused today (the item list does not depend on it for the keys) but kept for the old signature.
`qc` is the open inspection row (checks, weld_exam, nde_method, nde_result, inspector) or None.
`welds` are the live weld rows (weld_no).
"""
del packet
q = qc or {}
checks = q.get("checks") or {}
missing: list[str] = []
open_items = sum(1 for k in qc_keys() if not is_done(checks.get(k)))
if open_items:
missing.append(_plural(open_items, "inspection item"))
exam = q.get("weld_exam") or {}
results = []
for w in welds:
entry = exam.get(str(w.get("weld_no")))
result = entry.get("result") if isinstance(entry, Mapping) else None
results.append(str(result).lower() if result else "")
no_result = sum(1 for r in results if not r)
if no_result:
missing.append(_plural(no_result, "weld") + " without a visual result")
if any(r in REJECTS for r in results):
missing.append("a rejected weld (reject the packet instead)")
method = str(q.get("nde_method") or "").strip().lower()
if method not in NDE_NONE and str(q.get("nde_result") or "").strip().lower() not in NDE_OK:
missing.append("NDE result")
if not str(q.get("inspector") or "").strip():
missing.append("inspector name")
return missing
+111
View File
@@ -0,0 +1,111 @@
"""Shared SQL for the packet child rows: guarded row updates, the warehouse stage history, and the departments a
packet touched (who verifies a change order).
Every function here runs inside `packet_write`, so the packet row is already locked FOR UPDATE and child writes on
the same packet serialise on it.
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from typing import Any
from psycopg import sql
from psycopg.rows import dict_row
from . import packets
# Tables update_row may touch. The column names come from the handlers' fixed field lists, never from a request.
TABLES = frozenset(
{"bom_lines", "bom_shorts", "welds", "heat_tags", "packet_stages", "qc_inspections", "change_orders"}
)
# Statuses past the warehouse: the packet has been in its department.
IN_DEPT = frozenset({"ready", "assigned", "in_progress", "review", "qc", "complete", "filed"})
QC_STATUSES = frozenset({"qc", "complete", "filed"})
# Departments that never verify a change order: Clerical issues them, PM has no supervisor.
NEVER_VERIFY = frozenset({"CL", "PM"})
def update_row(
conn: Any, table: str, row_id: Any, values: Mapping[str, Any], *, bump: bool = True
) -> dict[str, Any] | None:
"""UPDATE one row by id with the given column values (and row_version + 1 when bump). Returns the new row."""
if table not in TABLES:
raise ValueError(f"update_row: table {table!r} is not allowed")
parts: list[sql.Composable] = [sql.SQL("{} = {}").format(sql.Identifier(k), sql.Placeholder()) for k in values]
if bump:
parts.append(sql.SQL("row_version = row_version + 1"))
if not parts:
raise ValueError("update_row: nothing to set")
query = sql.SQL("UPDATE {} SET {} WHERE id = {} RETURNING *").format(
sql.Identifier(table), sql.SQL(", ").join(parts), sql.Placeholder()
)
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(query, (*values.values(), row_id)).fetchone()
return dict(r) if r is not None else None
def current_wh(conn: Any, packet_id: int) -> dict[str, Any] | None:
return packets.one(
conn, "SELECT *, stage::text AS stage FROM packet_wh WHERE packet_id = %s AND closed_at IS NULL", (packet_id,)
)
def wh_move(
conn: Any,
packet_id: int,
stage: str,
by: Any,
*,
reason: str | None = None,
note: str | None = None,
auto: bool = False,
) -> None:
"""Close the current warehouse stage row and insert the new one."""
conn.execute("UPDATE packet_wh SET closed_at = now() WHERE packet_id = %s AND closed_at IS NULL", (packet_id,))
conn.execute(
"INSERT INTO packet_wh (packet_id, stage, by, reason, note, auto) VALUES (%s, %s::wh_stage, %s, %s, %s, %s)",
(packet_id, stage, by, reason, note, auto),
)
def co_touched(conn: Any, row: Mapping[str, Any]) -> list[str]:
"""The departments a packet has touched, in the old coTouched() order, without Clerical and PM.
WH once it left draft; its department once it reached the shop; every stage's department; QC once it reached
Q.C. (status qc, complete or filed, a Q.C. history row, or any inspection).
"""
pid = row["id"]
status = str(row["status"])
out: list[str] = []
def add(codes: Iterable[Any]) -> None:
for c in codes:
if c and str(c) not in out:
out.append(str(c))
if status != "draft":
add(["WH"])
if status in IN_DEPT:
add([row["dept_code"]])
add(r[0] for r in conn.execute("SELECT dept_code FROM packet_stages WHERE packet_id = %s ORDER BY id", (pid,)))
reached_qc = status in QC_STATUSES or bool(
conn.execute(
"SELECT EXISTS (SELECT 1 FROM qc_history WHERE packet_id = %s)"
" OR EXISTS (SELECT 1 FROM qc_inspections WHERE packet_id = %s)",
(pid, pid),
).fetchone()[0]
)
if reached_qc:
add(["QC"])
return [c for c in out if c not in NEVER_VERIFY]
def active_depts(conn: Any, codes: Iterable[str]) -> set[str]:
"""The given codes that are active departments."""
want = sorted({str(c) for c in codes})
if not want:
return set()
return {r[0] for r in conn.execute("SELECT code FROM departments WHERE code = ANY(%s) AND active", (want,))}
+787
View File
@@ -0,0 +1,787 @@
"""The packet read model, the policy's PacketState, and the board cards.
`read_packet` is the full answer of GET /api/packets/{id} and of every packet mutation. `packet_state` builds the
policy's view from the same child rows, so a write and a read decide from the same facts. `list_cards` serves the
boards from header columns, v_open_cos and the open short count only.
"""
from __future__ import annotations
import uuid
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from typing import Any
from psycopg.rows import dict_row
from .. import policy
from ..api import deps
from ..api.errors import ApiError
from . import checklists
HAND_FLAG = "Made by hand - check it and send it on"
DRAWING_NEEDED_FLAG = "Drawing needed - looking it up in the vault"
DRAWING_STATES_ATTACHED = ("attached", "hand")
# Keys left out of the sealed content: they change without the packet changing (who can do what, the counter,
# the seal itself, the data book export date) or come from code and AD rather than the packet (checklist texts,
# the flag, the unit and project names, display names).
SEAL_EXCLUDE = (
"can",
"blocked",
"holder",
"version",
"log",
"seal",
"databook_at",
"checklist_items",
"qc_items",
"flag",
)
# ---------------------------------------------------------------- small helpers
def rows(conn: Any, query: str, params: Any = ()) -> list[dict[str, Any]]:
with conn.cursor(row_factory=dict_row) as cur:
return list(cur.execute(query, params).fetchall())
def one(conn: Any, query: str, params: Any = ()) -> dict[str, Any] | None:
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(query, params).fetchone()
return dict(r) if r is not None else None
def uid_text(value: Any) -> str | None:
return None if value is None else str(value)
def load_row(conn: Any, packet_id: int, *, lock: bool = False) -> dict[str, Any] | None:
"""The packets row, or None. With lock=True it is taken FOR UPDATE (inside a transaction)."""
sql = "SELECT *, status::text AS status, source_kind::text AS source_kind, routed_to::text AS routed_to,"
sql += " drawing_state::text AS drawing_state FROM packets WHERE id = %s"
if lock:
sql += " FOR UPDATE"
return one(conn, sql, (packet_id,))
def flag_of(status: str, source_kind: str, flags: Iterable[str] | None, drawing_state: str | None = None) -> str | None:
"""The draft flag shown on cards and the packet page. The old packetFlag(), phase 2 part."""
if status != "draft":
return None
if source_kind == "hand":
return HAND_FLAG
if "drawing_needed" in (flags or ()):
return DRAWING_NEEDED_FLAG
return None
# SQL for the same rule, for the list filter.
FLAG_SQL = "(p.status = 'draft' AND (p.source_kind = 'hand' OR 'drawing_needed' = ANY(p.flags)))"
def policy_principal_for(conn: Any, p: deps.Principal) -> policy.Principal:
"""The policy Principal for a session: its roles, the user's station limits and the session kind."""
limits = [r[0] for r in conn.execute("SELECT station_id FROM user_station_limits WHERE user_id = %s", (p.user_id,))]
return policy.principal_from_roles(str(p.user_id), p.roles, station_limits=limits, session_kind=p.kind)
def co_hold_why(open_cos: Iterable[Mapping[str, Any]], dept_names: Mapping[str, str]) -> str:
parts: list[str] = []
for co in open_cos:
if str(co.get("review")) == "pending":
parts.append(f"Change order {co['co_no']} is on hold.")
else:
names = ", ".join(dept_names.get(c, c) for c in co.get("pending_depts") or [])
parts.append(f"Change order {co['co_no']} is waiting for verification by {names}.")
return " ".join(parts)
def dept_names(conn: Any) -> dict[str, str]:
return {r[0]: r[1] for r in conn.execute("SELECT code, name FROM departments")}
# ---------------------------------------------------------------- the child rows
@dataclass
class Children:
dept: dict[str, Any]
station: dict[str, Any] | None
work_orders: list[dict[str, Any]] = field(default_factory=list)
pages: list[dict[str, Any]] = field(default_factory=list)
bom: list[dict[str, Any]] = field(default_factory=list)
shorts: dict[int, dict[str, Any]] = field(default_factory=dict) # bom_line_id -> open short
receipts: dict[int, list[dict[str, Any]]] = field(default_factory=dict) # short id -> receipts
welds: list[dict[str, Any]] = field(default_factory=list)
heat_tags: list[dict[str, Any]] = field(default_factory=list)
heat_table: list[dict[str, Any]] = field(default_factory=list)
stages: list[dict[str, Any]] = field(default_factory=list)
wh: dict[str, Any] | None = None
qc: dict[str, Any] | None = None
qc_history: list[dict[str, Any]] = field(default_factory=list)
change_orders: list[dict[str, Any]] = field(default_factory=list)
co_items: dict[int, list[dict[str, Any]]] = field(default_factory=dict)
co_required: dict[int, list[str]] = field(default_factory=dict)
co_verified: dict[int, list[dict[str, Any]]] = field(default_factory=dict)
open_cos: list[dict[str, Any]] = field(default_factory=list)
docs: list[dict[str, Any]] = field(default_factory=list)
seal: dict[str, Any] | None = None
dept_names: dict[str, str] = field(default_factory=dict)
@property
def open_stage(self) -> dict[str, Any] | None:
return next((s for s in self.stages if s["closed_at"] is None), None)
def load_children(conn: Any, row: Mapping[str, Any], *, full: bool = True) -> Children:
"""Every child row the read model and the policy need. full=False skips what only the read model shows."""
pid = row["id"]
dept = one(conn, "SELECT code, name, weld FROM departments WHERE code = %s", (row["dept_code"],)) or {
"code": row["dept_code"],
"name": row["dept_code"],
"weld": False,
}
station = (
one(conn, "SELECT id, name FROM stations WHERE id = %s", (row["station_id"],)) if row["station_id"] else None
)
ch = Children(dept=dept, station=station)
ch.dept_names = dept_names(conn)
ch.pages = rows(conn, "SELECT * FROM packet_pages WHERE packet_id = %s ORDER BY n", (pid,))
ch.bom = rows(conn, "SELECT * FROM bom_lines WHERE packet_id = %s ORDER BY item", (pid,))
shorts = rows(conn, "SELECT * FROM bom_shorts WHERE packet_id = %s AND closed_at IS NULL", (pid,))
ch.shorts = {s["bom_line_id"]: s for s in shorts}
ch.welds = rows(conn, "SELECT * FROM welds WHERE packet_id = %s AND removed_at IS NULL ORDER BY weld_no", (pid,))
ch.stages = rows(conn, "SELECT * FROM packet_stages WHERE packet_id = %s ORDER BY id", (pid,))
ch.qc = one(conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (pid,))
ch.open_cos = rows(
conn,
"SELECT co_id, co_no, review::text AS review, hold, pending_depts FROM v_open_cos WHERE packet_id = %s"
" ORDER BY co_no",
(pid,),
)
if not full:
return ch
ch.work_orders = rows(conn, "SELECT * FROM packet_work_orders WHERE packet_id = %s ORDER BY sort, id", (pid,))
if shorts:
for r in rows(
conn,
"SELECT * FROM short_receipts WHERE short_id = ANY(%s) ORDER BY id",
([s["id"] for s in shorts],),
):
ch.receipts.setdefault(r["short_id"], []).append(r)
ch.heat_tags = rows(
conn, "SELECT * FROM heat_tags WHERE packet_id = %s AND removed_at IS NULL ORDER BY tag_no", (pid,)
)
ch.heat_table = rows(conn, "SELECT * FROM heat_table_placements WHERE packet_id = %s ORDER BY page", (pid,))
ch.wh = one(
conn, "SELECT *, stage::text AS stage FROM packet_wh WHERE packet_id = %s AND closed_at IS NULL", (pid,)
)
ch.qc_history = rows(conn, "SELECT * FROM qc_history WHERE packet_id = %s ORDER BY id", (pid,))
ch.change_orders = rows(
conn,
"SELECT *, origin::text AS origin, review::text AS review FROM change_orders WHERE packet_id = %s"
" ORDER BY co_no",
(pid,),
)
co_ids = [c["id"] for c in ch.change_orders]
if co_ids:
for r in rows(conn, "SELECT * FROM co_items WHERE co_id = ANY(%s) ORDER BY id", (co_ids,)):
ch.co_items.setdefault(r["co_id"], []).append(r)
for r in rows(
conn, "SELECT co_id, dept_code FROM co_required_depts WHERE co_id = ANY(%s) ORDER BY dept_code", (co_ids,)
):
ch.co_required.setdefault(r["co_id"], []).append(r["dept_code"])
for r in rows(conn, "SELECT * FROM co_verifications WHERE co_id = ANY(%s) ORDER BY at", (co_ids,)):
ch.co_verified.setdefault(r["co_id"], []).append(r)
ch.docs = rows(
conn, "SELECT * FROM packet_docs WHERE packet_id = %s AND removed_at IS NULL ORDER BY added_at, id", (pid,)
)
ch.seal = one(conn, "SELECT * FROM packet_seals WHERE packet_id = %s ORDER BY id DESC LIMIT 1", (pid,))
return ch
def state_from(row: Mapping[str, Any], ch: Children) -> policy.PacketState:
weld = bool(ch.dept.get("weld"))
live_bom = [b for b in ch.bom if not b["na"]]
stage = ch.open_stage
return policy.PacketState(
id=int(row["id"]),
status=str(row["status"]),
dept=str(row["dept_code"]),
station_id=row["station_id"],
welder_id=uid_text(row["welder_id"]),
wh_skipped=bool(row["wh_skipped"]),
routed_to=row["routed_to"],
vclosed=row["vclosed_at"] is not None,
removed=row["removed_at"] is not None,
source_kind=str(row["source_kind"]),
weld_dept=weld,
co_hold=bool(ch.open_cos),
co_hold_why=co_hold_why(ch.open_cos, ch.dept_names),
open_shorts=len(ch.shorts),
drawing_attached=any(pg["type"] == "drawing" and pg["void_at"] is None for pg in ch.pages),
heat_map=row["heat_map"],
bom_total=len(live_bom),
bom_ready=sum(1 for b in live_bom if b["pulled"] or b["id"] in ch.shorts),
welds=len(ch.welds),
checklist_complete=stage is not None and checklists.checklist_complete(stage["checklist"], weld),
filler_set=stage is not None and bool((stage["filler"] or "").strip()),
qc_missing=tuple(checklists.qc_missing(row, ch.qc, ch.welds)),
has_qc_sent=ch.qc is not None,
)
def packet_state(conn: Any, row: Mapping[str, Any]) -> policy.PacketState:
"""The policy's view of a packet row, from its child rows."""
return state_from(row, load_children(conn, row, full=False))
def holder_text(state: policy.PacketState, ch: Children) -> str:
return policy.holder_name(state, ch.dept.get("name"), (ch.station or {}).get("name"))
# ---------------------------------------------------------------- shaping
class People:
"""Display names for who references. In seal mode a reference is the id only."""
def __init__(self, conn: Any, ids: Iterable[Any], *, seal: bool = False):
self.seal = seal
want = sorted({str(i) for i in ids if i is not None})
self.names: dict[str, str] = {}
if want and not seal:
for r in conn.execute(
"SELECT id, display_name FROM users WHERE id = ANY(%s)", ([uuid.UUID(i) for i in want],)
):
self.names[str(r[0])] = r[1]
def ref(self, uid: Any) -> dict[str, Any] | None:
if uid is None:
return None
if self.seal:
return {"id": str(uid)}
return {"id": str(uid), "display_name": self.names.get(str(uid))}
def _station_ref(conn: Any, station_id: Any, cache: dict[Any, dict[str, Any] | None]) -> dict[str, Any] | None:
if station_id is None:
return None
if station_id not in cache:
cache[station_id] = one(conn, "SELECT id, name FROM stations WHERE id = %s", (station_id,))
return cache[station_id]
def _stage(conn: Any, s: Mapping[str, Any], people: People, cache: dict[Any, Any], *, closed: bool) -> dict[str, Any]:
welder = people.ref(s["welder_id"])
if welder is not None:
welder["stamp"] = s["welder_stamp"]
out = {
"id": s["id"],
"dept_code": s["dept_code"],
"station": _station_ref(conn, s["station_id"], cache),
"welder": welder,
"checklist": s["checklist"] or {},
"filler": s["filler"],
"filler_lot": s["filler_lot"],
"preheat": s["preheat"],
"notes": s["notes"],
"clock": {
"elapsed_ms": int(s["clock_elapsed_ms"] or 0),
"running_since": s["clock_running_since"],
"started_at": s["clock_started_at"],
},
"opened_at": s["opened_at"],
"row_version": s["row_version"],
}
if closed:
out["closed_at"] = s["closed_at"]
return out
def _qc(q: Mapping[str, Any], people: People) -> dict[str, Any]:
keys = (
"id",
"sent_at",
"book",
"spool",
"location",
"sent_note",
"nde_method",
"nde_result",
"nde_report",
"hardness",
"nace",
"inspector",
"stamp",
"notes",
"result",
"result_at",
"result_note",
"row_version",
)
out = {k: q[k] for k in keys}
out["sent_by"] = people.ref(q["sent_by"])
out["result_by"] = people.ref(q["result_by"])
out["checks"] = q["checks"] or {}
out["weld_exam"] = q["weld_exam"] or {}
return out
def _who_ids(row: Mapping[str, Any], ch: Children) -> list[Any]:
ids: list[Any] = [row["welder_id"], row["for_sup_id"], row["vclosed_by"], row["removed_by"]]
ids += [w["welder_id"] for w in ch.welds]
ids += [s["welder_id"] for s in ch.stages]
if ch.wh:
ids.append(ch.wh["by"])
if ch.qc:
ids += [ch.qc["sent_by"], ch.qc["result_by"]]
ids += [d["added_by"] for d in ch.docs]
for c in ch.change_orders:
ids += [c["issued_by"], c["approved_by"], c["rejected_by"]]
for vs in ch.co_verified.values():
ids += [v["by"] for v in vs]
for rs in ch.receipts.values():
ids += [r["by"] for r in rs]
return ids
def _unit(conn: Any, unit_id: int, *, seal: bool) -> dict[str, Any]:
if seal:
return {"id": unit_id}
u = one(
conn,
"SELECT u.id, u.lot, u.name, u.number, u.due_effective, p.id AS project_id, p.number AS project_number,"
" p.name AS project_name, p.customer, p.po, p.status::text AS project_status"
" FROM v_units u JOIN projects p ON p.id = u.project_id WHERE u.id = %s",
(unit_id,),
)
if u is None:
return {"id": unit_id, "number": None, "lot": None, "name": None, "project": None, "due_effective": None}
return {
"id": u["id"],
"number": u["number"],
"lot": u["lot"],
"name": u["name"],
"project": {
"id": u["project_id"],
"number": u["project_number"],
"name": u["project_name"],
"customer": u["customer"],
"po": u["po"],
"status": u["project_status"],
},
"due_effective": u["due_effective"],
}
def build(
conn: Any,
row: Mapping[str, Any],
pp: policy.Principal | None,
*,
include_children: bool = True,
seal: bool = False,
) -> dict[str, Any]:
"""The read model of one loaded packets row. pp None (seal mode) leaves out can, blocked and holder."""
ch = load_children(conn, row, full=True)
state = state_from(row, ch)
people = People(conn, _who_ids(row, ch), seal=seal)
cache: dict[Any, Any] = {}
welder = people.ref(row["welder_id"])
if welder is not None:
welder["stamp"] = row["welder_stamp"]
weld = bool(ch.dept.get("weld"))
out: dict[str, Any] = {
"id": row["id"],
"version": row["version"],
"unit": _unit(conn, row["unit_id"], seal=seal),
"dept": {"code": ch.dept["code"], "name": ch.dept["name"], "weld": weld},
"op": row["op"],
"title": row["title"],
"customer": row["customer"],
"drawing": row["drawing"],
"rev": row["rev"],
"wps": row["wps"],
"status": row["status"],
"status_at": row["status_at"],
"station": ch.station,
"welder": welder,
"for_sup": people.ref(row["for_sup_id"]),
"heat_map": row["heat_map"],
"wh_skipped": row["wh_skipped"],
"wh_sent_at": row["wh_sent_at"],
"routed_to": row["routed_to"],
"return_note": row["return_note"],
"vclosed": None
if row["vclosed_at"] is None
else {"at": row["vclosed_at"], "by": people.ref(row["vclosed_by"])},
"databook_at": row["databook_at"],
"source_kind": row["source_kind"],
"source": row["source"] or {},
"drawing_state": row["drawing_state"],
"flags": list(row["flags"] or []),
"visual": row["visual"] or {},
"removed": None
if row["removed_at"] is None
else {"at": row["removed_at"], "by": people.ref(row["removed_by"]), "reason": row["removed_reason"]},
"open_cos": [c["co_no"] for c in ch.open_cos],
"open_shorts": len(ch.shorts),
}
if include_children:
out["work_orders"] = [
{
k: w[k]
for k in (
"id",
"wo_base",
"lot",
"split",
"sub",
"seq",
"qty",
"target_hrs",
"op",
"description",
"visual_status",
"gone_at",
"row_version",
)
}
for w in ch.work_orders
]
out["pages"] = [
{
"n": pg["n"],
"type": pg["type"],
"asset_id": uid_text(pg["asset_id"]),
"pn": pg["pn"],
"dwg": pg["dwg"],
"rev": pg["rev"],
"void": None
if pg["void_at"] is None
else {"co_id": pg["void_co_id"], "at": pg["void_at"], "new_n": pg["void_new_n"]},
"url": f"/_blob/{pg['asset_id']}" if pg["asset_id"] else None,
}
for pg in ch.pages
]
out["bom"] = [_bom(b, ch, people) for b in ch.bom]
out["welds"] = [_weld(w, people) for w in ch.welds]
out["heat_tags"] = [
{k: t[k] for k in ("tag_no", "page", "item", "fx", "fy", "row_version")} for t in ch.heat_tags
]
out["heat_table"] = [{k: h[k] for k in ("page", "x", "y", "s")} for h in ch.heat_table]
st = ch.open_stage
out["stage"] = _stage(conn, st, people, cache, closed=False) if st else None
out["stages"] = [_stage(conn, s, people, cache, closed=True) for s in ch.stages if s["closed_at"] is not None]
out["wh"] = (
None
if ch.wh is None
else {
"stage": ch.wh["stage"],
"by": people.ref(ch.wh["by"]),
"at": ch.wh["at"],
"reason": ch.wh["reason"],
"note": ch.wh["note"],
"auto": ch.wh["auto"],
}
)
out["qc"] = _qc(ch.qc, people) if ch.qc else None
out["qc_history"] = [
{k: h[k] for k in ("id", "at", "result", "inspector", "note", "dept_code")} for h in ch.qc_history
]
out["change_orders"] = [_co(c, ch, people) for c in ch.change_orders]
out["docs"] = [
{
"id": d["id"],
"asset_id": str(d["asset_id"]),
"kind": d["kind"],
"title": d["title"],
"added_at": d["added_at"],
"added_by": people.ref(d["added_by"]),
"url": f"/_blob/{d['asset_id']}",
}
for d in ch.docs
]
out["seal"] = (
None
if ch.seal is None
else {"sealed_at": ch.seal["sealed_at"], "key_id": ch.seal["key_id"], "broken_at": ch.seal["broken_at"]}
)
out["checklist_items"] = checklists.checklist_for(weld, row["drawing"], row["rev"], row["wps"])
out["qc_items"] = checklists.qc_items_for(row["drawing"], row["rev"], row["wps"], row["welder_stamp"])
out["flag"] = flag_of(row["status"], row["source_kind"], row["flags"], row["drawing_state"])
if pp is not None:
allowed, blocked = policy.can(pp, state)
out["can"] = [str(a) for a in allowed]
out["blocked"] = {str(a): r for a, r in blocked.items()}
out["holder"] = holder_text(state, ch)
if seal:
for k in SEAL_EXCLUDE:
out.pop(k, None)
return out
def _bom(b: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
keys = (
"id",
"item",
"qty",
"part_id",
"description",
"dwg_ref",
"cl_item",
"length",
"material",
"heat",
"na",
"issued",
"loc",
"pulled",
"src",
"row_version",
)
out = {k: b[k] for k in keys}
out["mtr_asset_id"] = uid_text(b["mtr_asset_id"])
s = ch.shorts.get(b["id"])
out["short"] = (
None
if s is None
else {
"id": s["id"],
"qty": s["qty"],
"qty_open": s["qty_open"],
"reason": s["reason"],
"ref": s["ref"],
"eta": s["eta"],
"note": s["note"],
"opened_at": s["opened_at"],
"receipts": [
{
"id": r["id"],
"qty": r["qty"],
"heat": r["heat"],
"note": r["note"],
"at": r["at"],
"by": people.ref(r["by"]),
}
for r in ch.receipts.get(s["id"], [])
],
"row_version": s["row_version"],
}
)
return out
def _weld(w: Mapping[str, Any], people: People) -> dict[str, Any]:
keys = ("weld_no", "page", "fx", "fy", "wo_base", "piece", "type", "size", "process", "stamp", "at", "row_version")
out = {k: w[k] for k in keys}
out["items"] = list(w["items"] or [])
out["welder"] = people.ref(w["welder_id"])
return out
def _co(c: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
keys = (
"id",
"co_no",
"origin",
"review",
"minor",
"hold",
"ecn",
"reason",
"issued_at",
"approved_at",
"rejected_at",
"rejected_reason",
"row_version",
)
out = {k: c[k] for k in keys}
out["issued_by"] = people.ref(c["issued_by"])
out["approved_by"] = people.ref(c["approved_by"])
out["rejected_by"] = people.ref(c["rejected_by"])
out["items"] = [
{k: i[k] for k in ("old_page", "old_dwg", "old_rev", "new_page", "new_dwg", "new_rev", "added")}
for i in ch.co_items.get(c["id"], [])
]
out["required"] = ch.co_required.get(c["id"], [])
out["verified"] = {
v["dept_code"]: {"by": people.ref(v["by"]), "at": v["at"], "note": v["note"]}
for v in ch.co_verified.get(c["id"], [])
}
return out
def read_packet(
conn: Any, packet_id: int, policy_principal: policy.Principal | None, *, include_children: bool = True
) -> dict[str, Any]:
"""The read model, or 404 not_found. A removed packet reads fine, with `removed` set."""
row = load_row(conn, packet_id)
if row is None:
raise ApiError(404, "not_found", "No such packet.")
return build(conn, row, policy_principal, include_children=include_children)
def seal_content(conn: Any, row: Mapping[str, Any]) -> dict[str, Any]:
"""What a filing seal covers: the read model without the volatile and derived keys (SEAL_EXCLUDE)."""
return build(conn, row, None, include_children=True, seal=True)
# ---------------------------------------------------------------- board cards
CARD_SQL = """
SELECT p.id, p.version, p.unit_id, u.lot, pr.number || u.lot AS unit_number, pr.id AS project_id,
pr.number AS project_number, pr.name AS project_name, p.dept_code, d.name AS dept_name, d.weld,
p.op, p.title, p.customer, p.drawing, p.rev, p.status::text AS status, p.status_at, p.station_id,
st.name AS station_name, p.welder_id, wu.display_name AS welder_name, p.welder_stamp,
p.routed_to::text AS routed_to, p.vclosed_at, p.vclosed_by, vu.display_name AS vclosed_name,
p.source_kind::text AS source_kind, p.flags, p.drawing_state::text AS drawing_state, p.wh_skipped,
p.heat_map, p.removed_at, coalesce(u.due, pr.due) AS due_effective, p.updated_at,
(SELECT w.stage::text FROM packet_wh w WHERE w.packet_id = p.id AND w.closed_at IS NULL) AS wh_stage,
(SELECT count(*) FROM bom_shorts s WHERE s.packet_id = p.id AND s.closed_at IS NULL) AS open_shorts,
(SELECT count(*) FROM packet_work_orders wo WHERE wo.packet_id = p.id) AS wo_count,
(SELECT coalesce(json_agg(json_build_object('co_no', c.co_no, 'review', c.review,
'pending_depts', c.pending_depts) ORDER BY c.co_no), '[]')
FROM v_open_cos c WHERE c.packet_id = p.id) AS open_co_rows
FROM packets p
JOIN units u ON u.id = p.unit_id
JOIN projects pr ON pr.id = u.project_id
JOIN departments d ON d.code = p.dept_code
LEFT JOIN stations st ON st.id = p.station_id
LEFT JOIN users wu ON wu.id = p.welder_id
LEFT JOIN users vu ON vu.id = p.vclosed_by
"""
FLAG_ON = ("1", "true", "yes", "any", "flagged")
FLAG_OFF = ("0", "false", "no", "none")
def _like(word: str) -> str:
return "%" + word.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
def card_state(r: Mapping[str, Any], names: Mapping[str, str]) -> policy.PacketState:
"""PacketState from header columns and counts. Child-row facts the board does not load pass (the packet
page decides them): checklist, welds, filler, BOM pulled, Q.C. items."""
cos = r["open_co_rows"] or []
return policy.PacketState(
id=int(r["id"]),
status=str(r["status"]),
dept=str(r["dept_code"]),
station_id=r["station_id"],
welder_id=uid_text(r["welder_id"]),
wh_skipped=bool(r["wh_skipped"]),
routed_to=r["routed_to"],
vclosed=r["vclosed_at"] is not None,
removed=r["removed_at"] is not None,
source_kind=str(r["source_kind"]),
weld_dept=bool(r["weld"]),
co_hold=bool(cos),
co_hold_why=co_hold_why(cos, names),
open_shorts=int(r["open_shorts"] or 0),
drawing_attached=r["drawing_state"] in DRAWING_STATES_ATTACHED,
heat_map=r["heat_map"],
bom_total=0,
bom_ready=0,
welds=1,
checklist_complete=True,
filler_set=True,
qc_missing=(),
has_qc_sent=r["status"] == "qc",
)
def list_cards(conn: Any, policy_principal: policy.Principal, filters: Mapping[str, Any]) -> list[dict[str, Any]]:
"""The board cards. Filters: status (comma list), dept, unit_id, project_id, q, flag, limit, removed."""
where: list[str] = []
params: list[Any] = []
if not filters.get("removed"):
where.append("p.removed_at IS NULL")
status = [s.strip() for s in str(filters.get("status") or "").split(",") if s.strip()]
if status:
where.append("p.status::text = ANY(%s)")
params.append(status)
if filters.get("dept"):
where.append("p.dept_code = %s")
params.append(str(filters["dept"]))
if filters.get("unit_id") is not None:
where.append("p.unit_id = %s")
params.append(int(filters["unit_id"]))
if filters.get("project_id") is not None:
where.append("u.project_id = %s")
params.append(int(filters["project_id"]))
flag = str(filters.get("flag") or "").strip().lower()
if flag in FLAG_ON:
where.append(FLAG_SQL)
elif flag in FLAG_OFF:
where.append("NOT " + FLAG_SQL)
elif flag == "hand":
where.append("(p.status = 'draft' AND p.source_kind = 'hand')")
for word in str(filters.get("q") or "").split()[:8]:
pat = _like(word)
where.append(
"(pr.number ILIKE %s OR pr.name ILIKE %s OR (pr.number || u.lot) ILIKE %s OR u.lot ILIKE %s"
" OR p.drawing ILIKE %s OR p.customer ILIKE %s OR pr.customer ILIKE %s OR pr.po ILIKE %s"
" OR p.title ILIKE %s OR EXISTS (SELECT 1 FROM packet_work_orders wo WHERE wo.packet_id = p.id"
" AND wo.wo_base ILIKE %s))"
)
params += [pat] * 10
sql = CARD_SQL + (" WHERE " + " AND ".join(where) if where else "")
sql += " ORDER BY p.updated_at DESC, p.id DESC LIMIT %s"
params.append(int(filters.get("limit") or 500))
names = dept_names(conn)
transitions = {str(a) for a in policy.TRANSITIONS}
out: list[dict[str, Any]] = []
for r in rows(conn, sql, params):
state = card_state(r, names)
allowed, blocked = policy.can(policy_principal, state)
welder = (
None
if r["welder_id"] is None
else {"id": str(r["welder_id"]), "display_name": r["welder_name"], "stamp": r["welder_stamp"]}
)
out.append(
{
"id": r["id"],
"version": r["version"],
"unit": {"id": r["unit_id"], "number": r["unit_number"]},
"project": {"id": r["project_id"], "number": r["project_number"], "name": r["project_name"]},
"dept": {"code": r["dept_code"], "name": r["dept_name"]},
"op": r["op"],
"title": r["title"],
"customer": r["customer"],
"drawing": r["drawing"],
"rev": r["rev"],
"status": r["status"],
"status_at": r["status_at"],
"station": None if r["station_id"] is None else {"id": r["station_id"], "name": r["station_name"]},
"welder": welder,
"routed_to": r["routed_to"],
"vclosed": None
if r["vclosed_at"] is None
else {
"at": r["vclosed_at"],
"by": None
if r["vclosed_by"] is None
else {"id": str(r["vclosed_by"]), "display_name": r["vclosed_name"]},
},
"source_kind": r["source_kind"],
"flag": flag_of(r["status"], r["source_kind"], r["flags"], r["drawing_state"]),
"open_cos": [c["co_no"] for c in r["open_co_rows"] or []],
"open_shorts": int(r["open_shorts"] or 0),
"wh_stage": r["wh_stage"],
"due_effective": r["due_effective"],
"updated_at": r["updated_at"],
"holder": policy.holder_name(state, r["dept_name"], r["station_name"]),
"can": [str(a) for a in allowed if str(a) in transitions],
"blocked": {str(a): v for a, v in blocked.items() if str(a) in transitions},
"wo_count": int(r["wo_count"] or 0),
"removed": r["removed_at"] is not None,
}
)
return out
+704
View File
@@ -0,0 +1,704 @@
"""The write pipeline: every packet write is one transaction through `packet_write`.
Inside one transaction: lock the packet row (SELECT ... FOR UPDATE), build the policy's PacketState, check the
caller's expected step (409 stale_step), check the packet version when If-Match is given (409 version_conflict),
ask the policy (403 or 400 with the decision's code), take the signature when the rule needs one, hand the caller a
`Write`, then bump the version, write the packet_log row, the transition row and its side effects, the audit row and
the NOTIFY. The policy decides; a handler never does.
A wrong signature secret is the one failure that commits: the step-up failure count and the attempt row must stay,
so the three-strike rule works. Nothing else has been written at that point.
Child-row endpoints build on the same pieces: `check_row_version`, `next_counter`, `apply_field_cas`, `parse_if_match`.
"""
from __future__ import annotations
import hashlib
import hmac
import logging
from collections.abc import Iterator, Mapping, Sequence
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Any, Literal
from fastapi import Request
from psycopg.types.json import Jsonb
from pydantic import BaseModel, Field
from .. import audit, events, policy
from ..api import deps
from ..api.errors import ApiError
from ..auth import signatures
from ..auth.signatures import Signature
from ..policy.engine import MSG_FILED
from ..settings import SecretFileError
from ..text import LIMITS, clean_opt, reason_ok
from . import packets
log = logging.getLogger(__name__)
A = policy.Action
MSG_STALE = "Someone else already took this step."
MSG_VERSION = "The packet changed since you opened it. Reload and try again."
MSG_ROW = "Someone else changed this line. Reload and try again."
MSG_FIELD = "Someone else changed this since you opened it."
# Codes that answer 400 (the UI asks for input and retries); every other refusal answers 403.
ASK_CODES = frozenset({"it_reason_required", "reason_required"})
COUNTERS = {"weld": "next_weld_no", "heat": "next_heat_no", "page": "next_page_no", "co": "next_co_no"}
# The packet_log label per action, when the caller gives none.
LABELS: dict[policy.Action, str] = {
A.SEND_TO_WAREHOUSE: "Sent to warehouse",
A.RELEASE_TO_SHOP: "Sent straight to the shop",
A.SEND_DRAFT_TO_QC: "Sent straight to Q.C.",
A.WH_SEND_BACK: "Sent back to Clerical",
A.RETURN_TO_CLERICAL: "Sent back to Clerical",
A.WH_RELEASE: "Released to the shop",
A.SEND_BACK_TO_WAREHOUSE: "Sent back to the warehouse",
A.ASSIGN: "Sent to a station",
A.UNASSIGN: "Taken off the station",
A.PULL_BACK: "Pulled back from the station",
A.START: "Work started",
A.SUBMIT: "Submitted to the supervisor",
A.RETURN_TO_STATION: "Returned to the station",
A.SEND_TO_QC: "Sent to Q.C.",
A.ROUTE_FROM_REVIEW: "Routed to another department",
A.QC_REJECT: "Q.C. rejected",
A.QC_ACCEPT: "Q.C. accepted",
A.QC_ACCEPT_AND_ROUTE: "Q.C. accepted and routed",
A.FILE: "Filed in the data book",
A.ADMIN_REOPEN: "Reopened by IT",
A.EDIT_HEADER: "Details changed",
A.EDIT_DRAWING_REF: "Drawing number changed",
A.EDIT_DEPT: "Department changed",
A.MOVE_UNIT: "Moved to another unit",
A.EDIT_WOS: "Work orders changed",
A.EDIT_DOCUMENT_SET: "Drawing attached by hand",
A.DOC_ADD: "Document added",
A.DOC_REMOVE: "Document removed",
A.SET_VCLOSED: "Job closed in VISUAL",
A.SET_DATABOOK_AT: "Data book date set",
A.REMOVE: "Packet removed",
}
AUDIT_ACTIONS: dict[policy.Action, str] = {A.REMOVE: "packet.removed", A.MOVE_UNIT: "packet.moved"}
# ---------------------------------------------------------------- request bodies
class SignBody(BaseModel):
factor: Literal["pin", "password"]
secret: str = Field(max_length=1024)
class Expect(BaseModel):
status: str = Field(max_length=20)
dept: str = Field(max_length=10)
# ---------------------------------------------------------------- the Write object
@dataclass
class Write:
conn: Any # the request connection, inside one transaction
principal: deps.Principal # the session principal
policy_principal: policy.Principal
packet: dict[str, Any] # the packets row, locked FOR UPDATE
state: policy.PacketState
decision: policy.Decision
signature: Signature | None
version: int # the new version after the bump (set on exit)
action: policy.Action
extra: Mapping[str, Any]
it_reason: str | None # the cleaned IT reason, kept only when the admin bypass was used
request: Request
label: str | None = None
note: str | None = None
fields: list[str] = field(default_factory=list)
detail: dict[str, Any] = field(default_factory=dict)
transitioned: bool = False
transition_id: int | None = None
noop: bool = False
from_status: str = ""
from_dept: str = ""
@property
def id(self) -> int:
return int(self.packet["id"])
@property
def user_id(self) -> Any:
return self.principal.user_id
@property
def rule(self) -> policy.Rule:
return policy.RULES[self.action]
def log(self, action_label: str, note: str | None = None) -> None:
"""Override the packet_log label (and note) for this write."""
self.label = action_label
if note is not None:
self.note = note
def changed(self, *names: str) -> None:
"""Record the fields this write changed (they go into the audit detail)."""
for n in names:
if n not in self.fields:
self.fields.append(n)
def skip(self) -> None:
"""Nothing changed: no version bump, no log, audit or event."""
self.noop = True
def check(self, action: policy.Action, extra: Mapping[str, Any] | None = None) -> policy.Decision:
"""Check one more action on the same locked packet (for a write that touches several rules)."""
d = policy.check(self.policy_principal, self.state, action, extra=extra or self.extra, it_reason=self.it_reason)
refuse(d)
return d
# ---------------------------------------------------------------- helpers for callers
def refuse(decision: policy.Decision) -> None:
"""Raise the API error for a failed policy decision."""
if decision.ok:
return
status = 400 if decision.code in ASK_CODES else 403
raise ApiError(status, decision.code, decision.why)
def current_of(row: Mapping[str, Any]) -> dict[str, Any]:
return {"status": str(row["status"]), "dept": str(row["dept_code"]), "version": int(row["version"])}
def parse_if_match(request: Request) -> int | None:
"""The If-Match header as a plain integer (quotes and a W/ prefix are tolerated), or None when absent."""
raw = request.headers.get("if-match")
if raw is None:
return None
text = raw.strip().removeprefix("W/").strip().strip('"')
try:
return int(text)
except ValueError:
raise ApiError(400, "bad_if_match", "If-Match must be a version number.") from None
def require_if_match(request: Request) -> int:
v = parse_if_match(request)
if v is None:
raise ApiError(428, "if_match_required", "Send If-Match with the version you started from.")
return v
def check_row_version(row: Mapping[str, Any] | None, if_match: int | None, *, current: Any = None) -> None:
"""409 row_conflict with the current row when its row_version differs from If-Match.
`current` is what goes into the error (defaults to the row itself, JSON-shaped by the caller if needed).
A missing If-Match is 428 if_match_required.
"""
if row is None:
raise ApiError(404, "not_found", "No such line.")
if if_match is None:
raise ApiError(428, "if_match_required", "Send If-Match with the version you started from.")
if int(row["row_version"]) != int(if_match):
raise ApiError(
409, "row_conflict", MSG_ROW, extra={"current": jsonable(current if current is not None else row)}
)
def next_counter(conn: Any, packet_id: int, kind: str, n: int = 1) -> int:
"""Take `n` consecutive numbers from a packet counter (weld, heat, page, co); returns the first one.
Call it inside packet_write: the packet row is locked FOR UPDATE, so concurrent writers serialise on it.
Outside a pipeline the UPDATE itself takes the row lock for the rest of the transaction.
"""
col = COUNTERS.get(kind)
if col is None:
raise ValueError(f"unknown counter {kind!r}")
if n < 1:
raise ValueError("n must be at least 1")
row = conn.execute(
f"UPDATE packets SET {col} = {col} + %s WHERE id = %s RETURNING {col} - %s", # noqa: S608 (col from COUNTERS)
(n, packet_id, n),
).fetchone()
if row is None:
raise ApiError(404, "not_found", "No such packet.")
return int(row[0])
def _norm(value: Any) -> Any:
"""Comparable form of a value: uuid and dates as text, numbers as numbers, '' as None."""
if value is None or value == "":
return None
if isinstance(value, bool | int | float | str):
return value
return str(value)
def apply_field_cas(
current: Mapping[str, Any], set_: Mapping[str, Any], base: Mapping[str, Any] | None
) -> dict[str, Any]:
"""Field-level compare-and-set. For every key in set_ that base also has, base must equal current.
Conflicts raise 409 field_conflict with `current: {key: value}` for the conflicting keys. Keys without a base
entry are written without a check (the old merge3: mine wins). Returns the keys whose value actually changes.
"""
base = base or {}
conflicts = {k: current.get(k) for k in set_ if k in base and _norm(base[k]) != _norm(current.get(k))}
if conflicts:
raise ApiError(409, "field_conflict", MSG_FIELD, extra={"current": jsonable(conflicts)})
return {k: v for k, v in set_.items() if _norm(v) != _norm(current.get(k))}
def jsonable(value: Any) -> Any:
from fastapi.encoders import jsonable_encoder
return jsonable_encoder(value)
# ---------------------------------------------------------------- the pipeline
@contextmanager
def packet_write(
d: deps.Db,
p: deps.Principal,
packet_id: int,
action: policy.Action,
*,
request: Request,
extra: Mapping[str, Any] | None = None,
it_reason: str | None = None,
sign: SignBody | None = None,
expect: Expect | None = None,
sign_content: Any = None,
if_match: int | None = None,
also: Sequence[policy.Action] = (),
require_sign: bool | None = None,
policy_principal: policy.Principal | None = None,
) -> Iterator[Write]:
"""One packet write. See the module docstring for the order of steps.
`also` lists more actions the same write needs (each is checked; the first action is the one logged).
`require_sign` overrides the rule's signature: False skips it (removing a hand-attached drawing), True forces it.
"""
conn = d.conn
extra = dict(extra or {})
pp = policy_principal or packets.policy_principal_for(conn, p)
failure: ApiError | None = None
with conn.transaction():
row = packets.load_row(conn, packet_id, lock=True)
if row is None:
raise ApiError(404, "not_found", "No such packet.")
ch = packets.load_children(conn, row, full=False)
state = packets.state_from(row, ch)
if expect is not None and (expect.status != row["status"] or expect.dept != row["dept_code"]):
raise ApiError(409, "stale_step", MSG_STALE, extra={"current": current_of(row)})
extra.setdefault("holder_name", packets.holder_text(state, ch))
rule = policy.RULES[action]
if not state.removed and state.status == "filed" and not rule.filed_ok:
# Say "filed" rather than "wrong status" or "give an IT reason": nothing but a reopen changes it.
raise ApiError(403, "filed", MSG_FILED)
cleaned_it = reason_ok(it_reason)
decision = policy.check(pp, state, action, extra=extra, it_reason=it_reason)
refuse(decision)
bypass = decision.admin_bypass
for other in also:
dd = policy.check(pp, state, other, extra=extra, it_reason=it_reason)
refuse(dd)
bypass = bypass or dd.admin_bypass
if if_match is not None and int(if_match) != int(row["version"]):
raise ApiError(409, "version_conflict", MSG_VERSION, extra={"current": current_of(row)})
w = Write(
conn=conn,
principal=p,
policy_principal=pp,
packet=row,
state=state,
decision=decision,
signature=None,
version=int(row["version"]),
action=action,
extra=extra,
it_reason=cleaned_it if bypass else None,
request=request,
from_status=str(row["status"]),
from_dept=str(row["dept_code"]),
)
meaning = decision.signature
needs = bool(meaning) if require_sign is None else require_sign
if needs:
if sign is None:
raise ApiError(
400,
"signature_required",
"This step needs your signature.",
extra={"meaning": meaning or LABELS.get(action, str(action))},
)
try:
w.signature = signatures.sign(
conn,
deps.directory_of(request),
p.actor,
factor=sign.factor,
secret=sign.secret,
action=f"packet.{action}",
meaning=meaning,
packet_id=packet_id,
subject_type="packet",
subject_id=packet_id,
content=sign_content
if sign_content is not None
else {"action": str(action), "packet_id": packet_id, "version": int(row["version"])},
ip=p.ip,
settings=deps.settings_of(request),
)
except ApiError as e:
failure = e # commit the step-up failure records, then raise below
if failure is None:
yield w
_finish(w)
if failure is not None:
raise failure
def _finish(w: Write) -> None:
if w.noop:
return
if w.rule.transition and not w.transitioned:
apply_transition(w)
conn = w.conn
w.version = int(
conn.execute("UPDATE packets SET version = version + 1 WHERE id = %s RETURNING version", (w.id,)).fetchone()[0]
)
after = conn.execute("SELECT status::text, dept_code FROM packets WHERE id = %s", (w.id,)).fetchone()
status, dept = str(after[0]), str(after[1])
label = w.label or LABELS.get(w.action, str(w.action).replace("_", " ").capitalize())
note = clean_opt(w.note, LIMITS["note"]) if w.note else None
sig_id = w.signature.id if w.signature else None
conn.execute(
"INSERT INTO packet_log (packet_id, by, by_name, role, action, note, it_reason, signature_id, transition_id)"
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
(
w.id,
w.user_id,
w.principal.user["display_name"],
w.principal.role_snapshot,
label,
note,
w.it_reason,
sig_id,
w.transition_id,
),
)
detail: dict[str, Any] = {"from": w.from_status, "to": status, "dept": dept}
if w.from_dept != dept:
detail["from_dept"] = w.from_dept
if w.fields:
detail["fields"] = w.fields
if note:
detail["note"] = note
if sig_id:
detail["signature_id"] = sig_id
detail.update(w.detail)
audit.append(
conn,
action=AUDIT_ACTIONS.get(w.action, f"packet.{w.action}"),
user_id=w.user_id,
session_id=w.principal.session_id,
role_snapshot=w.principal.role_snapshot,
subject_type="packet",
subject_id=w.id,
packet_id=w.id,
detail=detail,
it_reason=w.it_reason,
ip=w.principal.ip,
)
events.publish(conn, {"event": "changed", "packet_id": w.id, "version": w.version, "status": status, "dept": dept})
# ---------------------------------------------------------------- transitions and their side effects
CLOCK_STOP = (
"clock_elapsed_ms = clock_elapsed_ms + CASE WHEN clock_running_since IS NULL THEN 0"
" ELSE (extract(epoch FROM (now() - clock_running_since)) * 1000)::bigint END, clock_running_since = NULL"
)
def _reason(w: Write) -> str | None:
return reason_ok(w.extra.get("reason"))
def _note(w: Write) -> str | None:
return clean_opt(w.extra.get("note"), LIMITS["note"])
def _wh_incoming(w: Write) -> None:
w.conn.execute("UPDATE packet_wh SET closed_at = now() WHERE packet_id = %s AND closed_at IS NULL", (w.id,))
w.conn.execute(
"INSERT INTO packet_wh (packet_id, stage, by, note) VALUES (%s, 'incoming', %s, %s)",
(w.id, w.user_id, _note(w)),
)
def _wh_close(w: Write) -> None:
w.conn.execute("UPDATE packet_wh SET closed_at = now() WHERE packet_id = %s AND closed_at IS NULL", (w.id,))
def _open_stage(w: Write, dept: str) -> None:
w.conn.execute("INSERT INTO packet_stages (packet_id, dept_code) VALUES (%s, %s)", (w.id, dept))
def _close_stage(w: Write, qc_snapshot: Any = None) -> None:
w.conn.execute(
f"UPDATE packet_stages SET {CLOCK_STOP}, closed_at = now(), qc_snapshot = coalesce(%s, qc_snapshot)," # noqa: S608
" row_version = row_version + 1 WHERE packet_id = %s AND closed_at IS NULL",
(None if qc_snapshot is None else Jsonb(jsonable(qc_snapshot)), w.id),
)
def _stage_update(w: Write, assignments: str, params: Sequence[Any] = ()) -> None:
w.conn.execute(
f"UPDATE packet_stages SET {assignments}, row_version = row_version + 1" # noqa: S608 (fixed fragments)
" WHERE packet_id = %s AND closed_at IS NULL",
(*params, w.id),
)
def _open_inspection(w: Write) -> dict[str, Any] | None:
return packets.one(w.conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (w.id,))
def _close_inspection(w: Write, insp: Mapping[str, Any] | None, dept: str) -> None:
"""Close the open inspection; when it had a result, keep it in qc_history."""
if insp is None:
return
w.conn.execute(
"UPDATE qc_inspections SET closed_at = now(), row_version = row_version + 1 WHERE id = %s", (insp["id"],)
)
if insp.get("result"):
w.conn.execute(
"INSERT INTO qc_history (packet_id, result, inspector, note, dept_code, snapshot)"
" VALUES (%s, %s, %s, %s, %s, %s)",
(w.id, insp["result"], insp.get("inspector"), insp.get("result_note"), dept, Jsonb(jsonable(dict(insp)))),
)
def _set_result(w: Write, insp: Mapping[str, Any] | None, result: str, note: str | None) -> dict[str, Any] | None:
if insp is None:
return None
return packets.one(
w.conn,
"UPDATE qc_inspections SET result = %s, result_at = now(), result_by = %s, result_note = %s,"
" signature_id = %s, row_version = row_version + 1 WHERE id = %s RETURNING *",
(result, w.user_id, note, w.signature.id if w.signature else None, insp["id"]),
)
def _set_packet(w: Write, assignments: str, params: Sequence[Any] = ()) -> None:
w.conn.execute(f"UPDATE packets SET {assignments} WHERE id = %s", (*params, w.id)) # noqa: S608 (fixed fragments)
def _settings(w: Write) -> Any:
return deps.settings_of(w.request)
def seal_key_id(key: bytes) -> str:
return hashlib.sha256(key).hexdigest()[:8]
def seal_hash(key: bytes, content: Any) -> bytes:
return hmac.new(key, signatures.canonical_json(content), hashlib.sha256).digest()
def _seal_key(w: Write) -> bytes:
try:
key: bytes = _settings(w).seal_key()
except SecretFileError as e:
log.error("seal key unavailable: %s", e)
raise ApiError(503, "server_misconfigured", "The server is missing its seal key.") from None
return key
def apply_transition(w: Write) -> None:
"""Set status, status_at and dept, write the packet_transitions row and run the action's side effects."""
if w.transitioned:
return
w.transitioned = True
a = w.action
rule = w.rule
to_status = rule.to_status
assert to_status is not None
ex = w.extra
reason = _reason(w)
from_dept = w.from_dept
to_dept = from_dept
if rule.dept_change != "no" and isinstance(ex.get("dept"), str) and ex.get("dept"):
to_dept = str(ex["dept"])
if a in (A.SEND_TO_WAREHOUSE, A.VIS_AUTO_RELEASE):
_set_packet(w, "wh_skipped = false, wh_sent_at = now(), return_note = NULL")
_wh_incoming(w)
elif a is A.RELEASE_TO_SHOP:
_set_packet(
w,
"wh_skipped = true, for_sup_id = %s, return_note = %s",
(ex.get("for_sup_id") or None, _note(w)),
)
_close_stage(w)
_open_stage(w, to_dept)
elif a is A.SEND_DRAFT_TO_QC:
_set_packet(w, "wh_skipped = true")
_close_inspection(w, _open_inspection(w), from_dept)
w.conn.execute(
"INSERT INTO qc_inspections (packet_id, sent_by, sent_note) VALUES (%s, %s, %s)",
(w.id, w.user_id, _note(w)),
)
elif a is A.WH_SEND_BACK:
_set_packet(w, "return_note = %s", (reason,))
_wh_close(w)
elif a is A.RETURN_TO_CLERICAL:
_set_packet(w, "return_note = %s", (reason,))
_close_stage(w)
elif a is A.WH_RELEASE:
_wh_close(w)
_close_stage(w)
_open_stage(w, to_dept)
elif a is A.SEND_BACK_TO_WAREHOUSE:
_set_packet(w, "return_note = %s, wh_skipped = false", (reason,))
_close_stage(w)
_wh_incoming(w)
elif a is A.ASSIGN:
station_id = int(ex["station_id"])
welder_id = str(ex["welder_id"])
stamp = clean_opt(ex.get("stamp"), LIMITS["stamp"])
if stamp is None:
r = w.conn.execute("SELECT welder_stamp FROM users WHERE id = %s", (welder_id,)).fetchone()
stamp = r[0] if r else None
_set_packet(w, "station_id = %s, welder_id = %s, welder_stamp = %s", (station_id, welder_id, stamp))
if not packets.one(w.conn, "SELECT id FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,)):
_open_stage(w, from_dept)
_stage_update(w, "station_id = %s, welder_id = %s, welder_stamp = %s", (station_id, welder_id, stamp))
elif a in (A.UNASSIGN, A.PULL_BACK):
_set_packet(w, "station_id = NULL, welder_id = NULL, welder_stamp = NULL")
_stage_update(w, f"{CLOCK_STOP}, station_id = NULL, welder_id = NULL, welder_stamp = NULL")
elif a is A.START:
_stage_update(w, "clock_running_since = now(), clock_started_at = coalesce(clock_started_at, now())")
elif a is A.SUBMIT:
_set_packet(w, "return_note = NULL")
_stage_update(w, CLOCK_STOP)
elif a is A.RETURN_TO_STATION:
_set_packet(w, "return_note = %s", (reason,))
elif a is A.SEND_TO_QC:
_close_inspection(w, _open_inspection(w), from_dept)
stage = packets.one(w.conn, "SELECT id FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,))
w.conn.execute(
"INSERT INTO qc_inspections (packet_id, stage_id, sent_by, book, spool, location, sent_note)"
" VALUES (%s, %s, %s, %s, %s, %s, %s)",
(
w.id,
stage["id"] if stage else None,
w.user_id,
ex.get("book") is True,
ex.get("spool") is True,
clean_opt(ex.get("location"), LIMITS["location"]),
_note(w),
),
)
elif a in (A.ROUTE_FROM_REVIEW, A.QC_ACCEPT_AND_ROUTE):
insp = _open_inspection(w)
if a is A.QC_ACCEPT_AND_ROUTE:
insp = _set_result(w, insp, "accepted", _note(w))
_close_stage(w, qc_snapshot=insp)
_close_inspection(w, insp, from_dept)
_open_stage(w, to_dept)
_set_packet(w, "station_id = NULL, welder_id = NULL, welder_stamp = NULL, return_note = NULL, routed_to = NULL")
elif a is A.QC_REJECT:
_set_result(w, _open_inspection(w), "rejected", reason)
_set_packet(w, "return_note = %s", (reason,))
if not packets.one(w.conn, "SELECT id FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,)):
_open_stage(w, from_dept) # a draft sent straight to Q.C. has no stage yet
elif a is A.QC_ACCEPT:
insp = _set_result(w, _open_inspection(w), "accepted", _note(w))
_close_inspection(w, insp, from_dept)
_close_stage(w, qc_snapshot=insp)
_set_packet(w, "routed_to = %s::route_kind", (str(ex["routed_to"]),))
_set_packet(w, "status = %s::packet_status, status_at = now(), dept_code = %s", (to_status, to_dept))
if a is A.FILE:
key = _seal_key(w)
row = packets.load_row(w.conn, w.id)
assert row is not None
content = packets.seal_content(w.conn, row)
w.conn.execute(
"INSERT INTO packet_seals (packet_id, hash, key_id, sealed_by, signature_id) VALUES (%s, %s, %s, %s, %s)",
(w.id, seal_hash(key, content), seal_key_id(key), w.user_id, w.signature.id if w.signature else None),
)
elif a is A.ADMIN_REOPEN:
w.conn.execute(
"UPDATE packet_seals SET broken_at = now(), broken_by = %s, broken_reason = %s"
" WHERE packet_id = %s AND broken_at IS NULL",
(w.user_id, reason, w.id),
)
tid = w.conn.execute(
"INSERT INTO packet_transitions (packet_id, by, action, from_status, to_status, from_dept, to_dept, reason,"
" signature_id, it_reason) VALUES (%s, %s, %s, %s::packet_status, %s::packet_status, %s, %s, %s, %s, %s)"
" RETURNING id",
(
w.id,
w.user_id,
str(a),
w.from_status,
to_status,
from_dept,
to_dept,
reason or _note(w),
w.signature.id if w.signature else None,
w.it_reason,
),
).fetchone()
w.transition_id = int(tid[0])
if w.note is None:
w.note = reason or _note(w)
def verify_seal(conn: Any, settings: Any, packet_id: int) -> dict[str, Any]:
"""The seal history of a packet and whether the current unbroken seal still matches the packet."""
seals = packets.rows(conn, "SELECT * FROM packet_seals WHERE packet_id = %s ORDER BY id DESC", (packet_id,))
people = packets.People(conn, [s["sealed_by"] for s in seals] + [s["broken_by"] for s in seals])
current = next((s for s in seals if s["broken_at"] is None), None)
valid: bool | None = None
if current is not None:
try:
key = settings.seal_key()
except SecretFileError:
key = None
if key is not None and seal_key_id(key) == current["key_id"]:
row = packets.load_row(conn, packet_id)
assert row is not None
valid = hmac.compare_digest(seal_hash(key, packets.seal_content(conn, row)), bytes(current["hash"]))
def shape(s: Mapping[str, Any]) -> dict[str, Any]:
return {
"id": s["id"],
"sealed_at": s["sealed_at"],
"sealed_by": people.ref(s["sealed_by"]),
"key_id": s["key_id"],
"hash": bytes(s["hash"]).hex(),
"signed": s["signature_id"] is not None,
"broken_at": s["broken_at"],
"broken_by": people.ref(s["broken_by"]),
"broken_reason": s["broken_reason"],
}
return {"seal": shape(current) if current else None, "valid": valid, "seals": [shape(s) for s in seals]}
+876
View File
@@ -0,0 +1,876 @@
"""Projects and units: numbers, lots, aliases, renumbering, field CAS, soft delete and packet counts.
Ports the old projects.js rules. A unit's number is its project's number plus its lot. Every old number
of a project or unit stays in project_aliases, so a lookup by any number still finds it. Packets point at
units by id, so a renumber never touches a packet. Numbers and lots are text: never cast them.
"""
from __future__ import annotations
import re
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from datetime import date
from typing import Any
from psycopg import errors as pgerr
from psycopg.rows import dict_row
from .. import audit, events
from ..api.errors import ApiError
from ..text import LIMITS, RX_LOT, RX_PROJECT, clean_opt, clean_text
STATUSES = ("open", "hold", "closed")
LIST_FILTERS = ("active", "open", "hold", "closed", "all")
SHOP_STATUSES = ("ready", "assigned", "in_progress", "review")
COUNT_KEYS = ("draft", "warehouse", "shop", "qc", "complete", "filed", "total")
MAX_UNITS_AT_ONCE = 200
MAX_WORDS = 8
LIST_LIMIT = 500
PROJECT_FIELDS = ("name", "customer", "po", "due", "status", "notes")
UNIT_FIELDS = ("name", "due", "status", "notes")
_DIGITS = re.compile(r"^[0-9]+$")
_ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
COUNTS_SQL = (
"count(*) FILTER (WHERE k.status = 'draft') AS c_draft,"
" count(*) FILTER (WHERE k.status = 'warehouse') AS c_warehouse,"
" count(*) FILTER (WHERE k.status IN ('ready', 'assigned', 'in_progress', 'review')) AS c_shop,"
" count(*) FILTER (WHERE k.status = 'qc') AS c_qc,"
" count(*) FILTER (WHERE k.status = 'complete') AS c_complete,"
" count(*) FILTER (WHERE k.status = 'filed') AS c_filed,"
" count(*) AS c_total"
)
PROJECT_SELECT = (
"SELECT p.*," # noqa: S608
" (SELECT count(*) FROM units u WHERE u.project_id = p.id AND u.removed_at IS NULL) AS unit_count,"
" c.*"
" FROM projects p"
" LEFT JOIN LATERAL (SELECT " + COUNTS_SQL + " FROM packets k JOIN units u ON u.id = k.unit_id"
" WHERE u.project_id = p.id AND k.removed_at IS NULL) c ON true"
)
UNIT_SELECT = (
"SELECT v.*, c.* FROM v_units v" # noqa: S608
" LEFT JOIN LATERAL (SELECT " + COUNTS_SQL + " FROM packets k"
" WHERE k.unit_id = v.id AND k.removed_at IS NULL) c ON true"
)
@dataclass(frozen=True)
class Who:
"""Who is writing, for the audit row. it_reason is set only when an admin acts outside their roles."""
user_id: Any
session_id: Any = None
role_snapshot: str | None = None
ip: str | None = None
it_reason: str | None = None
# ---------------------------------------------------------------- normalisation
def normalize_number(raw: Any) -> str:
"""A project number: cleaned, upper-cased, RX_PROJECT. 422 invalid_number otherwise."""
n = clean_text(raw, 200).upper()
if not RX_PROJECT.match(n):
raise ApiError(
422,
"invalid_number",
"A project number is 1 to 20 letters, digits or dashes, starting with a letter or digit.",
)
return n
def normalize_lot(raw: Any) -> str:
"""A unit lot: upper-cased, digits zero-padded to 3. `"7"` -> `"007"`, `"7a"` -> `"7A"`. 422 invalid_lot."""
lot = clean_text(raw, 40).upper()
if _DIGITS.match(lot) and len(lot) < 3:
lot = lot.zfill(3)
if not RX_LOT.match(lot) or (_DIGITS.match(lot) and len(lot) != 3):
raise ApiError(422, "invalid_lot", f"Lot {lot or '(blank)'}: 1 to 3 letters or digits, numbers as 001 to 999.")
return lot
def lot_input(project_number: str, raw: Any) -> str:
"""A lot as typed: a full unit number that starts with the project number is cut down to its lot."""
text = clean_text(raw, 40).upper()
pn = project_number.upper()
if len(text) > 3 and text.startswith(pn) and len(text) > len(pn):
text = text[len(pn) :]
return normalize_lot(text)
def parse_due(raw: Any) -> date | None:
if raw is None or (isinstance(raw, str) and not raw.strip()):
return None
if isinstance(raw, date):
return raw
text = str(raw).strip()
try:
if not _ISO_DATE.match(text):
raise ValueError(text)
return date.fromisoformat(text)
except ValueError:
raise ApiError(422, "invalid_date", "Give the due date as YYYY-MM-DD.") from None
def parse_status(raw: Any) -> str:
s = clean_text(raw, 20).lower()
if s not in STATUSES:
raise ApiError(422, "invalid_status", "Status is open, hold or closed.")
return s
def clean_reason(raw: Any) -> str:
text = clean_text(raw, LIMITS["reason"])
if not text:
raise ApiError(400, "reason_required", "Give a reason.")
return text
def _like(word: str) -> str:
return "%" + word.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
def _words(q: str | None) -> list[str]:
return [w for w in clean_text(q, 200).split() if w][:MAX_WORDS]
def _iso(v: Any) -> Any:
return v.isoformat() if isinstance(v, date) else v
def _counts(r: Mapping[str, Any]) -> dict[str, int]:
return {k: int(r.get("c_" + k) or 0) for k in COUNT_KEYS}
def _removed(r: Mapping[str, Any]) -> dict[str, Any] | None:
if r.get("removed_at") is None:
return None
by = r.get("removed_by")
return {"at": r["removed_at"], "by": str(by) if by else None, "reason": r.get("removed_reason")}
# ---------------------------------------------------------------- shapes
def project_out(r: Mapping[str, Any]) -> dict[str, Any]:
return {
"id": r["id"],
"number": r["number"],
"name": r["name"],
"customer": r["customer"],
"po": r["po"],
"due": r["due"],
"status": r["status"],
"notes": r["notes"],
"source": r["source"],
"needs_details": r["needs_details"],
"units": int(r.get("unit_count") or 0),
"packets": _counts(r),
"version": r["version"],
"created_at": r["created_at"],
"updated_at": r["updated_at"],
"removed": _removed(r),
}
def unit_out(r: Mapping[str, Any]) -> dict[str, Any]:
status = r["status"]
return {
"id": r["id"],
"project_id": r["project_id"],
"number": r["number"],
"lot": r["lot"],
"name": r["name"],
"due": r["due"],
"due_effective": r["due_effective"],
"status": status,
"status_effective": "closed" if r["project_status"] == "closed" else status,
"notes": r["notes"],
"packets": _counts(r),
"version": r["version"],
"created_at": r["created_at"],
"updated_at": r["updated_at"],
"removed": _removed(r),
}
# ---------------------------------------------------------------- lookups
def owners(conn: Any, number: str) -> list[dict[str, Any]]:
"""Everything that answers to `number`: live project and unit numbers first, then aliases of live ones."""
n = clean_text(number, 40).upper()
if not n:
return []
with conn.cursor(row_factory=dict_row) as cur:
rows: list[dict[str, Any]] = cur.execute(
"SELECT * FROM ("
" SELECT 0 AS rank, 'project' AS kind, p.id, p.id AS project_id, p.number, false AS alias"
" FROM projects p WHERE upper(p.number) = %(n)s AND p.removed_at IS NULL"
" UNION ALL"
" SELECT 1, 'unit', u.id, u.project_id, p.number || u.lot, false"
" FROM units u JOIN projects p ON p.id = u.project_id"
" WHERE upper(p.number || u.lot) = %(n)s AND u.removed_at IS NULL AND p.removed_at IS NULL"
" UNION ALL"
" SELECT 2, CASE WHEN a.unit_id IS NULL THEN 'project' ELSE 'unit' END, coalesce(a.unit_id, a.project_id),"
" a.project_id, CASE WHEN a.unit_id IS NULL THEN p.number ELSE p.number || u.lot END, true"
" FROM project_aliases a JOIN projects p ON p.id = a.project_id AND p.removed_at IS NULL"
" LEFT JOIN units u ON u.id = a.unit_id"
" WHERE a.alias = %(n)s AND (a.unit_id IS NULL OR u.removed_at IS NULL)"
") x ORDER BY rank, id",
{"n": n},
).fetchall()
return rows
def lookup(conn: Any, number: str) -> dict[str, Any] | None:
"""`{kind, id, project_id, number, alias}` for a current or old number, or None."""
found = owners(conn, number)
if not found:
return None
o = found[0]
return {"kind": o["kind"], "id": o["id"], "project_id": o["project_id"], "number": o["number"], "alias": o["alias"]}
def _taken(o: Mapping[str, Any]) -> ApiError:
if o["alias"]:
msg = f"That number is an old number of {o['number']}. Pick another number."
elif o["kind"] == "unit":
msg = f"{o['number']} already exists as a unit."
else:
msg = f"{o['number']} already exists as a project."
return ApiError(409, "number_taken", msg, extra={"taken": {"kind": o["kind"], "id": o["id"], "alias": o["alias"]}})
def assert_free(conn: Any, number: str, *, own_project: int | None = None, own_unit: int | None = None) -> None:
"""409 number_taken unless nothing answers to `number`, or only the entity's own old number does."""
for o in owners(conn, number):
if o["alias"] and own_unit is not None and o["kind"] == "unit" and o["id"] == own_unit:
continue
if o["alias"] and own_project is not None and o["kind"] == "project" and o["id"] == own_project:
continue
raise _taken(o)
def next_lot(conn: Any, project_id: int, number: str) -> str | None:
"""The old nextUnitNumber: highest numeric lot plus one, skipping numbers already used (old ones too)."""
rows = conn.execute(
"SELECT lot FROM units WHERE project_id = %s AND removed_at IS NULL AND lot ~ '^[0-9]{3}$'", (project_id,)
).fetchall()
n = max((int(r[0]) for r in rows), default=0) + 1
while n <= 999:
lot = f"{n:03d}"
if not owners(conn, number + lot):
return lot
n += 1
return None
def _unique_taken(e: Exception) -> ApiError:
return ApiError(409, "number_taken", "That number was just taken. Pick another number.")
# ---------------------------------------------------------------- reads
def get_project(conn: Any, project_id: int, *, removed: bool = False) -> dict[str, Any] | None:
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(PROJECT_SELECT + " WHERE p.id = %s", (project_id,)).fetchone()
if r is None or (r["removed_at"] is not None and not removed):
return None
return project_out(r)
def _project_or_404(conn: Any, project_id: int) -> dict[str, Any]:
out = get_project(conn, project_id)
if out is None:
raise ApiError(404, "not_found", "No such project.")
return out
def aliases_of(conn: Any, *, project_id: int | None = None, unit_id: int | None = None) -> list[dict[str, Any]]:
with conn.cursor(row_factory=dict_row) as cur:
if unit_id is not None:
rows = cur.execute(
"SELECT alias, kind, unit_id, created_at FROM project_aliases WHERE unit_id = %s"
" ORDER BY created_at, alias",
(unit_id,),
).fetchall()
else:
rows = cur.execute(
"SELECT alias, kind, unit_id, created_at FROM project_aliases WHERE project_id = %s"
" ORDER BY created_at, alias",
(project_id,),
).fetchall()
return [dict(r) for r in rows]
def project_detail(conn: Any, project_id: int) -> dict[str, Any]:
out = _project_or_404(conn, project_id)
with conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute(
UNIT_SELECT + " WHERE v.project_id = %s AND v.removed_at IS NULL ORDER BY v.lot", (project_id,)
).fetchall()
out["units"] = [unit_out(r) for r in rows]
out["unit_count"] = len(rows)
out["aliases"] = aliases_of(conn, project_id=project_id)
out["next_lot"] = next_lot(conn, project_id, out["number"])
return out
def list_projects(conn: Any, *, q: str | None = None, status: str = "active") -> list[dict[str, Any]]:
if status not in LIST_FILTERS:
raise ApiError(422, "invalid_status", "Status filter is active, open, hold, closed or all.")
where = ["p.removed_at IS NULL"]
params: list[Any] = []
if status == "active":
where.append("p.status <> 'closed'")
elif status != "all":
where.append("p.status = %s")
params.append(status)
for w in _words(q):
where.append(
"(p.number ILIKE %s OR p.name ILIKE %s OR p.customer ILIKE %s OR p.po ILIKE %s"
" OR EXISTS (SELECT 1 FROM project_aliases a WHERE a.project_id = p.id AND a.alias ILIKE %s))"
)
pat = _like(w)
params.extend([pat, pat, pat, pat, pat])
sql = PROJECT_SELECT + " WHERE " + " AND ".join(where) + f" ORDER BY p.number LIMIT {LIST_LIMIT}"
with conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute(sql, params).fetchall()
return [project_out(r) for r in rows]
def get_unit(conn: Any, unit_id: int, *, removed: bool = False) -> dict[str, Any] | None:
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(UNIT_SELECT + " WHERE v.id = %s", (unit_id,)).fetchone()
if r is None or (r["removed_at"] is not None and not removed):
return None
return unit_out(r)
def _unit_or_404(conn: Any, unit_id: int) -> dict[str, Any]:
out = get_unit(conn, unit_id)
if out is None:
raise ApiError(404, "not_found", "No such unit.")
return out
def unit_detail(conn: Any, unit_id: int) -> dict[str, Any]:
out = _unit_or_404(conn, unit_id)
p = get_project(conn, out["project_id"])
if p is None:
raise ApiError(404, "not_found", "No such unit.")
out["project"] = {k: p[k] for k in ("id", "number", "name", "customer", "po", "due", "status", "version")}
out["aliases"] = aliases_of(conn, unit_id=unit_id)
return out
def list_units(
conn: Any, *, project_id: int | None = None, q: str | None = None, status: str = "active"
) -> list[dict[str, Any]]:
"""Units for the picker's second step. Status applies to the effective status (a closed project closes all)."""
if status not in LIST_FILTERS:
raise ApiError(422, "invalid_status", "Status filter is active, open, hold, closed or all.")
eff = "(CASE WHEN v.project_status = 'closed' THEN 'closed' ELSE v.status::text END)"
where = [
"v.removed_at IS NULL",
"EXISTS (SELECT 1 FROM projects p WHERE p.id = v.project_id AND p.removed_at IS NULL)",
]
params: list[Any] = []
if project_id is not None:
where.append("v.project_id = %s")
params.append(project_id)
if status == "active":
where.append(f"{eff} <> 'closed'")
elif status != "all":
where.append(f"{eff} = %s")
params.append(status)
for w in _words(q):
where.append(
"(v.number ILIKE %s OR v.lot ILIKE %s OR v.name ILIKE %s"
" OR EXISTS (SELECT 1 FROM project_aliases a WHERE a.unit_id = v.id AND a.alias ILIKE %s))"
)
pat = _like(w)
params.extend([pat, pat, pat, pat])
sql = UNIT_SELECT + " WHERE " + " AND ".join(where) + f" ORDER BY v.project_number, v.lot LIMIT {LIST_LIMIT}"
with conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute(sql, params).fetchall()
return [unit_out(r) for r in rows]
# ---------------------------------------------------------------- writes: helpers
def _audit(conn: Any, who: Who, action: str, subject_type: str, subject_id: Any, detail: Any) -> None:
audit.append(
conn,
action=action,
user_id=who.user_id,
session_id=who.session_id,
role_snapshot=who.role_snapshot,
subject_type=subject_type,
subject_id=subject_id,
detail=detail,
it_reason=who.it_reason,
ip=who.ip,
)
def _publish(conn: Any, project_id: int, unit_id: int | None = None, action: str = "") -> None:
payload: dict[str, Any] = {"event": "changed", "kind": "project", "project_id": project_id, "action": action}
if unit_id is not None:
payload["unit_id"] = unit_id
events.publish(conn, payload)
def _lock_project(conn: Any, project_id: int) -> dict[str, Any]:
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(
"SELECT * FROM projects WHERE id = %s AND removed_at IS NULL FOR UPDATE", (project_id,)
).fetchone()
if r is None:
raise ApiError(404, "not_found", "No such project.")
return dict(r)
def _lock_unit(conn: Any, unit_id: int) -> tuple[dict[str, Any], dict[str, Any]]:
"""Lock the unit's project, then the unit (always in that order). Returns (unit, project)."""
row = conn.execute("SELECT project_id FROM units WHERE id = %s AND removed_at IS NULL", (unit_id,)).fetchone()
if row is None:
raise ApiError(404, "not_found", "No such unit.")
try:
project = _lock_project(conn, int(row[0]))
except ApiError:
raise ApiError(404, "not_found", "No such unit.") from None
with conn.cursor(row_factory=dict_row) as cur:
u = cur.execute("SELECT * FROM units WHERE id = %s AND removed_at IS NULL FOR UPDATE", (unit_id,)).fetchone()
if u is None:
raise ApiError(404, "not_found", "No such unit.")
return dict(u), project
def _cmp(v: Any) -> Any:
if v is None:
return None
if isinstance(v, date):
return v.isoformat()
text = str(v).strip()
return text or None
def _norm_field(key: str, value: Any, *, unit: bool) -> Any:
if key == "due":
return parse_due(value)
if key == "status":
return parse_status(value)
if key == "name":
return clean_opt(value, LIMITS["unit_name"] if unit else LIMITS["project_name"])
if key == "customer":
return clean_opt(value, LIMITS["customer"])
if key == "po":
return clean_opt(value, LIMITS["po"])
if key == "notes":
return clean_opt(value, LIMITS["notes"])
raise ApiError(422, "invalid_field", f"{key} cannot be changed here.")
def field_cas(
row: Mapping[str, Any], values: Mapping[str, Any], base: Mapping[str, Any], fields: Iterable[str], *, unit: bool
) -> dict[str, Any]:
"""The field-level CAS: returns {key: new value} for the keys that change; 409 field_conflict on a stale base.
A key whose base differs from the current value conflicts, unless the new value already equals the current one.
Keys without a base entry are written without a check.
"""
allowed = set(fields)
bad = [k for k in values if k not in allowed]
if bad:
raise ApiError(422, "invalid_field", f"{', '.join(sorted(bad))} cannot be changed here.")
new = {k: _norm_field(k, v, unit=unit) for k, v in values.items()}
conflicts = {
k: _iso(row[k]) for k in new if k in base and _cmp(base[k]) != _cmp(row[k]) and _cmp(new[k]) != _cmp(row[k])
}
if conflicts:
raise ApiError(
409,
"field_conflict",
"Someone else changed this since you opened it. Check the new values.",
extra={"current": conflicts},
)
return {k: v for k, v in new.items() if _cmp(v) != _cmp(row[k])}
def _change_detail(row: Mapping[str, Any], changes: Mapping[str, Any]) -> dict[str, Any]:
out: dict[str, Any] = {}
for k, v in changes.items():
out[k] = {"changed": True} if k == "notes" else {"from": _iso(row[k]), "to": _iso(v)}
return out
# ---------------------------------------------------------------- writes: projects
def create_project(conn: Any, who: Who, data: Mapping[str, Any]) -> dict[str, Any]:
number = normalize_number(data.get("number"))
name = clean_opt(data.get("name"), LIMITS["project_name"])
customer = clean_opt(data.get("customer"), LIMITS["customer"])
po = clean_opt(data.get("po"), LIMITS["po"])
notes = clean_opt(data.get("notes"), LIMITS["notes"])
due = parse_due(data.get("due"))
status = parse_status(data.get("status") or "open")
try:
with conn.transaction():
conn.execute("SELECT pg_advisory_xact_lock(7312)") # one number allocation at a time
assert_free(conn, number)
pid = conn.execute(
"INSERT INTO projects (number, name, customer, customer_src, po, po_src, due, status, notes, source,"
" created_by) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'hand', %s) RETURNING id",
(
number,
name,
customer,
"hand" if customer else None,
po,
"hand" if po else None,
due,
status,
notes,
who.user_id,
),
).fetchone()[0]
_audit(
conn,
who,
"project.created",
"project",
pid,
{"number": number, "name": name, "customer": customer, "po": po, "due": _iso(due), "status": status},
)
_publish(conn, pid, action="project.created")
except pgerr.UniqueViolation as e:
raise _unique_taken(e) from None
return _project_or_404(conn, pid)
def update_project(
conn: Any, who: Who, project_id: int, values: Mapping[str, Any], base: Mapping[str, Any]
) -> dict[str, Any]:
with conn.transaction():
row = _lock_project(conn, project_id)
changes = field_cas(row, values, base, PROJECT_FIELDS, unit=False)
if changes:
cols = dict(changes)
if "customer" in cols:
cols["customer_src"] = "hand" if cols["customer"] else None
if "po" in cols:
cols["po_src"] = "hand" if cols["po"] else None
sets = ", ".join(f"{k} = %s" for k in cols)
conn.execute(
f"UPDATE projects SET {sets}, version = version + 1, updated_at = now() WHERE id = %s", # noqa: S608
[*cols.values(), project_id],
)
_audit(
conn,
who,
"project.updated",
"project",
project_id,
{"number": row["number"], "fields": _change_detail(row, changes)},
)
_publish(conn, project_id, action="project.updated")
return _project_or_404(conn, project_id)
def renumber_project(conn: Any, who: Who, project_id: int, number: Any, reason: Any) -> dict[str, Any]:
new = normalize_number(number)
why = clean_reason(reason)
try:
with conn.transaction():
conn.execute("SELECT pg_advisory_xact_lock(7312)")
row = _lock_project(conn, project_id)
old = row["number"]
if new == old.upper():
raise ApiError(400, "same_number", f"The project is already {old}.")
units = conn.execute(
"SELECT id, lot FROM units WHERE project_id = %s AND removed_at IS NULL ORDER BY lot FOR UPDATE",
(project_id,),
).fetchall()
assert_free(conn, new, own_project=project_id)
for uid, lot in units:
for o in owners(conn, new + lot):
own_live = o["kind"] == "unit" and not o["alias"] and o["project_id"] == project_id
own_old = o["kind"] == "unit" and o["alias"] and o["id"] == uid
if not (own_live or own_old):
raise _taken(o)
old_numbers = {old.upper(): None, **{(old + lot).upper(): uid for uid, lot in units}}
new_numbers = [new, *[new + lot for _, lot in units]]
if set(old_numbers) & set(new_numbers):
raise ApiError(409, "number_taken", "The new numbers overlap the old ones. Pick another number.")
conn.execute(
"DELETE FROM project_aliases WHERE alias = ANY(%s) AND project_id = %s", (new_numbers, project_id)
)
for alias, uid in old_numbers.items():
conn.execute(
"INSERT INTO project_aliases (alias, project_id, unit_id, kind) VALUES (%s, %s, %s, 'renumber')"
" ON CONFLICT (alias) DO UPDATE SET project_id = EXCLUDED.project_id, unit_id = EXCLUDED.unit_id,"
" kind = EXCLUDED.kind, created_at = now()",
(alias, project_id, uid),
)
conn.execute(
"UPDATE projects SET number = %s, version = version + 1, updated_at = now() WHERE id = %s",
(new, project_id),
)
conn.execute(
"INSERT INTO project_renumbers (by, project_id, unit_id, old_number, new_number, reason)"
" VALUES (%s, %s, NULL, %s, %s, %s)",
(who.user_id, project_id, old, new, why),
)
for uid, lot in units:
conn.execute(
"INSERT INTO project_renumbers (by, project_id, unit_id, old_number, new_number, reason)"
" VALUES (%s, %s, %s, %s, %s, %s)",
(who.user_id, project_id, uid, old + lot, new + lot, why),
)
_audit(
conn,
who,
"project.renumbered",
"project",
project_id,
{"from": old, "to": new, "reason": why, "units": len(units)},
)
_publish(conn, project_id, action="project.renumbered")
except pgerr.UniqueViolation as e:
raise _unique_taken(e) from None
return _project_or_404(conn, project_id)
def _live_packets(conn: Any, unit_ids: list[int]) -> int:
if not unit_ids:
return 0
return int(
conn.execute(
"SELECT count(*) FROM packets WHERE unit_id = ANY(%s) AND removed_at IS NULL", (unit_ids,)
).fetchone()[0]
)
def remove_project(conn: Any, who: Who, project_id: int, reason: Any) -> dict[str, Any]:
"""Soft delete with its units. Refused (409 has_packets) while any of its units has a live packet."""
why = clean_reason(reason)
with conn.transaction():
row = _lock_project(conn, project_id)
ids = [
int(r[0])
for r in conn.execute(
"SELECT id FROM units WHERE project_id = %s AND removed_at IS NULL ORDER BY id FOR UPDATE",
(project_id,),
).fetchall()
]
n = _live_packets(conn, ids)
if n:
raise ApiError(
409,
"has_packets",
f"{row['number']} still has {n} packet{'' if n == 1 else 's'}. Move or remove them first.",
extra={"packets": n},
)
conn.execute(
"UPDATE units SET removed_at = now(), removed_by = %s, removed_reason = %s, version = version + 1,"
" updated_at = now() WHERE id = ANY(%s)",
(who.user_id, why, ids),
)
conn.execute(
"UPDATE projects SET removed_at = now(), removed_by = %s, removed_reason = %s, version = version + 1,"
" updated_at = now() WHERE id = %s",
(who.user_id, why, project_id),
)
_audit(
conn,
who,
"project.removed",
"project",
project_id,
{"number": row["number"], "reason": why, "units": ids},
)
_publish(conn, project_id, action="project.removed")
out = get_project(conn, project_id, removed=True)
assert out is not None
return out
# ---------------------------------------------------------------- writes: units
def lots_from_request(
project_number: str,
*,
lot: Any = None,
lots: list[Any] | None = None,
count: int | None = None,
start: Any = None,
suggest: str | None = None,
) -> list[str]:
"""The unit sheet's one-or-several mode: one lot, a list of lots, or `count` lots counting up from `start`."""
if lots:
out = [lot_input(project_number, x) for x in lots]
elif count is not None:
if not 1 <= count <= MAX_UNITS_AT_ONCE:
raise ApiError(422, "invalid_count", f"How many: 1 to {MAX_UNITS_AT_ONCE}.")
first = lot_input(project_number, start) if start not in (None, "") else suggest
if first is None or not _DIGITS.match(first):
raise ApiError(422, "invalid_lot", "The starting lot has to be a number, e.g. 001.")
b = int(first)
if b + count - 1 > 999:
raise ApiError(422, "invalid_lot", "Lots go up to 999. Start lower or add fewer.")
out = [f"{b + i:03d}" for i in range(count)]
elif lot not in (None, ""):
out = [lot_input(project_number, lot)]
else:
if suggest is None:
raise ApiError(422, "invalid_lot", "No free lot left in this project. Give one.")
out = [suggest]
if len(out) > MAX_UNITS_AT_ONCE:
raise ApiError(422, "invalid_count", f"How many: 1 to {MAX_UNITS_AT_ONCE}.")
dup = sorted({x for x in out if out.count(x) > 1})
if dup:
raise ApiError(409, "number_taken", f"Lot {dup[0]} is in the list twice.", extra={"lots": dup})
return out
def create_units(conn: Any, who: Who, project_id: int, data: Mapping[str, Any]) -> list[dict[str, Any]]:
name = clean_opt(data.get("name"), LIMITS["unit_name"])
notes = clean_opt(data.get("notes"), LIMITS["notes"])
due = parse_due(data.get("due"))
status = parse_status(data.get("status") or "open")
try:
with conn.transaction():
conn.execute("SELECT pg_advisory_xact_lock(7312)")
project = _lock_project(conn, project_id)
if project["status"] == "closed":
raise ApiError(409, "project_closed", f"Project {project['number']} is closed. Reopen it to add units.")
pn = project["number"]
wants_suggest = not data.get("lots") and data.get("lot") in (None, "") and data.get("start") in (None, "")
lots = lots_from_request(
pn,
lot=data.get("lot"),
lots=data.get("lots"),
count=data.get("count"),
start=data.get("start"),
suggest=next_lot(conn, project_id, pn) if wants_suggest else None,
)
for lot in lots:
assert_free(conn, pn + lot)
ids: list[int] = []
for lot in lots:
uid = conn.execute(
"INSERT INTO units (project_id, lot, name, due, status, notes, created_by)"
" VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id",
(project_id, lot, name, due, status, notes, who.user_id),
).fetchone()[0]
ids.append(int(uid))
_audit(
conn,
who,
"unit.created",
"unit",
uid,
{"number": pn + lot, "project_id": project_id, "lot": lot, "name": name, "due": _iso(due)},
)
_publish(conn, project_id, action="unit.created")
except pgerr.UniqueViolation as e:
raise _unique_taken(e) from None
with conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute(UNIT_SELECT + " WHERE v.id = ANY(%s) ORDER BY v.lot", (ids,)).fetchall()
return [unit_out(r) for r in rows]
def update_unit(
conn: Any, who: Who, unit_id: int, values: Mapping[str, Any], base: Mapping[str, Any]
) -> dict[str, Any]:
with conn.transaction():
row, project = _lock_unit(conn, unit_id)
changes = field_cas(row, values, base, UNIT_FIELDS, unit=True)
if changes:
sets = ", ".join(f"{k} = %s" for k in changes)
conn.execute(
f"UPDATE units SET {sets}, version = version + 1, updated_at = now() WHERE id = %s", # noqa: S608
[*changes.values(), unit_id],
)
_audit(
conn,
who,
"unit.updated",
"unit",
unit_id,
{"number": project["number"] + row["lot"], "fields": _change_detail(row, changes)},
)
_publish(conn, project["id"], unit_id, action="unit.updated")
return _unit_or_404(conn, unit_id)
def renumber_unit(conn: Any, who: Who, unit_id: int, lot: Any, reason: Any) -> dict[str, Any]:
why = clean_reason(reason)
try:
with conn.transaction():
conn.execute("SELECT pg_advisory_xact_lock(7312)")
row, project = _lock_unit(conn, unit_id)
pn = project["number"]
new_lot = lot_input(pn, lot)
if new_lot == row["lot"]:
raise ApiError(400, "same_number", f"The unit is already {pn + new_lot}.")
old, new = pn + row["lot"], pn + new_lot
assert_free(conn, new, own_unit=unit_id)
conn.execute("DELETE FROM project_aliases WHERE alias = %s AND unit_id = %s", (new.upper(), unit_id))
conn.execute(
"INSERT INTO project_aliases (alias, project_id, unit_id, kind) VALUES (%s, %s, %s, 'unit_renumber')"
" ON CONFLICT (alias) DO UPDATE SET project_id = EXCLUDED.project_id, unit_id = EXCLUDED.unit_id,"
" kind = EXCLUDED.kind, created_at = now()",
(old.upper(), project["id"], unit_id),
)
conn.execute(
"UPDATE units SET lot = %s, version = version + 1, updated_at = now() WHERE id = %s", (new_lot, unit_id)
)
conn.execute(
"INSERT INTO project_renumbers (by, project_id, unit_id, old_number, new_number, reason)"
" VALUES (%s, %s, %s, %s, %s, %s)",
(who.user_id, project["id"], unit_id, old, new, why),
)
_audit(conn, who, "unit.renumbered", "unit", unit_id, {"from": old, "to": new, "reason": why})
_publish(conn, project["id"], unit_id, action="unit.renumbered")
except pgerr.UniqueViolation as e:
raise _unique_taken(e) from None
return _unit_or_404(conn, unit_id)
def remove_unit(conn: Any, who: Who, unit_id: int, reason: Any) -> dict[str, Any]:
why = clean_reason(reason)
with conn.transaction():
row, project = _lock_unit(conn, unit_id)
n = _live_packets(conn, [unit_id])
number = project["number"] + row["lot"]
if n:
raise ApiError(
409,
"has_packets",
f"{number} still has {n} packet{'' if n == 1 else 's'}. Move or remove them first.",
extra={"packets": n},
)
conn.execute(
"UPDATE units SET removed_at = now(), removed_by = %s, removed_reason = %s, version = version + 1,"
" updated_at = now() WHERE id = %s",
(who.user_id, why, unit_id),
)
_audit(conn, who, "unit.removed", "unit", unit_id, {"number": number, "reason": why})
_publish(conn, project["id"], unit_id, action="unit.removed")
out = get_unit(conn, unit_id, removed=True)
assert out is not None
return out
+4 -2
View File
@@ -93,11 +93,13 @@ def create_app(
app.state.groups_cache = None
api_errors.install(app)
from .api import account, admin, auth, departments
from .api import account, admin, assets, auth, departments, packet_children, packets, projects, units
from .api import events as events_api
for module in (auth, account, admin, departments, events_api):
# Routers go in before the static catch-all below, which answers every other GET.
for module in (auth, account, admin, departments, events_api, projects, units, packets, packet_children, assets):
app.include_router(module.router)
app.include_router(assets.blob_router)
@app.get("/healthz")
def healthz() -> dict[str, Any]:
@@ -0,0 +1,771 @@
-- 0003_domain: the core domain. Projects and units, packets and their child tables, change
-- orders, documents and ITPs, plus the views v_units and v_open_cos. Runs as ps_migrate; 0001's
-- default privileges give ps_app SELECT, INSERT, UPDATE, DELETE on every table here.
-- Append-only tables revoke below. Text ID columns (work order base, lot, split, sub, part ids)
-- stay text: leading zeros are kept and lot 7A occurs.
CREATE TYPE packet_status AS ENUM (
'draft', 'warehouse', 'ready', 'assigned', 'in_progress', 'review', 'qc', 'complete', 'filed'
);
CREATE TYPE wh_stage AS ENUM ('incoming', 'pulling', 'hold', 'pulled');
CREATE TYPE co_review AS ENUM ('pending', 'approved', 'rejected');
CREATE TYPE co_origin AS ENUM ('visual', 'drawing', 'manual');
CREATE TYPE source_kind AS ENUM ('visual', 'hand');
CREATE TYPE project_status AS ENUM ('open', 'hold', 'closed');
CREATE TYPE route_kind AS ENUM ('databook', 'shipping');
CREATE TYPE drawing_state AS ENUM (
'none', 'needed', 'attached', 'rev_mismatch', 'not_released', 'not_found', 'lookup_failed', 'hand'
);
CREATE TYPE itp_status AS ENUM ('open', 'signed', 'reopened');
-- ---------------------------------------------------------------- organisation
-- Weld departments get the welding checklist and need welds and filler before submit.
ALTER TABLE departments ADD COLUMN weld bool NOT NULL DEFAULT false;
UPDATE departments SET weld = true WHERE code IN ('03', '04', '05', '06', '07', '10');
-- A worker with rows here may only work on packets at these stations. No rows = any station.
CREATE TABLE user_station_limits (
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
station_id bigint NOT NULL REFERENCES stations (id),
PRIMARY KEY (user_id, station_id)
);
-- ---------------------------------------------------------------- projects and units
-- *_src says where a value came from: visual, hand, or null when unset. A hand value is never
-- overwritten by the feed; *_visual keeps what VISUAL said beside it.
CREATE TABLE projects (
id bigserial PRIMARY KEY,
number text NOT NULL,
name text,
customer text,
po text,
due date,
status project_status NOT NULL DEFAULT 'open',
notes text,
source text NOT NULL DEFAULT 'hand' CHECK (source IN ('hand', 'visual')),
needs_details bool NOT NULL DEFAULT false,
customer_src text CHECK (customer_src IN ('visual', 'hand')),
customer_visual text,
po_src text CHECK (po_src IN ('visual', 'hand')),
po_visual text,
order_date date,
order_date_src text CHECK (order_date_src IN ('visual', 'hand')),
ship_date date,
ship_date_src text CHECK (ship_date_src IN ('visual', 'hand')),
version int NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES users (id),
updated_at timestamptz NOT NULL DEFAULT now(),
removed_at timestamptz,
removed_by uuid REFERENCES users (id),
removed_reason text
);
CREATE UNIQUE INDEX projects_number_live ON projects (upper(number)) WHERE removed_at IS NULL;
-- Every current and old number of every project and unit, upper-cased, so a lookup by any
-- number resolves. unit_id is null for a project number. The unit_id FK is added after units.
CREATE TABLE project_aliases (
alias text PRIMARY KEY CHECK (alias = upper(alias)),
project_id bigint NOT NULL REFERENCES projects (id),
unit_id bigint,
kind text NOT NULL CHECK (kind IN ('renumber', 'unit_renumber', 'import')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX project_aliases_project ON project_aliases (project_id);
-- lot is text: 1 to 3 characters, upper-cased, zero-padded to 3 when numeric (001, 7A).
CREATE TABLE units (
id bigserial PRIMARY KEY,
project_id bigint NOT NULL REFERENCES projects (id),
lot text NOT NULL CHECK (lot ~ '^[A-Z0-9]{1,3}$' AND (lot !~ '^[0-9]+$' OR length(lot) = 3)),
name text,
due date,
status project_status NOT NULL DEFAULT 'open',
notes text,
customer_src text CHECK (customer_src IN ('visual', 'hand')),
po_src text CHECK (po_src IN ('visual', 'hand')),
order_date date,
order_date_src text CHECK (order_date_src IN ('visual', 'hand')),
ship_date date,
ship_date_src text CHECK (ship_date_src IN ('visual', 'hand')),
version int NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES users (id),
updated_at timestamptz NOT NULL DEFAULT now(),
removed_at timestamptz,
removed_by uuid REFERENCES users (id),
removed_reason text
);
CREATE UNIQUE INDEX units_project_lot_live ON units (project_id, lot) WHERE removed_at IS NULL;
ALTER TABLE project_aliases
ADD CONSTRAINT project_aliases_unit_id_fkey FOREIGN KEY (unit_id) REFERENCES units (id);
CREATE TABLE project_renumbers (
id bigserial PRIMARY KEY,
at timestamptz NOT NULL DEFAULT now(),
by uuid REFERENCES users (id),
project_id bigint NOT NULL REFERENCES projects (id),
unit_id bigint REFERENCES units (id),
old_number text NOT NULL,
new_number text NOT NULL,
reason text
);
-- The unit number is the project number plus the lot. A unit's own due date wins over the project's.
CREATE VIEW v_units AS
SELECT u.*,
p.number || u.lot AS number,
p.number AS project_number,
p.name AS project_name,
p.status AS project_status,
coalesce(u.due, p.due) AS due_effective
FROM units u
JOIN projects p ON p.id = u.project_id;
-- ---------------------------------------------------------------- assets
-- One row per stored file. storage_key is the path under ASSETS_DIR. packet_id is the packet the
-- file was uploaded for, if any; the attach rows (packet_pages, packet_docs) are the real links.
CREATE TABLE assets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
sha256 bytea NOT NULL,
size bigint NOT NULL,
mime text NOT NULL,
original_name text,
storage_key text NOT NULL UNIQUE,
source text NOT NULL DEFAULT 'upload',
source_ref jsonb,
pages int,
uploaded_by uuid REFERENCES users (id),
uploaded_at timestamptz NOT NULL DEFAULT now(),
packet_id bigint
);
CREATE INDEX assets_sha256 ON assets (sha256);
-- ---------------------------------------------------------------- packets
-- version goes up in every write transaction (the pipeline does it, never a trigger).
-- next_*_no are the counters for server-assigned child numbers, taken under the FOR UPDATE lock.
CREATE TABLE packets (
id bigserial PRIMARY KEY,
unit_id bigint NOT NULL REFERENCES units (id),
dept_code text NOT NULL REFERENCES departments (code),
op text,
title text,
customer text,
drawing text,
rev text,
wps text,
status packet_status NOT NULL DEFAULT 'draft',
status_at timestamptz NOT NULL DEFAULT now(),
station_id bigint REFERENCES stations (id),
welder_id uuid REFERENCES users (id),
welder_stamp text,
for_sup_id uuid REFERENCES users (id),
heat_map bool,
wh_skipped bool NOT NULL DEFAULT false,
wh_sent_at timestamptz,
routed_to route_kind,
return_note text,
vclosed_at timestamptz,
vclosed_by uuid REFERENCES users (id),
databook_at timestamptz,
source_kind source_kind NOT NULL DEFAULT 'hand',
source jsonb NOT NULL DEFAULT '{}',
visual_key text,
visual_rel date,
visual_want date,
drawing_state drawing_state NOT NULL DEFAULT 'none',
flags text[] NOT NULL DEFAULT '{}',
visual_fp_applied text,
visual_closed_at timestamptz,
visual jsonb NOT NULL DEFAULT '{}',
next_weld_no int NOT NULL DEFAULT 1,
next_heat_no int NOT NULL DEFAULT 1,
next_page_no int NOT NULL DEFAULT 1,
next_co_no int NOT NULL DEFAULT 1,
version int NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES users (id),
updated_at timestamptz NOT NULL DEFAULT now(),
removed_at timestamptz,
removed_by uuid REFERENCES users (id),
removed_reason text
);
-- One live packet per VISUAL key. A removed packet keeps its key so the feed never recreates it.
CREATE UNIQUE INDEX packets_visual_key ON packets (visual_key)
WHERE visual_key IS NOT NULL AND removed_at IS NULL;
CREATE INDEX packets_status_live ON packets (status) WHERE removed_at IS NULL;
CREATE INDEX packets_unit ON packets (unit_id);
CREATE INDEX packets_dept_status ON packets (dept_code, status);
-- Sets updated_at only. A read never bumps version; the write pipeline bumps it explicitly.
CREATE FUNCTION ps_packet_touch() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;
CREATE TRIGGER packets_touch
BEFORE UPDATE ON packets
FOR EACH ROW EXECUTE FUNCTION ps_packet_touch();
-- ---------------------------------------------------------------- change orders
-- One pending VISUAL change order per packet at a time; the feed re-aims it rather than adding one.
CREATE TABLE change_orders (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
co_no int NOT NULL,
origin co_origin NOT NULL,
review co_review NOT NULL DEFAULT 'pending',
minor bool NOT NULL DEFAULT false,
hold bool NOT NULL DEFAULT false,
held_by uuid REFERENCES users (id),
held_at timestamptz,
ecn text,
reason text,
issued_at timestamptz DEFAULT now(),
issued_by uuid REFERENCES users (id),
issued_signature_id bigint REFERENCES signatures (id),
approved_at timestamptz,
approved_by uuid REFERENCES users (id),
approved_signature_id bigint REFERENCES signatures (id),
rejected_at timestamptz,
rejected_by uuid REFERENCES users (id),
rejected_reason text,
rejected_signature_id bigint REFERENCES signatures (id),
vis jsonb,
waiting jsonb,
staged jsonb,
target_fp text,
row_version int NOT NULL DEFAULT 1,
UNIQUE (packet_id, co_no)
);
CREATE UNIQUE INDEX change_orders_visual_pending ON change_orders (packet_id)
WHERE origin = 'visual' AND review = 'pending';
CREATE TABLE co_items (
id bigserial PRIMARY KEY,
co_id bigint NOT NULL REFERENCES change_orders (id),
old_page int,
old_dwg text,
old_rev text,
new_page int,
new_dwg text,
new_rev text,
added bool NOT NULL DEFAULT false
);
CREATE INDEX co_items_co ON co_items (co_id);
CREATE TABLE co_required_depts (
co_id bigint NOT NULL REFERENCES change_orders (id),
dept_code text NOT NULL REFERENCES departments (code),
PRIMARY KEY (co_id, dept_code)
);
-- Insert-only. A primary key violation means the department already verified.
CREATE TABLE co_verifications (
co_id bigint NOT NULL REFERENCES change_orders (id),
dept_code text NOT NULL REFERENCES departments (code),
by uuid NOT NULL REFERENCES users (id),
at timestamptz NOT NULL DEFAULT now(),
note text,
signature_id bigint NOT NULL REFERENCES signatures (id),
it_reason text,
PRIMARY KEY (co_id, dept_code)
);
-- The old openCOs(): an approved change order that some required department has not verified,
-- or a pending one Clerical put on hold. pending_depts lists the unverified departments, sorted.
CREATE VIEW v_open_cos AS
SELECT x.packet_id, x.co_id, x.co_no, x.review, x.hold, x.pending_depts
FROM (
SELECT co.packet_id,
co.id AS co_id,
co.co_no,
co.review,
co.hold,
ARRAY(
SELECT r.dept_code
FROM co_required_depts r
WHERE r.co_id = co.id
AND NOT EXISTS (
SELECT 1 FROM co_verifications v
WHERE v.co_id = r.co_id AND v.dept_code = r.dept_code
)
ORDER BY r.dept_code
) AS pending_depts
FROM change_orders co
WHERE co.review IN ('approved', 'pending')
) x
WHERE (x.review = 'approved' AND cardinality(x.pending_depts) > 0)
OR (x.review = 'pending' AND x.hold);
-- ---------------------------------------------------------------- packet children
-- All key parts are text. lot, split and sub default to '' so the unique key works without nulls.
CREATE TABLE packet_work_orders (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
wo_base text NOT NULL,
lot text NOT NULL DEFAULT '',
split text NOT NULL DEFAULT '',
sub text NOT NULL DEFAULT '',
seq int,
qty numeric,
target_hrs numeric,
op text,
description text,
visual_status text,
missing_runs int NOT NULL DEFAULT 0,
gone_at timestamptz,
sort int NOT NULL DEFAULT 0,
row_version int NOT NULL DEFAULT 1,
UNIQUE (packet_id, wo_base, lot, split, sub)
);
-- n comes from packets.next_page_no. A page replaced by a change order is voided, never deleted.
CREATE TABLE packet_pages (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
n int NOT NULL,
type text NOT NULL CHECK (type IN ('drawing', 'info', 'overlay')),
asset_id uuid REFERENCES assets (id),
pn int NOT NULL DEFAULT 1,
dwg text,
rev text,
co_id bigint REFERENCES change_orders (id),
void_co_id bigint REFERENCES change_orders (id),
void_at timestamptz,
void_by uuid REFERENCES users (id),
void_new_n int,
row_version int NOT NULL DEFAULT 1,
UNIQUE (packet_id, n)
);
CREATE TABLE bom_lines (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
item int NOT NULL,
qty numeric,
part_id text,
description text,
dwg_ref text,
cl_item text,
length text,
material text,
heat text,
na bool NOT NULL DEFAULT false,
issued numeric,
loc text,
pulled bool NOT NULL DEFAULT false,
pulled_at timestamptz,
pulled_by uuid REFERENCES users (id),
mtr_asset_id uuid REFERENCES assets (id),
src text CHECK (src IN ('visual', 'hand')),
vkey text,
whs text,
comm text,
cons text,
sub text,
grp text,
sort int NOT NULL DEFAULT 0,
row_version int NOT NULL DEFAULT 1,
UNIQUE (packet_id, item)
);
-- At most one open short per BOM line.
CREATE TABLE bom_shorts (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
bom_line_id bigint NOT NULL REFERENCES bom_lines (id) ON DELETE CASCADE,
qty numeric NOT NULL,
qty_open numeric NOT NULL,
reason text,
ref text,
eta date,
note text,
opened_at timestamptz NOT NULL DEFAULT now(),
opened_by uuid REFERENCES users (id),
closed_at timestamptz,
row_version int NOT NULL DEFAULT 1
);
CREATE UNIQUE INDEX bom_shorts_open ON bom_shorts (bom_line_id) WHERE closed_at IS NULL;
CREATE INDEX bom_shorts_packet_open ON bom_shorts (packet_id) WHERE closed_at IS NULL;
CREATE TABLE short_receipts (
id bigserial PRIMARY KEY,
short_id bigint NOT NULL REFERENCES bom_shorts (id),
packet_id bigint NOT NULL REFERENCES packets (id),
qty numeric NOT NULL,
heat text,
note text,
at timestamptz NOT NULL DEFAULT now(),
by uuid REFERENCES users (id)
);
CREATE INDEX short_receipts_short ON short_receipts (short_id);
-- One stage per visit to a shop department. The open stage has closed_at null.
-- checklist maps key -> {done, at, by, stamp}.
CREATE TABLE packet_stages (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
dept_code text NOT NULL REFERENCES departments (code),
station_id bigint REFERENCES stations (id),
welder_id uuid REFERENCES users (id),
welder_stamp text,
checklist jsonb NOT NULL DEFAULT '{}',
filler text,
filler_lot text,
preheat text,
notes text,
clock_elapsed_ms bigint NOT NULL DEFAULT 0,
clock_running_since timestamptz,
clock_started_at timestamptz,
qc_snapshot jsonb,
opened_at timestamptz NOT NULL DEFAULT now(),
closed_at timestamptz,
row_version int NOT NULL DEFAULT 1
);
CREATE UNIQUE INDEX packet_stages_open ON packet_stages (packet_id) WHERE closed_at IS NULL;
CREATE INDEX packet_stages_packet ON packet_stages (packet_id, id);
-- weld_no comes from packets.next_weld_no. fx and fy are fractions of the page.
CREATE TABLE welds (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
weld_no int NOT NULL,
page int,
fx double precision,
fy double precision,
wo_base text,
piece int,
items int[] NOT NULL DEFAULT '{}',
type text,
size text,
process text,
stamp text,
welder_id uuid REFERENCES users (id),
dept_code text REFERENCES departments (code),
stage_id bigint REFERENCES packet_stages (id),
carried_from int,
at timestamptz NOT NULL DEFAULT now(),
by uuid REFERENCES users (id),
row_version int NOT NULL DEFAULT 1,
removed_at timestamptz,
removed_by uuid REFERENCES users (id),
removed_reason text
);
CREATE UNIQUE INDEX welds_packet_no_live ON welds (packet_id, weld_no) WHERE removed_at IS NULL;
-- tag_no comes from packets.next_heat_no.
CREATE TABLE heat_tags (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
tag_no int NOT NULL,
page int,
item int,
fx double precision,
fy double precision,
carried_from int,
at timestamptz NOT NULL DEFAULT now(),
by uuid REFERENCES users (id),
row_version int NOT NULL DEFAULT 1,
removed_at timestamptz,
removed_by uuid REFERENCES users (id),
removed_reason text
);
CREATE UNIQUE INDEX heat_tags_packet_no_live ON heat_tags (packet_id, tag_no) WHERE removed_at IS NULL;
-- One heat table position per page; written as an idempotent upsert.
CREATE TABLE heat_table_placements (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
page int NOT NULL,
x double precision,
y double precision,
s double precision,
by uuid REFERENCES users (id),
at timestamptz NOT NULL DEFAULT now(),
UNIQUE (packet_id, page)
);
-- The warehouse stage history. The current row has closed_at null; a change closes it and inserts.
CREATE TABLE packet_wh (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
stage wh_stage NOT NULL,
by uuid REFERENCES users (id),
at timestamptz NOT NULL DEFAULT now(),
reason text,
note text,
auto bool NOT NULL DEFAULT false,
closed_at timestamptz
);
CREATE UNIQUE INDEX packet_wh_current ON packet_wh (packet_id) WHERE closed_at IS NULL;
-- Every status change. Replaces the old hist and sentBack lists.
CREATE TABLE packet_transitions (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
at timestamptz NOT NULL DEFAULT now(),
by uuid REFERENCES users (id),
action text NOT NULL,
from_status packet_status,
to_status packet_status,
from_dept text,
to_dept text,
reason text,
signature_id bigint REFERENCES signatures (id),
it_reason text
);
CREATE INDEX packet_transitions_packet ON packet_transitions (packet_id, id);
-- One open inspection per packet. weld_exam maps weld_no -> {result, note}.
CREATE TABLE qc_inspections (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
stage_id bigint REFERENCES packet_stages (id),
sent_at timestamptz NOT NULL DEFAULT now(),
sent_by uuid REFERENCES users (id),
book bool,
spool bool,
location text,
sent_note text,
checks jsonb NOT NULL DEFAULT '{}',
weld_exam jsonb NOT NULL DEFAULT '{}',
nde_method text,
nde_result text,
nde_report text,
hardness text,
nace bool,
inspector text,
stamp text,
notes text,
result text CHECK (result IN ('accepted', 'rejected')),
result_at timestamptz,
result_by uuid REFERENCES users (id),
result_note text,
signature_id bigint REFERENCES signatures (id),
row_version int NOT NULL DEFAULT 1,
closed_at timestamptz
);
CREATE UNIQUE INDEX qc_inspections_open ON qc_inspections (packet_id) WHERE closed_at IS NULL;
CREATE TABLE qc_history (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
at timestamptz NOT NULL DEFAULT now(),
result text,
inspector text,
note text,
dept_code text,
snapshot jsonb
);
CREATE INDEX qc_history_packet ON qc_history (packet_id, id);
-- HMAC seal written when a packet is filed. At most one unbroken seal per packet.
CREATE TABLE packet_seals (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
hash bytea NOT NULL,
key_id text NOT NULL,
sealed_at timestamptz NOT NULL DEFAULT now(),
sealed_by uuid REFERENCES users (id),
signature_id bigint REFERENCES signatures (id),
broken_at timestamptz,
broken_by uuid REFERENCES users (id),
broken_reason text
);
CREATE UNIQUE INDEX packet_seals_unbroken ON packet_seals (packet_id) WHERE broken_at IS NULL;
-- The user-visible history. by_name and role are snapshots at the time of writing.
CREATE TABLE packet_log (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
at timestamptz NOT NULL DEFAULT now(),
by uuid REFERENCES users (id),
by_name text,
role text,
action text NOT NULL,
note text,
it_reason text,
signature_id bigint REFERENCES signatures (id),
transition_id bigint REFERENCES packet_transitions (id)
);
CREATE INDEX packet_log_packet ON packet_log (packet_id, id);
-- What the feed last observed per leg. Written by the worker only.
CREATE TABLE packet_visual_state (
packet_id bigint PRIMARY KEY REFERENCES packets (id),
legs jsonb NOT NULL DEFAULT '[]',
ticket_hours numeric,
order_ref jsonb,
observed_at timestamptz
);
-- ---------------------------------------------------------------- documents and ITPs
CREATE TABLE packet_docs (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
asset_id uuid NOT NULL REFERENCES assets (id),
kind text NOT NULL,
title text,
added_at timestamptz NOT NULL DEFAULT now(),
added_by uuid REFERENCES users (id),
removed_at timestamptz,
removed_by uuid REFERENCES users (id),
removed_reason text
);
CREATE INDEX packet_docs_packet ON packet_docs (packet_id) WHERE removed_at IS NULL;
-- Q.C. documents for a project or a unit. A new revision points the old row at the new one.
CREATE TABLE qc_docs (
id bigserial PRIMARY KEY,
project_id bigint REFERENCES projects (id),
unit_id bigint REFERENCES units (id),
type text NOT NULL,
rev text,
asset_id uuid NOT NULL REFERENCES assets (id),
auto bool NOT NULL DEFAULT false,
superseded_by bigint REFERENCES qc_docs (id),
added_at timestamptz NOT NULL DEFAULT now(),
added_by uuid REFERENCES users (id)
);
-- One ITP per unit. template is frozen when the ITP is made.
CREATE TABLE itps (
id bigserial PRIMARY KEY,
unit_id bigint UNIQUE NOT NULL REFERENCES units (id),
template jsonb NOT NULL,
status itp_status NOT NULL DEFAULT 'open',
signoff_at timestamptz,
signoff_by uuid REFERENCES users (id),
signoff_signature_id bigint REFERENCES signatures (id),
signoff_asset_id uuid REFERENCES assets (id),
reopen_at timestamptz,
reopen_by uuid REFERENCES users (id),
reopen_reason text,
version int NOT NULL DEFAULT 1
);
CREATE TABLE itp_lines (
id bigserial PRIMARY KEY,
itp_id bigint NOT NULL REFERENCES itps (id),
key text NOT NULL,
result text CHECK (result IN ('approve', 'reject', 'na')),
by uuid REFERENCES users (id),
at timestamptz,
ncr text,
note text,
signature_id bigint REFERENCES signatures (id),
row_version int NOT NULL DEFAULT 1,
UNIQUE (itp_id, key)
);
-- Every reject keeps its NCR id, even after the line is approved later.
CREATE TABLE itp_rejects (
id bigserial PRIMARY KEY,
itp_id bigint NOT NULL REFERENCES itps (id),
line_key text NOT NULL,
ncr text NOT NULL,
note text,
at timestamptz NOT NULL DEFAULT now(),
by uuid REFERENCES users (id)
);
CREATE TABLE itp_blocks (
id bigserial PRIMARY KEY,
itp_id bigint NOT NULL REFERENCES itps (id),
block_key text NOT NULL,
signed_at timestamptz NOT NULL DEFAULT now(),
signed_by uuid REFERENCES users (id),
signature_id bigint REFERENCES signatures (id),
UNIQUE (itp_id, block_key)
);
-- ---------------------------------------------------------------- append-only records
CREATE TRIGGER project_renumbers_append_only
BEFORE UPDATE OR DELETE ON project_renumbers
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER project_renumbers_no_truncate
BEFORE TRUNCATE ON project_renumbers
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER short_receipts_append_only
BEFORE UPDATE OR DELETE ON short_receipts
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER short_receipts_no_truncate
BEFORE TRUNCATE ON short_receipts
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER packet_transitions_append_only
BEFORE UPDATE OR DELETE ON packet_transitions
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER packet_transitions_no_truncate
BEFORE TRUNCATE ON packet_transitions
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER qc_history_append_only
BEFORE UPDATE OR DELETE ON qc_history
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER qc_history_no_truncate
BEFORE TRUNCATE ON qc_history
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER packet_log_append_only
BEFORE UPDATE OR DELETE ON packet_log
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER packet_log_no_truncate
BEFORE TRUNCATE ON packet_log
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER itp_rejects_append_only
BEFORE UPDATE OR DELETE ON itp_rejects
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER itp_rejects_no_truncate
BEFORE TRUNCATE ON itp_rejects
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER co_verifications_append_only
BEFORE UPDATE OR DELETE ON co_verifications
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER co_verifications_no_truncate
BEFORE TRUNCATE ON co_verifications
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
-- Explicit, after the default privileges: the app may only read and append these.
REVOKE UPDATE, DELETE, TRUNCATE ON project_renumbers, short_receipts, packet_transitions, qc_history,
packet_log, itp_rejects, co_verifications FROM ps_app;
GRANT SELECT, INSERT ON project_renumbers, short_receipts, packet_transitions, qc_history,
packet_log, itp_rejects, co_verifications TO ps_app;
-- Views are read-only to the app.
REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON v_units, v_open_cos FROM ps_app;
GRANT SELECT ON v_units, v_open_cos TO ps_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ps_app;
+34
View File
@@ -0,0 +1,34 @@
"""Packet policy: who may do what to a packet, and when. Pure functions, no I/O. See docs/POLICY.md."""
from __future__ import annotations
from .actions import ACTIVE, RULES, SHOP, STATUSES, TRANSITIONS, Action, Rule, Who
from .custody import holder_name, holds, works_on
from .engine import Decision, can, check, evaluate
from .preconditions import INPUT_ONLY, MESSAGES, PRECONDITIONS
from .principal import SYSTEM_FEED, PacketState, Principal, principal_from_roles
__all__ = [
"ACTIVE",
"INPUT_ONLY",
"MESSAGES",
"PRECONDITIONS",
"RULES",
"SHOP",
"STATUSES",
"SYSTEM_FEED",
"TRANSITIONS",
"Action",
"Decision",
"PacketState",
"Principal",
"Rule",
"Who",
"can",
"check",
"evaluate",
"holder_name",
"holds",
"principal_from_roles",
"works_on",
]
+318
View File
@@ -0,0 +1,318 @@
"""The Action enum, the Who predicates and the rule table. Plain data; the engine reads it.
A port of the old guard.js POLICY_TRANSITIONS, POLICY_FIELDS, SIGN_OFF and CO_HOLD tables, with the
tightenings listed in docs/POLICY.md.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
# Statuses in workflow order (the packet_status enum declaration order).
STATUSES: tuple[str, ...] = (
"draft",
"warehouse",
"ready",
"assigned",
"in_progress",
"review",
"qc",
"complete",
"filed",
)
SHOP: frozenset[str] = frozenset({"ready", "assigned", "in_progress", "review"})
ACTIVE: frozenset[str] = frozenset({"assigned", "in_progress"})
# Short labels for messages.
STATUS_LABELS: dict[str, str] = {
"draft": "Draft",
"warehouse": "With warehouse",
"ready": "Ready to assign",
"assigned": "At a station",
"in_progress": "In progress",
"review": "Supervisor review",
"qc": "At Q.C.",
"complete": "Complete",
"filed": "Filed",
}
# Department codes that are not shop departments.
SUPPORT_CODES: frozenset[str] = frozenset({"CL", "WH", "QC", "PM"})
class Action(StrEnum):
# transitions
SEND_TO_WAREHOUSE = "send_to_warehouse"
RELEASE_TO_SHOP = "release_to_shop"
SEND_DRAFT_TO_QC = "send_draft_to_qc"
WH_SEND_BACK = "wh_send_back"
RETURN_TO_CLERICAL = "return_to_clerical"
WH_RELEASE = "wh_release"
SEND_BACK_TO_WAREHOUSE = "send_back_to_warehouse"
ASSIGN = "assign"
UNASSIGN = "unassign"
PULL_BACK = "pull_back"
START = "start"
SUBMIT = "submit"
RETURN_TO_STATION = "return_to_station"
SEND_TO_QC = "send_to_qc"
ROUTE_FROM_REVIEW = "route_from_review"
QC_REJECT = "qc_reject"
QC_ACCEPT = "qc_accept"
QC_ACCEPT_AND_ROUTE = "qc_accept_and_route"
FILE = "file"
ADMIN_REOPEN = "admin_reopen"
# edits (no status change)
EDIT_HEADER = "edit_header"
EDIT_DRAWING_REF = "edit_drawing_ref"
EDIT_DEPT = "edit_dept"
MOVE_UNIT = "move_unit"
EDIT_WOS = "edit_wos"
EDIT_BOM = "edit_bom"
WH_RECEIVE_SHORT = "wh_receive_short"
EDIT_HEATS = "edit_heats"
EDIT_HEAT_TABLE = "edit_heat_table"
EDIT_WELDS = "edit_welds"
EDIT_CHECKLIST = "edit_checklist"
EDIT_STAGE_FIELDS = "edit_stage_fields"
CLOCK = "clock"
EDIT_QC = "edit_qc"
SET_WH_STAGE = "set_wh_stage"
EDIT_DOCUMENT_SET = "edit_document_set"
DOC_ADD = "doc_add"
DOC_REMOVE = "doc_remove"
REV_APPROVE = "rev_approve"
SET_VCLOSED = "set_vclosed"
SET_DATABOOK_AT = "set_databook_at"
REMOVE = "remove"
CO_ISSUE = "co_issue"
CO_APPROVE = "co_approve"
CO_REJECT = "co_reject"
CO_HOLD_TOGGLE = "co_hold_toggle"
CO_VERIFY = "co_verify"
ITP_EDIT_LINE = "itp_edit_line"
ITP_BLOCK_SIGN = "itp_block_sign"
ITP_SIGN_OFF = "itp_sign_off"
ITP_REOPEN = "itp_reopen"
VIS_UPDATE_DRAFT = "vis_update_draft"
VIS_OPEN_CO = "vis_open_co"
VIS_AUTO_RELEASE = "vis_auto_release"
VIS_RETIRE_DRAFT = "vis_retire_draft"
class Who(StrEnum):
"""The closed set of who predicates. custody.allows() evaluates them."""
CL = "CL"
CL_SUP = "CL_SUP"
WH = "WH"
WH_SUP = "WH_SUP"
QC = "QC"
QC_SUP = "QC_SUP"
CL_OR_QC = "CL_OR_QC"
HOLDER = "HOLDER"
DEPT_LEAD = "DEPT_LEAD"
DEPT_SUP = "DEPT_SUP"
WORKS_ON = "WORKS_ON"
CO_VERIFIER = "CO_VERIFIER"
ADMIN = "ADMIN"
SYSTEM = "SYSTEM"
@dataclass(frozen=True)
class Rule:
who: Who
from_status: frozenset[str] # empty = any status (edits); the filed lock is applied separately
to_status: str | None # None for edits
dept_change: str = "no" # no | required | allowed
co_hold: bool = False
preconditions: tuple[str, ...] = ()
signature: str | None = None # the meaning text when a signature is required
reason_required: bool = False
filed_ok: bool = False # only SET_DATABOOK_AT and ADMIN_REOPEN
system_only: bool = False
@property
def transition(self) -> bool:
return self.to_status is not None
def _s(*statuses: str | frozenset[str]) -> frozenset[str]:
out: set[str] = set()
for s in statuses:
if isinstance(s, str):
out.add(s)
else:
out.update(s)
return frozenset(out)
ANY: frozenset[str] = frozenset()
_REASON = ("reason_given",)
_DRAFT_OUT = ("heat_map_answered", "drawing_attached")
A = Action
RULES: dict[Action, Rule] = {
# transitions
A.SEND_TO_WAREHOUSE: Rule(Who.CL, _s("draft"), "warehouse", preconditions=_DRAFT_OUT),
A.RELEASE_TO_SHOP: Rule(
Who.CL, _s("draft"), "ready", dept_change="allowed", preconditions=(*_DRAFT_OUT, "target_dept_given")
),
A.SEND_DRAFT_TO_QC: Rule(Who.CL, _s("draft"), "qc", preconditions=_DRAFT_OUT),
A.WH_SEND_BACK: Rule(Who.WH, _s("warehouse"), "draft", preconditions=_REASON, reason_required=True),
A.RETURN_TO_CLERICAL: Rule(Who.DEPT_LEAD, _s("ready"), "draft", preconditions=_REASON, reason_required=True),
A.WH_RELEASE: Rule(Who.WH, _s("warehouse"), "ready", co_hold=True, preconditions=("bom_all_pulled_or_short",)),
A.SEND_BACK_TO_WAREHOUSE: Rule(
Who.DEPT_LEAD, _s("ready"), "warehouse", preconditions=_REASON, reason_required=True
),
A.ASSIGN: Rule(Who.DEPT_LEAD, _s("ready"), "assigned", preconditions=("welder_set",)),
A.UNASSIGN: Rule(Who.DEPT_LEAD, _s("assigned"), "ready"),
A.PULL_BACK: Rule(Who.DEPT_LEAD, _s("in_progress"), "ready"),
A.START: Rule(Who.WORKS_ON, _s("assigned"), "in_progress"),
A.SUBMIT: Rule(
Who.WORKS_ON,
_s("assigned", "in_progress"),
"review",
co_hold=True,
preconditions=("checklist_complete", "welds_recorded", "filler_set"),
signature="Submit work to the supervisor",
),
A.RETURN_TO_STATION: Rule(Who.DEPT_SUP, _s("review"), "assigned", preconditions=_REASON, reason_required=True),
A.SEND_TO_QC: Rule(
Who.DEPT_SUP,
_s("review"),
"qc",
co_hold=True,
preconditions=("qc_book_spool_location",),
signature="Send to Q.C.",
),
A.ROUTE_FROM_REVIEW: Rule(
Who.DEPT_SUP,
_s("review"),
"ready",
dept_change="required",
co_hold=True,
preconditions=("target_dept_given",),
signature="Route to another department",
),
A.QC_REJECT: Rule(Who.QC, _s("qc"), "review", preconditions=_REASON, signature="Q.C. reject", reason_required=True),
A.QC_ACCEPT: Rule(
Who.QC,
_s("qc"),
"complete",
co_hold=True,
preconditions=("qc_missing_empty", "route_given"),
signature="Q.C. accept",
),
A.QC_ACCEPT_AND_ROUTE: Rule(
Who.QC,
_s("qc"),
"ready",
dept_change="required",
co_hold=True,
preconditions=("qc_missing_empty", "target_dept_given"),
signature="Q.C. accept and route",
),
A.FILE: Rule(
Who.CL,
_s("complete"),
"filed",
co_hold=True,
preconditions=("vclosed_if_databook",),
signature="File in the data book",
),
A.ADMIN_REOPEN: Rule(
Who.ADMIN,
_s("filed"),
"complete",
preconditions=_REASON,
signature="Reopen a filed packet",
reason_required=True,
filed_ok=True,
),
# edits
A.EDIT_HEADER: Rule(Who.CL, _s("draft"), None),
A.EDIT_DRAWING_REF: Rule(Who.CL, ANY, None),
A.EDIT_DEPT: Rule(Who.CL, _s("draft"), None),
A.MOVE_UNIT: Rule(Who.CL, ANY, None),
A.EDIT_WOS: Rule(Who.CL, _s("draft"), None),
A.EDIT_BOM: Rule(Who.HOLDER, _s("draft", "warehouse", SHOP), None),
A.WH_RECEIVE_SHORT: Rule(Who.WH, ANY, None, preconditions=("short_open",)),
A.EDIT_HEATS: Rule(Who.HOLDER, _s("warehouse", SHOP), None),
A.EDIT_HEAT_TABLE: Rule(Who.HOLDER, _s("warehouse", SHOP), None),
A.EDIT_WELDS: Rule(Who.HOLDER, SHOP, None),
A.EDIT_CHECKLIST: Rule(Who.HOLDER, SHOP, None),
A.EDIT_STAGE_FIELDS: Rule(Who.HOLDER, SHOP, None),
A.CLOCK: Rule(Who.HOLDER, SHOP, None),
A.EDIT_QC: Rule(Who.QC, _s("qc"), None),
A.SET_WH_STAGE: Rule(Who.WH, _s("warehouse"), None),
A.EDIT_DOCUMENT_SET: Rule(Who.CL, ANY, None, signature="Attach a drawing by hand"),
A.DOC_ADD: Rule(Who.CL_OR_QC, ANY, None),
A.DOC_REMOVE: Rule(Who.CL_OR_QC, ANY, None),
A.REV_APPROVE: Rule(
Who.CL_SUP,
_s("draft"),
None,
preconditions=_REASON,
signature="Approve the M-Files revision",
reason_required=True,
),
A.SET_VCLOSED: Rule(Who.CL, _s("complete"), None),
A.SET_DATABOOK_AT: Rule(Who.CL_OR_QC, ANY, None, filed_ok=True),
A.REMOVE: Rule(Who.CL, _s("draft"), None, preconditions=_REASON, reason_required=True),
A.CO_ISSUE: Rule(Who.CL, ANY, None, signature="Issue a change order"),
A.CO_APPROVE: Rule(Who.CL, ANY, None, signature="Approve a change order"),
A.CO_REJECT: Rule(
Who.CL, ANY, None, preconditions=_REASON, signature="Reject a change order", reason_required=True
),
A.CO_HOLD_TOGGLE: Rule(Who.CL, ANY, None),
A.CO_VERIFY: Rule(Who.CO_VERIFIER, ANY, None, signature="Verify a change order for the department"),
A.ITP_EDIT_LINE: Rule(Who.QC, ANY, None),
A.ITP_BLOCK_SIGN: Rule(Who.QC, ANY, None, signature="Sign an ITP block"),
A.ITP_SIGN_OFF: Rule(Who.QC, ANY, None, signature="Sign off the ITP"),
# QC supervisor; an admin passes through the bypass with an IT reason.
A.ITP_REOPEN: Rule(Who.QC_SUP, ANY, None, preconditions=_REASON, reason_required=True),
A.VIS_UPDATE_DRAFT: Rule(Who.SYSTEM, _s("draft"), None, system_only=True),
A.VIS_OPEN_CO: Rule(Who.SYSTEM, frozenset(STATUSES) - {"draft", "filed"}, None, system_only=True),
A.VIS_AUTO_RELEASE: Rule(Who.SYSTEM, _s("draft"), "warehouse", preconditions=_DRAFT_OUT, system_only=True),
A.VIS_RETIRE_DRAFT: Rule(Who.SYSTEM, _s("draft"), None, system_only=True),
}
# The user transitions (not the system ones), in table order.
TRANSITIONS: tuple[Action, ...] = tuple(a for a, r in RULES.items() if r.transition and not r.system_only)
# What an edit changes, for the "Your access doesn't allow changing <what>" message.
EDIT_WHAT: dict[Action, str] = {
A.EDIT_HEADER: "the packet details",
A.EDIT_DRAWING_REF: "the drawing number",
A.EDIT_DEPT: "the department",
A.MOVE_UNIT: "the unit",
A.EDIT_WOS: "the work orders",
A.EDIT_BOM: "the materials",
A.WH_RECEIVE_SHORT: "short parts",
A.EDIT_HEATS: "heat tags",
A.EDIT_HEAT_TABLE: "the heat table",
A.EDIT_WELDS: "welds",
A.EDIT_CHECKLIST: "the checklist",
A.EDIT_STAGE_FIELDS: "the stage details",
A.CLOCK: "the clock",
A.EDIT_QC: "the Q.C. record",
A.SET_WH_STAGE: "the warehouse stage",
A.EDIT_DOCUMENT_SET: "the drawing pages",
A.DOC_ADD: "the documents",
A.DOC_REMOVE: "the documents",
A.REV_APPROVE: "the drawing revision",
A.SET_VCLOSED: "the VISUAL close",
A.SET_DATABOOK_AT: "the data book date",
A.REMOVE: "whether it is removed",
A.CO_ISSUE: "change orders",
A.CO_APPROVE: "change orders",
A.CO_REJECT: "change orders",
A.CO_HOLD_TOGGLE: "change orders",
A.CO_VERIFY: "change orders",
A.ITP_EDIT_LINE: "the ITP",
A.ITP_BLOCK_SIGN: "the ITP",
A.ITP_SIGN_OFF: "the ITP",
A.ITP_REOPEN: "the ITP",
}
+123
View File
@@ -0,0 +1,123 @@
"""Custody: who holds a packet right now, and the Who predicates. A direct port of guard.js holds()."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from .actions import ACTIVE, SUPPORT_CODES, Who
from .principal import PacketState, Principal
WORKER, LEAD, SUPERVISOR = 1, 2, 3
def works_on(principal: Principal, state: PacketState) -> bool:
"""A member of the packet's department whose station limits allow the packet's station.
Leads and supervisors always pass. A worker passes when they have no station limits or the limits hold
the packet's station. This is the old worksOn(); START and SUBMIT add the assigned-welder check.
"""
level = principal.level(state.dept)
if level == 0:
return False
if level >= LEAD:
return True
return not principal.station_limits or (
state.station_id is not None and state.station_id in principal.station_limits
)
def holds(principal: Principal, state: PacketState) -> bool:
"""True when the principal holds the packet in its current status. Admin is not a holder; see the bypass."""
st = state.status
if st == "draft":
return "CL" in principal.support
if st == "warehouse":
return "WH" in principal.support
if st in ("ready", "review"):
return principal.level(state.dept) >= LEAD
if st in ACTIVE:
return principal.level(state.dept) >= LEAD or works_on(principal, state)
if st == "qc":
return "QC" in principal.support
if st == "complete":
return "CL" in principal.support
return False
def holder_name(state: PacketState, dept_name: str | None = None, station_name: str | None = None) -> str:
"""Who has the packet, for messages. The department name falls back to its code."""
st = state.status
if st == "draft":
return "Clerical"
if st == "warehouse":
return "Warehouse"
if st == "qc":
return "Q.C."
if st == "complete":
return "Clerical (data book)"
if st == "filed":
return "the data book (filed)"
name = dept_name or state.dept
if st in ACTIVE and station_name:
return f"{name} · {station_name}"
return name
def _works_on_assigned(principal: Principal, state: PacketState) -> bool:
"""START and SUBMIT: a lead or above in the department, or the assigned welder within their station limits."""
level = principal.level(state.dept)
if level >= LEAD:
return True
return (
level == WORKER
and principal.user_id is not None
and state.welder_id is not None
and str(state.welder_id) == str(principal.user_id)
and works_on(principal, state)
)
def _co_verifier(principal: Principal, extra: Mapping[str, Any]) -> bool:
"""A supervisor of the department named in the request (extra["dept"])."""
dept = extra.get("dept")
if not isinstance(dept, str) or not dept:
return False
if dept in ("WH", "QC"):
return dept in principal.support_sup
if dept in SUPPORT_CODES:
return False # Clerical issues change orders; it never verifies them. PM has no supervisor.
return principal.level(dept) >= SUPERVISOR
def allows(who: Who, principal: Principal, state: PacketState, extra: Mapping[str, Any]) -> bool:
"""Evaluate one Who predicate. Admin is never allowed here except by Who.ADMIN; the bypass is a rule."""
if who is Who.CL:
return "CL" in principal.support
if who is Who.CL_SUP:
return "CL" in principal.support_sup
if who is Who.WH:
return "WH" in principal.support
if who is Who.WH_SUP:
return "WH" in principal.support_sup
if who is Who.QC:
return "QC" in principal.support
if who is Who.QC_SUP:
return "QC" in principal.support_sup
if who is Who.CL_OR_QC:
return "CL" in principal.support or "QC" in principal.support
if who is Who.HOLDER:
return holds(principal, state)
if who is Who.DEPT_LEAD:
return principal.level(state.dept) >= LEAD
if who is Who.DEPT_SUP:
return principal.level(state.dept) >= SUPERVISOR
if who is Who.WORKS_ON:
return _works_on_assigned(principal, state)
if who is Who.CO_VERIFIER:
return _co_verifier(principal, extra)
if who is Who.ADMIN:
return principal.admin
if who is Who.SYSTEM:
return principal.system is not None
return False
+183
View File
@@ -0,0 +1,183 @@
"""The policy engine: check one action, or list what a principal can do on a packet.
Order of checks, exactly:
1. removed packet: nothing passes, Admin included.
2. status: the rule's from statuses and the status conditions (RETURN_TO_CLERICAL needs wh_skipped,
SET_VCLOSED needs routed_to databook). An unknown status fails here.
3. change-order hold, Admin included.
4. system-only rules refuse people; the system principal passes only system-only rules.
5. admin bypass of who and custody, with an IT reason (SET_DATABOOK_AT needs none).
6. filed lock: only SET_DATABOOK_AT and ADMIN_REOPEN, Admin included.
7. who and custody.
8. preconditions in rule order, never bypassed.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any
from ..text import reason_ok
from .actions import EDIT_WHAT, RULES, STATUS_LABELS, STATUSES, Action, Rule
from .custody import allows, holder_name
from .preconditions import INPUT_ONLY, PRECONDITIONS
from .principal import PacketState, Principal
EMPTY: Mapping[str, Any] = MappingProxyType({})
MSG_REMOVED = "This packet was removed."
MSG_UNKNOWN_STATUS = "This packet has an unknown status."
MSG_CO_HOLD = "A change order holds this step."
MSG_SYSTEM_ONLY = "Only the VISUAL feed does this."
MSG_SYSTEM_FORBIDDEN = "The VISUAL feed cannot do this."
MSG_IT_REASON = "Give an IT reason of at least 8 characters."
MSG_FILED = "This packet is filed and locked. IT can reopen it if a correction is needed."
MSG_FORBIDDEN_STEP = "Your access doesn't allow this step."
MSG_NOT_WH_SKIPPED = "This packet came through the warehouse. Send it back to the warehouse instead."
MSG_NOT_DATABOOK = "Only packets routed to the data book are closed in VISUAL here."
@dataclass(frozen=True)
class Decision:
ok: bool
code: str = "" # removed | wrong_status | co_hold | filed | it_reason_required | forbidden | precondition
# | reason_required | system_only
why: str = "" # the message for the user
admin_bypass: bool = False # an admin passed who and custody only through the bypass (an IT edit)
signature: str | None = None # the rule's signature meaning, when one is required
reasons: tuple[str, ...] = () # read mode only: every blocking reason (CO hold and state preconditions)
# Status conditions beyond the from statuses: (predicate, message when it fails).
STATUS_CONDITIONS: dict[Action, tuple[Callable[[PacketState], bool], str]] = {
Action.RETURN_TO_CLERICAL: (lambda s: s.wh_skipped, MSG_NOT_WH_SKIPPED),
Action.SET_VCLOSED: (lambda s: s.routed_to == "databook", MSG_NOT_DATABOOK),
}
def _wrong_status(state: PacketState, action: Action, rule: Rule) -> str | None:
if state.status not in STATUSES:
return MSG_UNKNOWN_STATUS
if rule.from_status and state.status not in rule.from_status:
return f"This step isn't open while the packet is {STATUS_LABELS[state.status]}."
cond = STATUS_CONDITIONS.get(action)
if cond is not None and not cond[0](state):
return cond[1]
return None
def _forbidden_why(action: Action, rule: Rule, state: PacketState, extra: Mapping[str, Any]) -> str:
what = EDIT_WHAT.get(action)
if rule.transition or what is None:
return MSG_FORBIDDEN_STEP
holder = extra.get("holder_name")
if not isinstance(holder, str) or not holder:
holder = holder_name(state)
return f"Your access doesn't allow changing {what} on this packet. It's with {holder} right now."
def _evaluate(
principal: Principal,
state: PacketState,
action: Action,
extra: Mapping[str, Any],
it_reason: str | None,
*,
read: bool,
assume_it_reason: bool,
) -> Decision:
rule = RULES[action]
# (1) removed
if state.removed:
return Decision(False, "removed", MSG_REMOVED)
# (2) status
why = _wrong_status(state, action, rule)
if why is not None:
return Decision(False, "wrong_status", why)
# (3) change-order hold (deferred in read mode so the UI can show it as blocked after who passes)
blockers: list[str] = []
if rule.co_hold and state.co_hold:
hold_why = state.co_hold_why or MSG_CO_HOLD
if not read:
return Decision(False, "co_hold", hold_why)
blockers.append(hold_why)
# (4) system
if rule.system_only and principal.system is None:
return Decision(False, "system_only", MSG_SYSTEM_ONLY)
if principal.system is not None and not rule.system_only:
return Decision(False, "forbidden", MSG_SYSTEM_FORBIDDEN)
# (5) admin bypass
who_ok = allows(rule.who, principal, state, extra)
bypass = False
if not who_ok and principal.admin:
if action is Action.SET_DATABOOK_AT:
bypass = True
elif (read and assume_it_reason) or reason_ok(it_reason) is not None:
bypass = True
else:
return Decision(False, "it_reason_required", MSG_IT_REASON)
# (6) filed lock
if state.status == "filed" and not rule.filed_ok:
return Decision(False, "filed", MSG_FILED)
# (7) who and custody
if not who_ok and not bypass:
return Decision(False, "forbidden", _forbidden_why(action, rule, state, extra))
# (8) preconditions, never bypassed
for name in rule.preconditions:
if read and name in INPUT_ONLY:
continue
failed = PRECONDITIONS[name](state, extra)
if failed is None:
continue
if read:
blockers.append(failed)
continue
code = "reason_required" if rule.reason_required and name == "reason_given" else "precondition"
return Decision(False, code, failed, admin_bypass=bypass)
if blockers:
code = "co_hold" if rule.co_hold and state.co_hold else "precondition"
return Decision(False, code, blockers[0], admin_bypass=bypass, reasons=tuple(blockers))
return Decision(True, admin_bypass=bypass, signature=rule.signature)
def check(
principal: Principal,
state: PacketState,
action: Action,
*,
extra: Mapping[str, Any] = EMPTY,
it_reason: str | None = None,
) -> Decision:
"""Decide one action with the request input in `extra`. Never raises for an unknown status."""
return _evaluate(principal, state, action, extra, it_reason, read=False, assume_it_reason=False)
def evaluate(principal: Principal, state: PacketState, action: Action, *, assume_it_reason: bool = True) -> Decision:
"""Read-mode decision: input-only preconditions skipped, CO hold and state preconditions collected in reasons.
For an admin the IT reason is assumed when `assume_it_reason` is true; admin_bypass says the UI will ask.
"""
return _evaluate(principal, state, action, EMPTY, None, read=True, assume_it_reason=assume_it_reason)
def can(
principal: Principal, state: PacketState, assume_it_reason: bool = True
) -> tuple[list[Action], dict[Action, list[str]]]:
"""What the principal can do now, and what is blocked with the reasons. System-only actions are never listed.
`can` holds the actions that pass with an empty extra (input-only preconditions skipped). `blocked` holds the
actions that pass who and custody but fail a state precondition or the CO hold.
"""
allowed: list[Action] = []
blocked: dict[Action, list[str]] = {}
for action, rule in RULES.items():
if rule.system_only:
continue
d = evaluate(principal, state, action, assume_it_reason=assume_it_reason)
if d.ok:
allowed.append(action)
elif d.reasons:
blocked[action] = list(d.reasons)
return allowed, blocked
@@ -0,0 +1,146 @@
"""Named preconditions. Each takes (state, extra) and returns the reason when it fails, else None.
They are checked on the server and never bypassed, including by Admin. Some read request input (extra) rather
than packet state; `can()` skips those, because they are inputs the user gives with the step.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from typing import Any
from ..text import clean_text, reason_ok
from .actions import SUPPORT_CODES
from .principal import PacketState
Precondition = Callable[[PacketState, Mapping[str, Any]], str | None]
MESSAGES: dict[str, str] = {
"heat_map_answered": "Say whether this packet needs a heat map.",
"drawing_attached": "Attach the drawing first.",
"bom_all_pulled_or_short": "Pull every material line or mark it short first.",
"short_open": "No part is short on this packet.",
"welder_set": "Pick a station and a welder.",
"checklist_complete": "Finish the checklist first.",
"welds_recorded": "Record at least one weld first.",
"filler_set": "Enter the filler metal first.",
"qc_book_spool_location": "Confirm the book and the spool, and give the location.",
"qc_missing_empty": "Q.C. is not finished.",
"reason_given": "Give a reason of at least 8 characters.",
"vclosed_if_databook": "Close the job in VISUAL first.",
"co_staged_if_dwg_change": "Stage the new drawing for the change order first.",
"route_given": "Choose Data Book or Shipping.",
"target_dept_given": "Choose a different department.",
}
# Preconditions that read request input, not packet state. can() skips them.
INPUT_ONLY: frozenset[str] = frozenset(
{
"welder_set",
"target_dept_given",
"route_given",
"reason_given",
"qc_book_spool_location",
"co_staged_if_dwg_change",
}
)
ROUTES = ("databook", "shipping")
def _fail(name: str, ok: bool) -> str | None:
return None if ok else MESSAGES[name]
def heat_map_answered(state: PacketState, extra: Mapping[str, Any]) -> str | None:
return _fail("heat_map_answered", state.heat_map is not None)
def drawing_attached(state: PacketState, extra: Mapping[str, Any]) -> str | None:
return _fail("drawing_attached", state.drawing_attached)
def bom_all_pulled_or_short(state: PacketState, extra: Mapping[str, Any]) -> str | None:
return _fail("bom_all_pulled_or_short", state.bom_ready >= state.bom_total)
def short_open(state: PacketState, extra: Mapping[str, Any]) -> str | None:
"""The old whCanReceive: the warehouse may only receive into a packet with an open short."""
return _fail("short_open", state.open_shorts > 0)
def welder_set(state: PacketState, extra: Mapping[str, Any]) -> str | None:
return _fail("welder_set", extra.get("station_id") not in (None, "") and extra.get("welder_id") not in (None, ""))
def checklist_complete(state: PacketState, extra: Mapping[str, Any]) -> str | None:
return _fail("checklist_complete", state.checklist_complete)
def welds_recorded(state: PacketState, extra: Mapping[str, Any]) -> str | None:
return _fail("welds_recorded", not state.weld_dept or state.welds > 0)
def filler_set(state: PacketState, extra: Mapping[str, Any]) -> str | None:
return _fail("filler_set", not state.weld_dept or state.filler_set)
def qc_book_spool_location(state: PacketState, extra: Mapping[str, Any]) -> str | None:
ok = extra.get("book") is True and extra.get("spool") is True and bool(clean_text(extra.get("location"), 120))
return _fail("qc_book_spool_location", ok)
def qc_missing_empty(state: PacketState, extra: Mapping[str, Any]) -> str | None:
if not state.qc_missing:
return None
return "Q.C. still needs: " + ", ".join(state.qc_missing) + "."
def reason_given(state: PacketState, extra: Mapping[str, Any]) -> str | None:
return _fail("reason_given", reason_ok(extra.get("reason")) is not None)
def vclosed_if_databook(state: PacketState, extra: Mapping[str, Any]) -> str | None:
return _fail("vclosed_if_databook", state.routed_to != "databook" or state.vclosed)
def co_staged_if_dwg_change(state: PacketState, extra: Mapping[str, Any]) -> str | None:
return _fail("co_staged_if_dwg_change", extra.get("dwg_change") is not True or extra.get("staged") is True)
def route_given(state: PacketState, extra: Mapping[str, Any]) -> str | None:
return _fail("route_given", extra.get("routed_to") in ROUTES)
def is_shop_code(value: Any) -> bool:
"""A non-empty department code that is not a support code. The API checks it exists."""
return isinstance(value, str) and bool(value.strip()) and value not in SUPPORT_CODES
def target_dept_given(state: PacketState, extra: Mapping[str, Any]) -> str | None:
"""A shop department different from the packet's. For a draft (RELEASE_TO_SHOP) any shop department."""
dept = extra.get("dept")
if not is_shop_code(dept):
return "Choose a department."
if state.status != "draft" and dept == state.dept:
return MESSAGES["target_dept_given"]
return None
PRECONDITIONS: dict[str, Precondition] = {
"heat_map_answered": heat_map_answered,
"drawing_attached": drawing_attached,
"bom_all_pulled_or_short": bom_all_pulled_or_short,
"short_open": short_open,
"welder_set": welder_set,
"checklist_complete": checklist_complete,
"welds_recorded": welds_recorded,
"filler_set": filler_set,
"qc_book_spool_location": qc_book_spool_location,
"qc_missing_empty": qc_missing_empty,
"reason_given": reason_given,
"vclosed_if_databook": vclosed_if_databook,
"co_staged_if_dwg_change": co_staged_if_dwg_change,
"route_given": route_given,
"target_dept_given": target_dept_given,
}
+99
View File
@@ -0,0 +1,99 @@
"""The policy's view of a person (Principal) and of a packet (PacketState)."""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from types import MappingProxyType
from ..auth.groups import SHOP_LEVELS, parse_role
SUPPORT = ("CL", "WH", "QC")
LEVELS: dict[str, int] = {level: i + 1 for i, level in enumerate(SHOP_LEVELS)} # worker 1, lead 2, supervisor 3
@dataclass(frozen=True)
class Principal:
user_id: str | None # uuid as text; None for system
admin: bool = False
support: frozenset[str] = frozenset() # codes among CL, WH, QC the user is a member of (any level)
support_sup: frozenset[str] = frozenset() # codes among CL, WH, QC where the user is Supervisor
shop: Mapping[str, int] = field(default_factory=lambda: MappingProxyType({})) # code -> 1 worker, 2 lead, 3 sup
station_limits: frozenset[int] = frozenset() # station ids a worker is limited to; empty = any
pm: bool = False
session_kind: str = "password" # password | pin
system: str | None = None # "visual-feed" for the worker; never from a session
def level(self, dept: str) -> int:
"""Shop level in a department: 0 none, 1 worker, 2 lead, 3 supervisor."""
return int(self.shop.get(dept, 0))
@dataclass(frozen=True)
class PacketState:
id: int
status: str # packet_status value
dept: str # dept_code
station_id: int | None
welder_id: str | None # uuid text of the assigned welder
wh_skipped: bool
routed_to: str | None # databook | shipping | None
vclosed: bool
removed: bool
source_kind: str # visual | hand
weld_dept: bool # departments.weld
co_hold: bool # an approved CO with pending verifications, or a pending CO with hold=true
co_hold_why: str = "" # text for the error
open_shorts: int = 0
drawing_attached: bool = False
heat_map: bool | None = None
bom_total: int = 0 # lines not marked n/a
bom_ready: int = 0 # lines pulled, or with an open short
welds: int = 0
checklist_complete: bool = False # every key of the department checklist is done (empty list is complete)
filler_set: bool = False
qc_missing: tuple[str, ...] = () # the old qcMissing() list, computed by the read model
has_qc_sent: bool = False # qc_inspections row exists for the current stage
SYSTEM_FEED = Principal(user_id=None, system="visual-feed")
def principal_from_roles(
user_id: str | None,
roles: Iterable[str],
station_limits: Iterable[int] = (),
session_kind: str = "password",
) -> Principal:
"""Build the policy Principal from phase 1 role strings. A role that does not parse is ignored."""
admin = False
pm = False
support: set[str] = set()
support_sup: set[str] = set()
shop: dict[str, int] = {}
for text in roles:
try:
role = parse_role(text)
except ValueError:
continue
if role.kind == "admin":
admin = True
elif role.kind == "pm":
pm = True
elif role.kind.startswith("shop_") and role.dept_code:
level = LEVELS[role.kind.removeprefix("shop_")]
shop[role.dept_code] = max(level, shop.get(role.dept_code, 0))
elif role.dept_code in SUPPORT:
support.add(role.dept_code)
if role.kind.endswith("_supervisor"):
support_sup.add(role.dept_code)
return Principal(
user_id=user_id,
admin=admin,
support=frozenset(support),
support_sup=frozenset(support_sup),
shop=MappingProxyType(shop),
station_limits=frozenset(int(s) for s in station_limits),
pm=pm,
session_kind=session_kind,
)
+8
View File
@@ -99,6 +99,7 @@ class Settings(BaseSettings):
device_cookie_days: int = Field(default=365, ge=1)
pin_min_digits: int = Field(default=6, ge=4)
pin_max_digits: int = Field(default=12, ge=4)
upload_max_bytes: int = Field(default=25 * 1024 * 1024, ge=1024)
@model_validator(mode="before")
@classmethod
@@ -159,6 +160,13 @@ class Settings(BaseSettings):
raise SecretFileError(f"SESSION_KEY_FILE file {self.session_key_file} holds a key that is too short")
return key.encode("utf-8")
def seal_key(self) -> bytes:
"""The HMAC key for filing seals, from SEAL_KEY_FILE."""
key = read_secret(self.seal_key_file, "SEAL_KEY_FILE").strip()
if len(key) < 16:
raise SecretFileError(f"SEAL_KEY_FILE file {self.seal_key_file} holds a key that is too short")
return key.encode("utf-8")
@lru_cache(maxsize=1)
def get_settings() -> Settings:
+90
View File
@@ -0,0 +1,90 @@
"""Input cleaning and limits: the old build's cleanText, cleanDeep and validation regexes, now on the server.
Every string that enters the database goes through `clean_text` with the field's limit. Control characters
(except tab, LF and CR), DEL, the zero-width and bidi override characters are removed, the text is NFC
normalised, trimmed and cut to the limit.
"""
from __future__ import annotations
import re
import unicodedata
from typing import Any
# C0 controls except tab (09), LF (0A) and CR (0D); DEL; zero-width and bidi marks; isolates.
_STRIP = re.compile("[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f​-‏‪-‮⁦-⁩]")
RX_HEAT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ./#_-]{0,39}$")
RX_STAMP = re.compile(r"^[A-Za-z0-9-]{1,10}$")
RX_EMP = re.compile(r"^[A-Z0-9-]{1,20}$")
RX_PROJECT = re.compile(r"^[A-Z0-9][A-Z0-9-]{0,19}$")
RX_LOT = re.compile(r"^[A-Z0-9]{1,3}$")
# Character limits per field. Reasons for IT edits and send-backs need at least REASON_MIN.
LIMITS: dict[str, int] = {
"title": 200,
"customer": 120,
"drawing": 60,
"rev": 10,
"wps": 40,
"op": 40,
"notes": 2000,
"reason": 500,
"note": 500,
"heat": 40,
"stamp": 10,
"location": 120,
"part_id": 60,
"description": 240,
"material": 120,
"length": 40,
"project_number": 20,
"project_name": 120,
"po": 60,
"lot": 3,
"unit_name": 120,
"doc_title": 160,
"ecn": 40,
"name": 160,
}
REASON_MIN = 8
MAX_BODY_BYTES = 200_000
def clean_text(value: Any, max_len: int = 2000) -> str:
"""The old cleanText: strip controls and invisible marks, NFC, trim, cut. None becomes ''."""
if value is None:
return ""
text = value if isinstance(value, str) else str(value)
text = _STRIP.sub("", text)
text = unicodedata.normalize("NFC", text).strip()
return text[:max_len]
def clean_opt(value: Any, max_len: int) -> str | None:
"""clean_text, but an empty result is None (for nullable columns)."""
text = clean_text(value, max_len)
return text or None
def clean_deep(value: Any, max_len: int = 4000, depth: int = 0) -> Any:
"""Clean every string inside a JSON-like value. Depth is capped at 12 like the old build."""
if depth > 12:
return value
if isinstance(value, str):
return clean_text(value, max_len)
if isinstance(value, list):
return [clean_deep(x, max_len, depth + 1) for x in value]
if isinstance(value, dict):
return {str(k)[:120]: clean_deep(v, max_len, depth + 1) for k, v in value.items()}
return value
def limit(field: str) -> int:
return LIMITS.get(field, 2000)
def reason_ok(value: Any) -> str | None:
"""A cleaned reason of at least REASON_MIN characters, else None."""
text = clean_text(value, LIMITS["reason"])
return text if len(text) >= REASON_MIN else None