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:
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""The core domain: packets, projects and units, documents, the write pipeline. Pure SQL helpers over one connection."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,))}
|
||||
@@ -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
|
||||
@@ -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]}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user