"""Q.C. folders: the documents Q.C. files for a project or a unit (ITP, WPS / PQR, procedures, certificates). The folders are the projects and units Clerical sets up; nothing is created on the Q.C. side. A project document covers every unit of the project (a unit folder lists it as inherited). Nothing is ever deleted: a new revision is a new row, and the old row points at it (superseded_by, superseded_at, superseded_by_user) and stays readable under "Superseded". The old build's qcfolders.js. """ from __future__ import annotations from collections.abc import Iterable, Mapping from typing import Any from ..text import LIMITS from . import itp, packets # The document types (the old QC_TYPES), stored as the label itself. QC_TYPES: tuple[str, ...] = ( "ITP", "WPS / PQR", "NDE procedure", "Hydrotest procedure", "PMI / material", "Calibration certificate", "Customer spec", "Other", ) DOC_MIMES = frozenset({"application/pdf", "image/png", "image/jpeg"}) TITLE_MAX = LIMITS["qc_doc_title"] REV_MAX = LIMITS["qc_doc_rev"] NOTE_MAX = LIMITS["qc_doc_note"] DOC_SQL = """ SELECT d.*, a.original_name AS file_name, a.size AS file_size FROM qc_docs d JOIN assets a ON a.id = d.asset_id """ PROJECT_SQL = ( "SELECT id, number, name, customer, po, status::text AS status FROM projects WHERE id = %s AND removed_at IS NULL" ) def doc_rows(conn: Any, where: str, params: Any = ()) -> list[dict[str, Any]]: return packets.rows(conn, DOC_SQL + " WHERE " + where + " ORDER BY d.added_at, d.id", params) def shape(rows: Iterable[Mapping[str, Any]], conn: Any) -> list[dict[str, Any]]: rows = list(rows) people = packets.People(conn, [r["added_by"] for r in rows] + [r["superseded_by_user"] for r in rows]) return [ { "id": r["id"], "project_id": r["project_id"], "unit_id": r["unit_id"], "type": r["type"], "title": r["title"], "rev": r["rev"], "note": r["note"], "asset_id": str(r["asset_id"]), "url": f"/_blob/{r['asset_id']}", "file_name": r["file_name"], "size": r["file_size"], "auto": r["auto"], "added_at": r["added_at"], "added_by": people.ref(r["added_by"]), "superseded_by": r["superseded_by"], "superseded_at": r["superseded_at"], "superseded_by_user": people.ref(r["superseded_by_user"]), } for r in rows ] def _split(rows: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: current = [r for r in rows if r["superseded_by"] is None] old = sorted((r for r in rows if r["superseded_by"] is not None), key=lambda r: r["superseded_at"], reverse=True) return current, old def project_ref(conn: Any, project_id: int) -> dict[str, Any] | None: return packets.one(conn, PROJECT_SQL, (project_id,)) def _has_itp_doc(rows: Iterable[Mapping[str, Any]]) -> bool: return any(r["type"] == "ITP" and r["superseded_by"] is None for r in rows) def project_folder(conn: Any, project_id: int) -> dict[str, Any] | None: """GET /api/qc/folders/projects/{id}, or None for an unknown project.""" project = project_ref(conn, project_id) if project is None: return None current, old = _split(doc_rows(conn, "d.project_id = %s", (project_id,))) units = packets.rows( conn, "SELECT id, number, lot, name, status::text AS status FROM v_units" " WHERE project_id = %s AND removed_at IS NULL ORDER BY lot", (project_id,), ) unit_docs: dict[int, list[dict[str, Any]]] = {} if units: for r in doc_rows(conn, "d.unit_id = ANY(%s) AND d.superseded_by IS NULL", ([u["id"] for u in units],)): unit_docs.setdefault(r["unit_id"], []).append(r) sums = itp.summaries(conn, [u["id"] for u in units]) return { "project": project, "docs": shape(current, conn), "superseded": shape(old, conn), "units": [ { "id": u["id"], "number": u["number"], "lot": u["lot"], "name": u["name"], "status": u["status"], "docs": len(unit_docs.get(u["id"], [])), "itp": sums.get(u["id"]), "has_itp_doc": _has_itp_doc(unit_docs.get(u["id"], [])), } for u in units ], } def unit_folder(conn: Any, unit_id: int) -> dict[str, Any] | None: """GET /api/qc/folders/units/{id}, or None for an unknown unit.""" unit = packets.one( conn, "SELECT id, number, lot, name, status::text AS status, po, project_id FROM v_units" " WHERE id = %s AND removed_at IS NULL", (unit_id,), ) if unit is None: return None project = project_ref(conn, unit["project_id"]) if project is None: return None current, old = _split(doc_rows(conn, "d.unit_id = %s", (unit_id,))) inherited = doc_rows(conn, "d.project_id = %s AND d.superseded_by IS NULL", (project["id"],)) summary = itp.summaries(conn, [unit_id]).get(unit_id) open_unit = unit["status"] != "closed" and project["status"] != "closed" no_itp = open_unit and summary is None and not _has_itp_doc(current) and not _has_itp_doc(inherited) return { "unit": {k: unit[k] for k in ("id", "number", "lot", "name", "status", "po")}, "project": project, "docs": shape(current, conn), "inherited": shape(inherited, conn), "superseded": shape(old, conn), "itp": summary, "no_itp": no_itp, } FOLDERS_SQL = """ SELECT p.id, p.number, p.name, p.customer, p.po, p.status::text AS status, (SELECT count(*) FROM units u WHERE u.project_id = p.id AND u.removed_at IS NULL) AS units, (SELECT count(*) FROM qc_docs d WHERE d.superseded_by IS NULL AND (d.project_id = p.id OR d.unit_id IN (SELECT u.id FROM units u WHERE u.project_id = p.id AND u.removed_at IS NULL))) AS docs, CASE WHEN p.status = 'closed' OR EXISTS (SELECT 1 FROM qc_docs d WHERE d.project_id = p.id AND d.type = 'ITP' AND d.superseded_by IS NULL) THEN 0 ELSE (SELECT count(*) FROM units u WHERE u.project_id = p.id AND u.removed_at IS NULL AND u.status <> 'closed' AND NOT EXISTS (SELECT 1 FROM itps i WHERE i.unit_id = u.id) AND NOT EXISTS (SELECT 1 FROM qc_docs d WHERE d.unit_id = u.id AND d.type = 'ITP' AND d.superseded_by IS NULL)) END AS units_without_itp FROM projects p WHERE p.removed_at IS NULL """ def folders(conn: Any, show: str = "active") -> list[dict[str, Any]]: """GET /api/qc/folders: one row per live project, by number. `active` leaves out closed projects.""" sql = FOLDERS_SQL + (" AND p.status <> 'closed'" if show != "all" else "") + " ORDER BY p.number" return [ {**r, "units": int(r["units"]), "docs": int(r["docs"]), "units_without_itp": int(r["units_without_itp"])} for r in packets.rows(conn, sql) ] def packet_folder_docs(conn: Any, unit_id: int, project_id: int | None) -> list[dict[str, Any]]: """The current Q.C. folder documents of a packet's unit and of its project, unit first.""" out: list[dict[str, Any]] = [] for scope, where, ident in (("unit", "d.unit_id = %s", unit_id), ("project", "d.project_id = %s", project_id)): if ident is None: continue for r in doc_rows(conn, where + " AND d.superseded_by IS NULL", (ident,)): out.append( { "id": r["id"], "type": r["type"], "title": r["title"], "rev": r["rev"], "url": f"/_blob/{r['asset_id']}", "scope": scope, "added_at": r["added_at"], } ) return out