Phase 6: Q.C. inspection, project folders, packet documents and the digital ITP
ci / frontend (push) Successful in 37s
ci / server (push) Successful in 9m18s
ci / image (push) Successful in 35s
ci / e2e (push) Successful in 2m17s

The Q.C. screen (KPIs, boards, project folders), the Q.C. tab on the packet
page (check items from the qc_check_items settings row, visual exam per weld,
NDE, hardness with the NACE label, notes), accept with a route and a
signature gated by qc_missing_empty, reject with a reason or an NCR number
closing the inspection into qc_history, Q.C. folder documents per project and
unit with superseding revisions, packet documents as a closed set with
take-out reasons, the digital ITP per unit from the frozen template (line
initials with CAS, unsign, N/A, customer codes, rejects with NCR, block
signatures, sign-off with the PDF hook, admin reopen, the ITP log), hold-point
warnings by department page group, the scoped policy check for unit and
project writes, migration 0007, tests, Playwright scenarios and docs.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-17 20:08:53 -06:00
co-authored by Claude Fable 5.1
parent 8bb1f7ae4d
commit 00531cb329
64 changed files with 11715 additions and 260 deletions
+744
View File
@@ -0,0 +1,744 @@
"""The digital ITP: read, start, header, lines, sections, blocks, sign-off and reopen.
Reads need any session. Every write runs policy.engine.check_scoped with its action, then locks the ITP row; the
answer is the full ITP read. ITP writes never look at a packet: a filed packet in the unit does not lock the ITP.
"""
from __future__ import annotations
from datetime import date
from typing import Any
from fastapi import APIRouter, Depends, Request
from psycopg.types.json import Jsonb
from pydantic import BaseModel, Field
from .. import policy
from ..domain import itp as dom
from ..domain import ncr as ncrs
from ..domain import packets
from ..domain.itp_template import ITP_TEMPLATE
from ..domain.pipeline import SignBody, apply_field_cas, jsonable, parse_if_match
from ..text import LIMITS, clean_opt, clean_text
from .deps import Db, Principal, current_principal, get_db
from .errors import ApiError
from .packets import WRITE
router = APIRouter(tags=["itp"])
A = policy.Action
HDR_MAX = 120
CUST_BY_MAX = 80
NOTE_MAX = 500
QN_MAX = 40
REMARKS_MAX = 400
CUST_NAME_MAX = 80
HDR_FIELDS = ("no", *dom.HDR_KEYS)
MSG_NO_LINE = "No such ITP line."
MSG_NO_SECTION = "No such ITP section."
MSG_NO_BLOCK = "No such ITP block."
# ---------------------------------------------------------------- bodies
class It(BaseModel):
it_reason: str | None = Field(default=None, max_length=1000)
class HeaderBody(It):
set: dict[str, str | None] = Field(max_length=10)
base: dict[str, str | None] = Field(default_factory=dict, max_length=10)
class LineBody(It):
na: bool | None = None
cust_code: str | None = Field(default=None, max_length=20)
cust_by: str | None = Field(default=None, max_length=500)
cust_at: date | None = None
note: str | None = Field(default=None, max_length=2000)
class SectionNaBody(It):
na: bool
class RejectBody(It):
ncr: str | None = Field(default=None, max_length=100)
note: str | None = Field(default=None, max_length=2000)
class NcrBody(It):
ncr: str = Field(max_length=100)
class BlockBody(It):
qn: str | None = Field(default=None, max_length=500)
remarks: str | None = Field(default=None, max_length=2000)
cust_name: str | None = Field(default=None, max_length=500)
cust_at: date | None = None
cust_clear: bool | None = None
class SignBodyOnly(It):
sign: SignBody | None = None
class ReopenBody(It):
reason: str | None = Field(default=None, max_length=2000)
# ---------------------------------------------------------------- helpers
def _pp(d: Db, p: Principal) -> policy.Principal:
return packets.policy_principal_for(d.conn, p)
def _answer(d: Db, p: Principal, itp_id: int) -> dict[str, Any]:
row = dom.load_row(d.conn, itp_id)
if row is None:
raise ApiError(404, "not_found", dom.MSG_NO_ITP)
return dom.read(d.conn, row, _pp(d, p))
def _item(w: dom.ItpWrite, key: str) -> dict[str, Any]:
it = next((i for i in dom.items(w.itp["template"] or {}) if str(i["n"]) == key), None)
if it is None:
raise ApiError(404, "not_found", MSG_NO_LINE)
return it
def _line(w: dom.ItpWrite, key: str) -> dict[str, Any]:
_item(w, key)
row = packets.one(
w.conn, "SELECT * FROM itp_lines WHERE itp_id = %s AND key = %s FOR UPDATE", (w.id, key)
) or packets.one(
w.conn,
"INSERT INTO itp_lines (itp_id, key) VALUES (%s, %s) RETURNING *",
(w.id, key),
)
assert row is not None
return row
def _update_line(w: dom.ItpWrite, line_id: int, values: dict[str, Any]) -> None:
sets = ", ".join(f"{k} = %s" for k in values)
w.conn.execute(
f"UPDATE itp_lines SET {sets}, row_version = row_version + 1 WHERE id = %s", # noqa: S608 (fixed field names)
(*values.values(), line_id),
)
def _block(w: dom.ItpWrite, key: str) -> tuple[dict[str, Any], dict[str, Any]]:
b = next((x for x in dom.blocks(w.itp["template"] or {}) if x["key"] == key), None)
if b is None:
raise ApiError(404, "not_found", MSG_NO_BLOCK)
row = packets.one(
w.conn, "SELECT * FROM itp_blocks WHERE itp_id = %s AND block_key = %s FOR UPDATE", (w.id, key)
) or packets.one(
w.conn,
"INSERT INTO itp_blocks (itp_id, block_key) VALUES (%s, %s) RETURNING *",
(w.id, key),
)
assert row is not None
return b, row
def _update_block(w: dom.ItpWrite, block_id: int, values: dict[str, Any]) -> None:
sets = ", ".join(f"{k} = %s" for k in values)
w.conn.execute(
f"UPDATE itp_blocks SET {sets}, row_version = row_version + 1 WHERE id = %s", # noqa: S608 (fixed field names)
(*values.values(), block_id),
)
def _lines(w: dom.ItpWrite) -> dict[str, dict[str, Any]]:
return dom.load_lines(w.conn, w.id)
def _is_admin(p: Principal) -> bool:
return bool(p.is_admin)
def _same_user(a: Any, b: Any) -> bool:
return a is not None and b is not None and str(a) == str(b)
# ---------------------------------------------------------------- read and start
@router.get("/api/units/{unit_id}/itp")
def get_itp(
unit_id: int,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""The unit's ITP, or {"itp": null, "unit": {id, number}} when it has none. 404 for an unknown unit."""
conn = d.conn
unit = packets.one(conn, "SELECT id, number FROM v_units WHERE id = %s AND removed_at IS NULL", (unit_id,))
if unit is None:
raise ApiError(404, "not_found", "No such unit.")
row = dom.row_for_unit(conn, unit_id)
if row is None:
return {"itp": None, "unit": {"id": unit["id"], "number": unit["number"]}}
return dom.read(conn, row, _pp(d, p))
@router.post("/api/units/{unit_id}/itp", dependencies=WRITE)
def start_itp(
unit_id: int,
request: Request,
body: It | None = None,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Start the unit's ITP from the template: the template is frozen, the header filled from the unit and project."""
body = body or It()
conn = d.conn
pp = _pp(d, p)
decision, it_kept = dom.scoped_decision(p, pp, A.ITP_START, it_reason=body.it_reason)
with conn.transaction():
unit = packets.one(
conn,
"SELECT u.id, u.po, u.lot, p.number AS pno, p.name AS pname, p.customer, p.po AS project_po"
" FROM units u JOIN projects p ON p.id = u.project_id"
" WHERE u.id = %s AND u.removed_at IS NULL FOR UPDATE OF u",
(unit_id,),
)
if unit is None:
raise ApiError(404, "not_found", "No such unit.")
if dom.row_for_unit(conn, unit_id) is not None:
raise ApiError(409, "itp_exists", "This unit already has an ITP.")
number = f"{unit['pno']}{unit['lot']}"
no = f"{number}-ITP"
hdr = {
"client": unit["customer"] or "",
"pname": unit["pname"] or "",
"pno": unit["pno"] or "",
"po": unit["po"] or unit["project_po"] or "",
"unit": number,
}
template = jsonable(ITP_TEMPLATE)
itp_id = int(
conn.execute(
"INSERT INTO itps (unit_id, template, status, no, hdr, created_by) VALUES (%s, %s, 'open', %s, %s, %s)"
" RETURNING id",
(unit_id, Jsonb(template), no, Jsonb(hdr), p.user_id),
).fetchone()[0]
)
keys = [str(i["n"]) for i in dom.items(template)]
with conn.cursor() as cur:
cur.executemany("INSERT INTO itp_lines (itp_id, key) VALUES (%s, %s)", [(itp_id, k) for k in keys])
cur.executemany(
"INSERT INTO itp_blocks (itp_id, block_key) VALUES (%s, %s)",
[(itp_id, b["key"]) for b in dom.blocks(template)],
)
row = dom.load_row(conn, itp_id, lock=True)
assert row is not None
w = dom.ItpWrite(
conn=conn,
principal=p,
policy_principal=pp,
itp=row,
action=A.ITP_START,
decision=decision,
request=request,
it_reason=it_kept,
version=int(row["version"]),
)
w.log("ITP started", f"{no} · template {template.get('ver')}")
dom._finish(w)
return _answer(d, p, itp_id)
# ---------------------------------------------------------------- header
@router.patch("/api/itps/{itp_id}", dependencies=WRITE)
def patch_header(
itp_id: int,
body: HeaderBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""The plan number and header fields, with field-level compare-and-set (409 field_conflict)."""
bad = sorted(k for k in body.set if k not in HDR_FIELDS)
if bad:
raise ApiError(422, "invalid_field", f"Not an ITP header field: {', '.join(bad)}.")
values = {k: clean_text(v, HDR_MAX) for k, v in body.set.items()}
base = {k: clean_text(v, HDR_MAX) for k, v in body.base.items() if k in HDR_FIELDS}
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
hdr = dict(w.itp["hdr"] or {})
current = {"no": w.itp["no"] or "", **{k: hdr.get(k) or "" for k in dom.HDR_KEYS}}
changes = apply_field_cas(current, values, base)
if "no" in changes and not changes["no"]:
raise ApiError(422, "invalid_field", "The plan number cannot be empty.")
if not changes:
return _answer(d, p, itp_id)
for k in HDR_FIELDS:
if k not in changes:
continue
if k == "no":
w.log("ITP plan no. changed", changes[k])
else:
hdr[k] = changes[k]
w.log("ITP header changed", f"{k}: {changes[k]}")
w.conn.execute(
"UPDATE itps SET no = %s, hdr = %s WHERE id = %s", (changes.get("no", w.itp["no"]), Jsonb(hdr), itp_id)
)
return _answer(d, p, itp_id)
# ---------------------------------------------------------------- lines
@router.post("/api/itps/{itp_id}/lines/{key}/sign", dependencies=WRITE)
def sign_line(
itp_id: int,
key: str,
request: Request,
body: It | None = None,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Approve a line (Q.C. initials). Optional If-Match is the line's row_version."""
body = body or It()
if_match = parse_if_match(request)
with dom.itp_write(d, p, itp_id, A.ITP_LINE_SIGN, request=request, it_reason=body.it_reason) as w:
line = _line(w, key)
if if_match is not None and int(line["row_version"]) != if_match:
raise ApiError(
409,
"row_conflict",
"Someone else changed this line. Reload and try again.",
extra={"current": {"row_version": line["row_version"], "result": line["result"]}},
)
if line["result"] == "approve":
raise ApiError(409, "already_signed", "This line is already approved.")
if line["result"] == "na":
raise ApiError(409, "line_na", "This line is marked N/A.")
rejected = line["result"] == "reject"
if rejected and not line["ncr"]:
raise ApiError(409, "ncr_required", "Enter the NCR # before approving this line.")
name = p.user["display_name"]
w.conn.execute(
"UPDATE itp_lines SET result = 'approve', by = %s, at = now(), initials = %s,"
" row_version = row_version + 1 WHERE id = %s",
(p.user_id, dom.initials(name), line["id"]),
)
w.log(f"ITP line {key} approved", f"after NCR {line['ncr']}" if rejected else None)
return _answer(d, p, itp_id)
@router.post("/api/itps/{itp_id}/lines/{key}/unsign", dependencies=WRITE)
def unsign_line(
itp_id: int,
key: str,
request: Request,
body: It | None = None,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Remove an approval: the inspector who initialed it, or an admin, while the line's block is not signed."""
body = body or It()
with dom.itp_write(d, p, itp_id, A.ITP_LINE_UNSIGN, request=request, it_reason=body.it_reason) as w:
line = _line(w, key)
if line["result"] != "approve":
raise ApiError(409, "not_signed", "This line is not approved.")
if not (_same_user(line["by"], p.user_id) or _is_admin(p)):
raise ApiError(403, "forbidden", "Only the inspector who initialed this line, or an admin, can remove it.")
blk = dom.block_of_line(w.itp["template"] or {}, key)
if blk is not None:
brow = packets.one(
w.conn, "SELECT signed_at FROM itp_blocks WHERE itp_id = %s AND block_key = %s", (w.id, blk["key"])
)
if brow is not None and brow["signed_at"] is not None:
raise ApiError(409, "block_signed", "Undo the section signature first.")
who = packets.People(w.conn, [line["by"]]).name(line["by"]) or ""
was = f"was {who} {dom.date_text(line['at'], w.tz)}".strip()
w.conn.execute(
"UPDATE itp_lines SET result = NULL, by = NULL, at = NULL, initials = NULL,"
" row_version = row_version + 1 WHERE id = %s",
(line["id"],),
)
w.log(f"ITP line {key} initial removed", was)
return _answer(d, p, itp_id)
@router.patch("/api/itps/{itp_id}/lines/{key}", dependencies=WRITE)
def patch_line(
itp_id: int,
key: str,
body: LineBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""N/A, the customer code and witness, and the note of a line."""
given = body.model_fields_set
cc: str | None = None
if "cust_code" in given:
cc = clean_text(body.cust_code, 20).upper()
if cc and cc not in dom.CUST_CODES:
raise ApiError(422, "invalid_field", "Pick a customer code from the list.")
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
line = _line(w, key)
values: dict[str, Any] = {}
parts: list[str] = []
if "na" in given and body.na is not None and body.na != (line["result"] == "na"):
if body.na:
if line["result"] == "approve":
raise ApiError(409, "already_signed", "Undo the approval first.")
if line["result"] == "reject":
raise ApiError(409, "line_rejected", "This line is rejected.")
values["result"] = "na"
parts.append("marked N/A")
else:
values["result"] = None
parts.append("applies again")
if "cust_code" in given and (cc or None) != (line["cust_code"] or None):
values["cust_code"] = cc or None
parts.append(f"customer code {cc}" if cc else "customer code as template")
if "cust_by" in given:
who = clean_opt(body.cust_by, CUST_BY_MAX)
if who != (line["cust_by"] or None):
values["cust_by"] = who
parts.append(f"customer {who}" if who else "customer cleared")
if who:
values["cust_at"] = body.cust_at or dom.today(w.tz)
values["cust_rec"] = p.user_id
values["cust_rec_at"] = w.conn.execute("SELECT now()").fetchone()[0]
else:
values.update({"cust_at": None, "cust_rec": None, "cust_rec_at": None})
elif who and "cust_at" in given and body.cust_at and body.cust_at != line["cust_at"]:
values["cust_at"] = body.cust_at
parts.append(f"customer date {dom.date_text(body.cust_at)}")
elif "cust_at" in given and line["cust_by"] and body.cust_at and body.cust_at != line["cust_at"]:
values["cust_at"] = body.cust_at
parts.append(f"customer date {dom.date_text(body.cust_at)}")
if "note" in given:
note = clean_opt(body.note, NOTE_MAX)
if note != (line["note"] or None):
values["note"] = note
parts.append("note")
if not values:
return _answer(d, p, itp_id)
_update_line(w, line["id"], values)
w.log(f"ITP line {key} updated", ", ".join(parts))
return _answer(d, p, itp_id)
@router.post("/api/itps/{itp_id}/sections/{n}/na", dependencies=WRITE)
def section_na(
itp_id: int,
n: str,
body: SectionNaBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Mark every line of a section N/A, or make it apply again. Refused once a line of it is approved."""
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
sec = next((s for s in (w.itp["template"] or {}).get("sections") or [] if str(s.get("n")) == n), None)
if sec is None:
raise ApiError(404, "not_found", MSG_NO_SECTION)
rows = [_line(w, str(i["n"])) for i in sec.get("items") or []]
if any(r["result"] == "approve" for r in rows):
raise ApiError(409, "already_signed", "A line of this section is approved. Undo it first.")
if body.na and any(r["result"] == "reject" for r in rows):
raise ApiError(409, "line_rejected", "A line of this section is rejected.")
want = "na" if body.na else None
todo = [r for r in rows if (r["result"] == "na") != body.na]
if not todo:
return _answer(d, p, itp_id)
for r in todo:
_update_line(w, r["id"], {"result": want})
w.log(f"ITP section {n} {'marked N/A' if body.na else 'applies again'}", str(sec.get("t") or ""))
return _answer(d, p, itp_id)
@router.post("/api/itps/{itp_id}/lines/{key}/reject", dependencies=WRITE)
def reject_line(
itp_id: int,
key: str,
body: RejectBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Reject a line, with its NCR number now or later. The line cannot be approved until the NCR is entered."""
ncr = ncrs.validate_ncr(body.ncr) if clean_text(body.ncr, 100) else None
note = clean_opt(body.note, NOTE_MAX)
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
line = _line(w, key)
if line["result"] == "approve":
raise ApiError(409, "already_signed", "This line is approved. Undo the approval first.")
if line["result"] == "na":
raise ApiError(409, "line_na", "This line is marked N/A.")
if line["result"] == "reject":
raise ApiError(409, "line_rejected", "This line is already rejected.")
_update_line(w, line["id"], {"result": "reject", "ncr": ncr})
w.conn.execute(
"INSERT INTO itp_rejects (itp_id, line_key, ncr, note, by, kind) VALUES (%s, %s, %s, %s, %s, 'reject')",
(w.id, key, ncr, note, p.user_id),
)
head = f"NCR {ncr}" if ncr else "NCR # pending"
w.log(f"ITP line {key} rejected", f"{head} · {note}" if note else head)
return _answer(d, p, itp_id)
@router.post("/api/itps/{itp_id}/lines/{key}/ncr", dependencies=WRITE)
def enter_ncr(
itp_id: int,
key: str,
body: NcrBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""The NCR number of an open reject that has none yet."""
ncr = ncrs.validate_ncr(body.ncr)
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
line = _line(w, key)
if line["result"] != "reject" or line["ncr"]:
raise ApiError(409, "no_open_reject", "This line has no open reject waiting for an NCR #.")
_update_line(w, line["id"], {"ncr": ncr})
w.conn.execute(
"INSERT INTO itp_rejects (itp_id, line_key, ncr, by, kind) VALUES (%s, %s, %s, %s, 'ncr')",
(w.id, key, ncr, p.user_id),
)
w.log(f"ITP line {key} NCR number entered", f"NCR {ncr}")
return _answer(d, p, itp_id)
# ---------------------------------------------------------------- blocks
@router.patch("/api/itps/{itp_id}/blocks/{key}", dependencies=WRITE)
def patch_block(
itp_id: int,
key: str,
body: BlockBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""QN #, remarks and the customer complete record of a block. No signature."""
given = body.model_fields_set
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
_b, row = _block(w, key)
values: dict[str, Any] = {}
if "qn" in given:
qn = clean_opt(body.qn, QN_MAX)
if qn != (row["qn"] or None):
values["qn"] = qn
w.log(f"ITP block {key} QN # changed", qn or "")
if "remarks" in given:
rem = clean_opt(body.remarks, REMARKS_MAX)
if rem != (row["remarks"] or None):
values["remarks"] = rem
w.log(f"ITP block {key} remarks changed", rem or "")
if body.cust_clear:
if row["cust_name"] or row["cust_at"]:
values.update({"cust_name": None, "cust_at": None, "cust_by": None, "cust_rec_at": None})
w.log(f"ITP block {key} customer complete cleared", row["cust_name"] or "")
elif "cust_name" in given:
name = clean_opt(body.cust_name, CUST_NAME_MAX)
if name is None:
raise ApiError(422, "invalid_field", "Enter the customer rep's name.")
at = body.cust_at or dom.today(w.tz)
if name != row["cust_name"] or at != row["cust_at"]:
values.update(
{
"cust_name": name,
"cust_at": at,
"cust_by": p.user_id,
"cust_rec_at": w.conn.execute("SELECT now()").fetchone()[0],
}
)
w.log(f"ITP block {key} customer complete recorded", name)
if not values:
return _answer(d, p, itp_id)
_update_block(w, row["id"], values)
return _answer(d, p, itp_id)
@router.post("/api/itps/{itp_id}/blocks/{key}/sign", dependencies=WRITE)
def sign_block(
itp_id: int,
key: str,
body: SignBodyOnly,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Sign a block of sections. Every line in it must be approved or N/A."""
conn = d.conn
row0 = dom.load_row(conn, itp_id)
if row0 is None:
raise ApiError(404, "not_found", dom.MSG_NO_ITP)
# Who first, then the ready and unsigned checks before the signature, so a refused step costs no PIN attempt.
# Both run again under the lock below.
dom.scoped_decision(p, _pp(d, p), A.ITP_BLOCK_SIGN, it_reason=body.it_reason)
_block_gate(conn, row0, key)
with dom.itp_write(
d,
p,
itp_id,
A.ITP_BLOCK_SIGN,
request=request,
it_reason=body.it_reason,
sign=body.sign,
sign_extra={"block_key": key},
) as w:
b, row = _block(w, key)
_block_gate(conn, w.itp, key)
_update_block(
w,
row["id"],
{
"signed_at": conn.execute("SELECT now()").fetchone()[0],
"signed_by": p.user_id,
"signature_id": w.signature.id if w.signature else None,
},
)
w.log(f"ITP block {key} signed", dom.block_scope(b))
return _answer(d, p, itp_id)
def _block_gate(conn: Any, itp_row: dict[str, Any], key: str) -> None:
if str(itp_row["status"]) == "complete":
raise ApiError(409, "itp_complete", dom.MSG_COMPLETE)
b = next((x for x in dom.blocks(itp_row["template"] or {}) if x["key"] == key), None)
if b is None:
raise ApiError(404, "not_found", MSG_NO_BLOCK)
brow = packets.one(
conn, "SELECT signed_at FROM itp_blocks WHERE itp_id = %s AND block_key = %s", (itp_row["id"], key)
)
if brow is not None and brow["signed_at"] is not None:
raise ApiError(409, "already_signed", "This block is already signed.")
if not dom.block_ready(b, dom.load_lines(conn, int(itp_row["id"]))):
raise ApiError(409, "block_not_ready", "Every line must be initialed or N/A first.")
@router.delete("/api/itps/{itp_id}/blocks/{key}/sign", dependencies=WRITE)
def unsign_block(
itp_id: int,
key: str,
request: Request,
body: It | None = None,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Remove a block signature: the signer or an admin, while the ITP is open. The block's fields stay."""
body = body or It()
with dom.itp_write(d, p, itp_id, A.ITP_BLOCK_UNSIGN, request=request, it_reason=body.it_reason) as w:
_b, row = _block(w, key)
if row["signed_at"] is None:
raise ApiError(409, "not_signed", "This block is not signed.")
if not (_same_user(row["signed_by"], p.user_id) or _is_admin(p)):
raise ApiError(403, "forbidden", "Only the inspector who signed this block, or an admin, can remove it.")
_update_block(w, row["id"], {"signed_at": None, "signed_by": None, "signature_id": None})
w.log(f"ITP block {key} signature removed")
return _answer(d, p, itp_id)
# ---------------------------------------------------------------- sign-off and reopen
@router.post("/api/itps/{itp_id}/signoff", dependencies=WRITE)
def sign_off(
itp_id: int,
body: SignBodyOnly,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Sign the ITP off: every block signed. The ITP locks; the PDF hook (phase 7) may file a copy."""
from ..pdf import itp as itp_pdf
conn = d.conn
row0 = dom.load_row(conn, itp_id)
if row0 is None:
raise ApiError(404, "not_found", dom.MSG_NO_ITP)
dom.scoped_decision(p, _pp(d, p), A.ITP_SIGN_OFF, it_reason=body.it_reason)
_signoff_gate(conn, row0)
with dom.itp_write(d, p, itp_id, A.ITP_SIGN_OFF, request=request, it_reason=body.it_reason, sign=body.sign) as w:
_signoff_gate(conn, w.itp)
conn.execute(
"UPDATE itps SET status = 'complete', signoff_at = now(), signoff_by = %s, signoff_signature_id = %s"
" WHERE id = %s",
(p.user_id, w.signature.id if w.signature else None, itp_id),
)
row = dom.load_row(conn, itp_id)
assert row is not None
asset_id = itp_pdf.file_signed_itp_pdf(conn, row, p.actor)
if asset_id is not None:
_file_auto_doc(w, row, asset_id)
w.log("ITP signed off", "PDF filed")
else:
w.log("ITP signed off", "PDF filing reserved for phase 7")
return _answer(d, p, itp_id)
def _signoff_gate(conn: Any, itp_row: dict[str, Any]) -> None:
if str(itp_row["status"]) == "complete":
raise ApiError(409, "itp_complete", dom.MSG_COMPLETE)
keys = [b["key"] for b in dom.blocks(itp_row["template"] or {})]
signed = {
r[0]
for r in conn.execute(
"SELECT block_key FROM itp_blocks WHERE itp_id = %s AND signed_at IS NOT NULL", (itp_row["id"],)
)
}
if not keys or any(k not in signed for k in keys):
raise ApiError(409, "blocks_unsigned", "Every section must be signed first.")
AUTO_NOTE = "Filed automatically when the digital ITP was signed off."
def _file_auto_doc(w: dom.ItpWrite, row: dict[str, Any], asset_id: Any) -> None:
"""Store the signed PDF on the ITP and file it in the unit's Q.C. folder, superseding the earlier automatic copy."""
conn = w.conn
conn.execute("UPDATE itps SET signoff_asset_id = %s WHERE id = %s", (asset_id, row["id"]))
new_id = conn.execute(
"INSERT INTO qc_docs (unit_id, type, title, rev, note, asset_id, auto, added_by)"
" VALUES (%s, 'ITP', %s, NULL, %s, %s, true, %s) RETURNING id",
(row["unit_id"], f"{row['no']} — signed off", AUTO_NOTE, asset_id, w.user_id),
).fetchone()[0]
conn.execute(
"UPDATE qc_docs SET superseded_by = %s, superseded_at = now(), superseded_by_user = %s"
" WHERE unit_id = %s AND type = 'ITP' AND auto AND superseded_by IS NULL AND id <> %s",
(new_id, w.user_id, row["unit_id"], new_id),
)
@router.post("/api/itps/{itp_id}/reopen", dependencies=WRITE)
def reopen(
itp_id: int,
body: ReopenBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Reopen a signed-off ITP: a Q.C. supervisor, or an admin (the reason serves as the IT reason when none is given).
The blocks and lines keep their signatures."""
reason = clean_opt(body.reason, LIMITS["reason"])
it_reason = body.it_reason or (body.reason if p.is_admin else None)
extra = {"reason": reason} if reason else {}
with dom.itp_write(
d, p, itp_id, A.ITP_REOPEN, request=request, extra=extra, it_reason=it_reason, allow_complete=True
) as w:
if str(w.itp["status"]) != "complete":
raise ApiError(409, "itp_open", "The ITP is open already.")
w.conn.execute(
"UPDATE itps SET status = 'open', reopen_at = now(), reopen_by = %s, reopen_reason = %s WHERE id = %s",
(p.user_id, reason, itp_id),
)
w.log("ITP reopened", reason)
return _answer(d, p, itp_id)
+195 -27
View File
@@ -21,6 +21,7 @@ from pydantic import BaseModel, Field
from .. import policy
from ..domain import checklists, packets
from ..domain import qc as qc_dom
from ..domain.children import NEVER_VERIFY, active_depts, co_touched, current_wh, update_row, wh_move
from ..domain.pipeline import (
SignBody,
@@ -260,16 +261,29 @@ class WeldExamIn(BaseModel):
class QcBody(It):
"""`{set, base}` for field-level compare-and-set of the scalar fields, or the phase 2 flat body with If-Match on
the inspection's row_version (checks and weld_exam merge per key)."""
checks: dict[str, bool] | None = Field(default=None, max_length=50)
weld_exam: dict[str, WeldExamIn] | None = Field(default=None, max_length=2000)
nde_method: str | None = Field(default=None, max_length=500)
nde_result: str | None = Field(default=None, max_length=500)
nde_report: str | None = Field(default=None, max_length=500)
hardness: str | None = Field(default=None, max_length=500)
nace: bool | None = None
inspector: str | None = Field(default=None, max_length=500)
stamp: str | None = Field(default=None, max_length=40)
notes: str | None = Field(default=None, max_length=5000)
set: dict[str, str | None] | None = Field(default=None, max_length=10)
base: dict[str, str | None] | None = Field(default=None, max_length=10)
class QcCheckBody(It):
done: bool
class QcWeldBody(It):
result: Literal["accept", "reject", ""] | None = None
note: str | None = Field(default=None, max_length=2000)
class CoItemIn(BaseModel):
@@ -1457,6 +1471,143 @@ def set_wh_stage(
# ---------------------------------------------------------------- Q.C.
QC_FIELDS = ("inspector", "stamp", "nde_method", "nde_result", "nde_report", "hardness", "notes")
QC_NOTE_NAMES = {"inspector": "inspector", "stamp": "stamp", "hardness": "hardness", "nde_report": "report"}
def _open_inspection(conn: Any, packet_id: int) -> dict[str, Any]:
insp = packets.one(conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (packet_id,))
if insp is None:
raise ApiError(409, "no_inspection", MSG_NO_INSPECTION)
return insp
def _qc_value(key: str, value: Any) -> Any:
"""One scalar Q.C. field, cleaned and checked."""
if key == "notes":
return clean_opt(value, LIMITS["notes"])
if key == "stamp":
return _stamp(value)
text = clean_opt(value, QC_FIELD_MAX)
if key == "nde_method" and text is not None and text not in qc_dom.NDE_METHODS:
raise ApiError(422, "invalid_field", "Pick an NDE method from the list.")
if key == "nde_result" and text is not None and text not in qc_dom.NDE_RESULTS:
raise ApiError(422, "invalid_field", "Pick an NDE result from the list.")
return text
def _qc_logs(w: Write, changes: dict[str, Any]) -> None:
"""The log lines of a Q.C. field change: "NDE method: RT", "NDE result: Acceptable", then "Q.C. record changed"
with the other fields. A method set to None required clears the result and report without a line of their own."""
none = changes.get("nde_method") == qc_dom.NDE_NONE
lines: list[tuple[str, str | None]] = []
if "nde_method" in changes:
lines.append((f"NDE method: {changes['nde_method'] or '-'}", None))
if "nde_result" in changes and not (none and not changes["nde_result"]):
lines.append((f"NDE result: {changes['nde_result'] or '-'}", None))
parts: list[str] = []
for k in ("inspector", "stamp", "hardness", "nde_report", "notes"):
if k not in changes or (k == "nde_report" and none and not changes[k]):
continue
v = changes[k]
if k == "notes":
parts.append("notes changed" if v else "notes cleared")
else:
parts.append(f"{QC_NOTE_NAMES[k]} {v}" if v else f"{QC_NOTE_NAMES[k]} cleared")
if parts:
lines.append(("Q.C. record changed", " · ".join(parts)))
if not lines:
lines.append(("Q.C. record changed", None))
w.log(*lines[0])
for more in lines[1:]:
w.log_also(*more)
@router.put("/{packet_id}/qc/checks/{key}", dependencies=WRITE)
def put_qc_check(
packet_id: int,
key: str,
body: QcCheckBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Tick or untick one Q.C. check. Per-key writes never conflict, so there is no If-Match."""
pp = _pp(d, p)
conn = d.conn
with packet_write(d, p, packet_id, A.EDIT_QC, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
items = w.children.qc_items if w.children is not None and w.children.qc_items else None
if key not in checklists.qc_keys(items):
raise ApiError(422, "invalid_key", "That is not a Q.C. check item.")
insp = _open_inspection(conn, packet_id)
if checklists.is_done((insp["checks"] or {}).get(key)) == body.done:
w.skip()
return _read(d, pp, packet_id)
if body.done:
conn.execute(
"UPDATE qc_inspections SET checks = jsonb_set(checks, ARRAY[%s]::text[],"
" jsonb_build_object('done', true, 'at', now(), 'by', %s::text, 'stamp', %s::text)),"
" row_version = row_version + 1 WHERE id = %s",
(key, str(w.user_id), insp["stamp"], insp["id"]),
)
else:
conn.execute(
"UPDATE qc_inspections SET checks = jsonb_set(checks, ARRAY[%s]::text[], %s),"
" row_version = row_version + 1 WHERE id = %s",
(key, Jsonb({"done": False}), insp["id"]),
)
pk = w.packet
texts = checklists.qc_items_for(pk["drawing"], pk["rev"], pk["wps"], pk["welder_stamp"], items)
text = next((i["text"] for i in texts if i["key"] == key), key)
w.changed(f"qc.checks.{key}")
w.log(("Q.C. checked: " if body.done else "Q.C. unchecked: ") + text)
return _read(d, pp, packet_id)
@router.put("/{packet_id}/qc/welds/{no}", dependencies=WRITE)
def put_qc_weld(
packet_id: int,
no: int,
body: QcWeldBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""The visual exam of one weld: accept, reject or cleared (empty), and a note."""
given = body.model_fields_set
pp = _pp(d, p)
conn = d.conn
with packet_write(d, p, packet_id, A.EDIT_QC, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
live = conn.execute(
"SELECT 1 FROM welds WHERE packet_id = %s AND weld_no = %s AND removed_at IS NULL", (packet_id, no)
).fetchone()
if live is None:
raise ApiError(422, "invalid_weld", "No such weld on this packet.")
insp = _open_inspection(conn, packet_id)
k = str(no)
cur = dict((insp["weld_exam"] or {}).get(k) or {})
result = (body.result or "") if "result" in given else str(cur.get("result") or "")
note = clean_text(body.note, LIMITS["note"]) if "note" in given else str(cur.get("note") or "")
result_changed = result != str(cur.get("result") or "")
note_changed = note != str(cur.get("note") or "")
if not result_changed and not note_changed:
w.skip()
return _read(d, pp, packet_id)
conn.execute(
"UPDATE qc_inspections SET weld_exam = jsonb_set(weld_exam, ARRAY[%s]::text[],"
" %s::jsonb || jsonb_build_object('at', now(), 'by', %s::text)), row_version = row_version + 1"
" WHERE id = %s",
(k, Jsonb({"result": result, "note": note}), str(w.user_id), insp["id"]),
)
label = qc_dom.weld_label(no)
w.changed(f"qc.weld_exam.{k}")
if result_changed:
w.log(f"VT {result} · {label}" if result else f"VT cleared · {label}", note if result and note else None)
else:
w.log("Q.C. record changed", f"{label} note")
return _read(d, pp, packet_id)
@router.patch("/{packet_id}/qc", dependencies=WRITE)
def patch_qc(
packet_id: int,
@@ -1465,36 +1616,50 @@ def patch_qc(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""The open inspection. checks and weld_exam merge per key; If-Match is the inspection's row_version."""
if_match = parse_if_match(request)
given = body.model_fields_set
if body.checks:
bad = sorted(set(body.checks) - set(checklists.qc_keys()))
if bad:
raise ApiError(422, "invalid_key", f"Not a Q.C. item: {', '.join(bad)}.")
"""The open inspection's fields.
`{set, base}`: inspector, stamp, nde_method, nde_result, nde_report, hardness and notes with field-level
compare-and-set (409 field_conflict with `current`). The phase 2 flat body with If-Match (the inspection's
row_version) still works and merges checks and weld_exam per key. Setting the NDE method to None required clears
the result and the report. The NACE flag is not written: the read model computes it from the WPS.
"""
cas = body.set is not None
values: dict[str, Any] = {}
for k in ("nde_method", "nde_result", "nde_report", "hardness", "inspector"):
if k in given:
values[k] = clean_opt(getattr(body, k), QC_FIELD_MAX)
if "notes" in given:
values["notes"] = clean_opt(body.notes, LIMITS["notes"])
if "stamp" in given:
values["stamp"] = _stamp(body.stamp)
if "nace" in given:
values["nace"] = body.nace
base: dict[str, Any] = {}
if_match: int | None = None
if cas:
assert body.set is not None
bad = sorted(k for k in body.set if k not in QC_FIELDS)
if bad:
raise ApiError(422, "invalid_field", f"Not a Q.C. field: {', '.join(bad)}.")
values = {k: _qc_value(k, v) for k, v in body.set.items()}
for k, v in (body.base or {}).items():
if k in QC_FIELDS:
base[k] = clean_opt(v, LIMITS["notes"] if k == "notes" else QC_FIELD_MAX)
else:
given = body.model_fields_set
if body.checks:
bad = sorted(set(body.checks) - set(checklists.qc_keys(checklists.load_qc_items(d.conn))))
if bad:
raise ApiError(422, "invalid_key", f"Not a Q.C. item: {', '.join(bad)}.")
values = {k: _qc_value(k, getattr(body, k)) for k in QC_FIELDS if k in given}
if_match = parse_if_match(request)
if values.get("nde_method") == qc_dom.NDE_NONE:
values["nde_result"] = None
values["nde_report"] = None
pp = _pp(d, p)
conn = d.conn
with packet_write(d, p, packet_id, A.EDIT_QC, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
insp = packets.one(
conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (packet_id,)
)
if insp is None:
raise ApiError(409, "no_inspection", MSG_NO_INSPECTION)
check_row_version(insp, if_match)
changes = _diff(insp, values)
insp = _open_inspection(conn, packet_id)
if cas:
changes = apply_field_cas({k: insp[k] for k in QC_FIELDS}, values, base)
else:
check_row_version(insp, if_match)
changes = _diff(insp, values)
scalar = dict(changes)
at = conn.execute("SELECT now()").fetchone()[0].isoformat()
by = str(w.user_id)
if body.checks:
if not cas and body.checks:
stamp = values.get("stamp", insp["stamp"])
checks = dict(insp["checks"] or {})
for k, done in body.checks.items():
@@ -1503,7 +1668,7 @@ def patch_qc(
checks[k] = {"done": True, "at": at, "by": by, "stamp": stamp} if done else {"done": False}
if checks != (insp["checks"] or {}):
changes["checks"] = Jsonb(checks)
if body.weld_exam:
if not cas and body.weld_exam:
live = {
str(r[0])
for r in conn.execute(
@@ -1531,7 +1696,10 @@ def patch_qc(
return _read(d, pp, packet_id)
update_row(conn, "qc_inspections", insp["id"], changes)
w.changed(*[f"qc.{k}" for k in changes])
w.log("Q.C. record changed")
if scalar:
_qc_logs(w, scalar)
else:
w.log("Q.C. record changed")
return _read(d, pp, packet_id)
+50 -15
View File
@@ -17,7 +17,9 @@ from pydantic import BaseModel, Field
from .. import audit, events, policy
from ..domain import assets as asset_store
from ..domain import ncr as ncrs
from ..domain import packets, shop
from ..domain import qc as qc_dom
from ..domain.pipeline import (
Expect,
SignBody,
@@ -110,6 +112,7 @@ class TransitionBody(BaseModel):
spool: bool | None = None
location: str | None = Field(default=None, max_length=500)
for_sup_id: uuid.UUID | None = None
ncr: str | None = Field(default=None, max_length=100)
sign: SignBody | None = None
it_reason: str | None = Field(default=None, max_length=1000)
@@ -137,6 +140,7 @@ class DocBody(BaseModel):
asset_id: uuid.UUID
kind: str = Field(min_length=1, max_length=100)
title: str | None = Field(default=None, max_length=500)
note: str | None = Field(default=None, max_length=2000)
it_reason: str | None = Field(default=None, max_length=1000)
@@ -494,7 +498,7 @@ TRANSITION_FIELDS: dict[policy.Action, tuple[str, ...]] = {
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_REJECT: ("reason", "ncr"),
A.QC_ACCEPT: ("routed_to", "note"),
A.QC_ACCEPT_AND_ROUTE: ("dept", "note"),
A.FILE: ("note",),
@@ -534,6 +538,8 @@ def transition(
extra["dept"] = shop_dept(conn, extra["dept"])
if "for_sup_id" in extra:
extra["for_sup_id"] = active_user(conn, extra["for_sup_id"], "supervisor")
if action is A.QC_REJECT:
_reject_reason(extra)
if "stamp" in extra:
# The assign sheet's stamp overrides the welder's own for this assignment; empty means use theirs.
stamp = clean_text(extra["stamp"], 40)
@@ -570,6 +576,21 @@ def transition(
return _read(d, pp, packet_id)
def _reject_reason(extra: dict[str, Any]) -> None:
"""A Q.C. reject takes a reason, an NCR number, or both. The NCR stands in for the reason text: the reason the
policy checks is "NCR-2026-0021" alone, or "NCR-2026-0021 · <reason>". Without an NCR the reason needs its usual
8 characters."""
text = clean_text(extra.pop("reason", None), LIMITS["reason"])
raw = clean_text(extra.pop("ncr", None), 100)
ncr = ncrs.validate_ncr(raw) if raw else None
if ncr:
extra["ncr"] = ncr
extra["reason"] = f"{ncr} · {text}" if text else ncr
elif text:
extra["reason"] = text
extra["reason_text"] = text
@router.post("/{packet_id}/reopen", dependencies=WRITE)
def reopen(
packet_id: int,
@@ -750,27 +771,35 @@ def add_doc(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""A report for the data book (NDE, hydrotest, certificates, NCRs). Never a drawing: drawings come from M-Files."""
conn = d.conn
kind = clean_text(body.kind, 40)
if kind not in qc_dom.DOC_KINDS:
raise ApiError(422, "invalid_kind", "Pick a document type from the list.")
asset = asset_store.get(conn, body.asset_id)
if asset is None:
raise ApiError(404, "not_found", "No such file.")
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"])
if asset["mime"] not in qc_dom.DOC_MIMES:
raise ApiError(422, "invalid_asset", "The file must be a PDF, PNG or JPEG.")
label = qc_dom.DOC_KINDS[kind]
title = clean_opt(body.title, LIMITS["doc_title"]) or label
note = clean_opt(body.note, qc_dom.DOC_NOTE_MAX)
pp = _pp(d, p)
with packet_write(d, p, packet_id, A.DOC_ADD, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
doc_id = conn.execute(
"INSERT INTO packet_docs (packet_id, asset_id, kind, title, added_by) VALUES (%s, %s, %s, %s, %s)"
" RETURNING id",
(packet_id, asset["id"], kind, title, p.user_id),
"INSERT INTO packet_docs (packet_id, asset_id, kind, title, note, added_by)"
" VALUES (%s, %s, %s, %s, %s, %s) RETURNING id",
(packet_id, asset["id"], kind, title, note, p.user_id),
).fetchone()[0]
w.changed("docs")
w.note = title or kind
w.log("Document added", " · ".join(x for x in (label, title, asset.get("original_name")) if x))
w.detail.update({"doc_id": int(doc_id), "asset_id": str(asset["id"]), "kind": kind})
return _read(d, pp, packet_id)
DOC_REASON_MIN = 3
@router.delete("/{packet_id}/docs/{doc_id}", dependencies=WRITE)
def remove_doc(
packet_id: int,
@@ -780,23 +809,29 @@ def remove_doc(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Take a document out of the data book. The row and the file stay, with who, when and why."""
body = body or ReasonBody()
conn = d.conn
pp = _pp(d, p)
with packet_write(
d, p, packet_id, A.DOC_REMOVE, request=request, it_reason=body.it_reason, policy_principal=pp
) as w:
reason = clean_opt(body.reason, LIMITS["reason"])
reason = clean_text(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),
conn, "SELECT * FROM packet_docs WHERE id = %s AND packet_id = %s FOR UPDATE", (doc_id, packet_id)
)
if doc is None:
raise ApiError(404, "not_found", "No such document.")
if doc["removed_at"] is not None:
raise ApiError(409, "already_removed", "This document is already out of the data book.")
if len(reason) < DOC_REASON_MIN:
raise ApiError(400, "reason_required", "Say why it comes out of the data book.")
conn.execute(
"UPDATE packet_docs SET removed_at = now(), removed_by = %s, removed_reason = %s WHERE id = %s",
(p.user_id, reason, doc_id),
)
w.changed("docs")
w.note = ", ".join(x for x in (doc["title"] or doc["kind"], reason) if x)
w.log("Document taken out of the data book", " · ".join(x for x in (doc["title"] or doc["kind"], reason) if x))
w.detail["doc_id"] = doc_id
return _read(d, pp, packet_id)
+183
View File
@@ -0,0 +1,183 @@
"""The Q.C. screen: the home board and the project and unit folders with their documents.
Any signed-in session reads. Filing a folder document is QC_DOC_ADD (Clerical or Q.C.; admin with an IT reason),
decided by policy.engine.check_scoped because a folder belongs to a project or a unit, not a packet. Screens read
PostgreSQL only.
"""
from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from .. import audit, events, policy
from ..domain import assets as asset_store
from ..domain import itp as itp_dom
from ..domain import packets, qcdocs
from ..domain import qc as dom
from ..text import clean_opt, clean_text
from .deps import Db, Principal, current_principal, get_db
from .errors import ApiError
from .packets import WRITE
router = APIRouter(prefix="/api/qc", tags=["qc"])
A = policy.Action
class QcDocBody(BaseModel):
project_id: int | None = None
unit_id: int | None = None
type: str = Field(min_length=1, max_length=100)
title: str | None = Field(default=None, max_length=1000)
rev: str | None = Field(default=None, max_length=100)
note: str | None = Field(default=None, max_length=5000)
asset_id: uuid.UUID
replaces: int | None = None
it_reason: str | None = Field(default=None, max_length=1000)
@router.get("/home")
def home(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
project_id: int | None = Query(None),
unit_id: int | None = Query(None),
) -> dict[str, Any]:
conn = d.conn
return dom.home(conn, packets.policy_principal_for(conn, p), project_id=project_id, unit_id=unit_id)
@router.get("/folders")
def folders(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
show: str = Query("active", pattern="^(active|all)$"),
) -> list[dict[str, Any]]:
return qcdocs.folders(d.conn, show)
@router.get("/folders/projects/{project_id}")
def project_folder(
project_id: int,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
out = qcdocs.project_folder(d.conn, project_id)
if out is None:
raise ApiError(404, "not_found", "No such project.")
return out
@router.get("/folders/units/{unit_id}")
def unit_folder(
unit_id: int,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
out = qcdocs.unit_folder(d.conn, unit_id)
if out is None:
raise ApiError(404, "not_found", "No such unit.")
return out
@router.post("/docs", dependencies=WRITE)
def add_doc(
body: QcDocBody,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""File a document in a project or unit folder, or a new revision of one (`replaces`). Nothing is deleted: the
old revision points at the new one. The answer is the folder read."""
conn = d.conn
if (body.project_id is None) == (body.unit_id is None):
raise ApiError(422, "invalid_request", "File the document in one project or one unit.")
doc_type = clean_text(body.type, 100)
if doc_type not in qcdocs.QC_TYPES:
raise ApiError(422, "invalid_type", "Pick a document type from the list.")
asset = asset_store.get(conn, body.asset_id)
if asset is None:
raise ApiError(404, "not_found", "No such file.")
if asset["mime"] not in qcdocs.DOC_MIMES:
raise ApiError(422, "invalid_asset", "The file must be a PDF, PNG or JPEG.")
title = clean_opt(body.title, qcdocs.TITLE_MAX) or doc_type
rev = clean_opt(body.rev, qcdocs.REV_MAX)
note = clean_opt(body.note, qcdocs.NOTE_MAX)
pp = packets.policy_principal_for(conn, p)
decision, it_kept = itp_dom.scoped_decision(p, pp, A.QC_DOC_ADD, it_reason=body.it_reason)
with conn.transaction():
if body.unit_id is not None:
folder = packets.one(
conn,
"SELECT u.id, pr.number || u.lot AS number FROM units u JOIN projects pr ON pr.id = u.project_id"
" WHERE u.id = %s AND u.removed_at IS NULL FOR UPDATE OF u",
(body.unit_id,),
)
scope_col = "unit_id"
else:
folder = packets.one(
conn,
"SELECT id, number FROM projects WHERE id = %s AND removed_at IS NULL FOR UPDATE",
(body.project_id,),
)
scope_col = "project_id"
if folder is None:
raise ApiError(404, "not_found", "No such unit." if body.unit_id is not None else "No such project.")
old = None
if body.replaces is not None:
old = packets.one(conn, "SELECT * FROM qc_docs WHERE id = %s FOR UPDATE", (body.replaces,))
if old is None or old[scope_col] != folder["id"]:
raise ApiError(422, "invalid_replace", "That document is not in this folder.")
if old["superseded_by"] is not None:
raise ApiError(409, "already_superseded", "That document already has a newer revision.")
new_id = int(
conn.execute(
"INSERT INTO qc_docs (project_id, unit_id, type, title, rev, note, asset_id, added_by)"
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING id",
(body.project_id, body.unit_id, doc_type, title, rev, note, asset["id"], p.user_id),
).fetchone()[0]
)
if old is not None:
conn.execute(
"UPDATE qc_docs SET superseded_by = %s, superseded_at = now(), superseded_by_user = %s WHERE id = %s",
(new_id, p.user_id, old["id"]),
)
title_rev = f"{title} Rev {rev}" if rev else title
text_note = " · ".join(x for x in (folder["number"], doc_type, title_rev, asset.get("original_name")) if x)
revise = old is not None
detail: dict[str, Any] = {
"text": "Q.C. document revised" if revise else "Q.C. document added",
"note": text_note,
"doc_id": new_id,
"project_id": body.project_id,
"unit_id": body.unit_id,
}
if revise:
detail["replaces"] = old["id"] if old else None
audit.append(
conn,
action="qc_doc.revise" if revise else "qc_doc.add",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="qc_doc",
subject_id=new_id,
detail=detail,
it_reason=it_kept,
ip=p.ip,
)
events.publish(
conn,
{"event": "changed", "kind": "qc_doc", "project_id": body.project_id, "unit_id": body.unit_id},
)
del decision
if body.unit_id is not None:
out = qcdocs.unit_folder(conn, body.unit_id)
else:
assert body.project_id is not None
out = qcdocs.project_folder(conn, body.project_id)
assert out is not None
return out