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]>
184 lines
7.0 KiB
Python
184 lines
7.0 KiB
Python
"""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
|