2026-10-17 20:08:53 -06:00
|
|
|
|
"""The digital ITP (Inspection and Test Plan), one per unit.
|
|
|
|
|
|
|
|
|
|
|
|
Starting an ITP freezes a copy of the Q.C. template (domain/itp_template.py) into itps.template, so a later template
|
|
|
|
|
|
change never alters an ITP in use. Q.C. initials each line (approve), marks lines N/A, rejects a line with an NCR
|
|
|
|
|
|
number (now or later), records the customer's code and witness, signs each block of sections with a signature, and
|
|
|
|
|
|
signs the ITP off with a signature. Open hold (H) and witness (W) points, and rejected lines, show as a warning on the
|
|
|
|
|
|
unit's packets for the department's pages. Nothing is blocked by them.
|
|
|
|
|
|
|
|
|
|
|
|
The pure functions here (items, is_done, is_hold_code, open_holds, blocks, block_ready, progress, dept_pages,
|
|
|
|
|
|
holds_for_packet) port the old itp.js helpers. They take the frozen template and the lines as a mapping of line key to
|
|
|
|
|
|
a row with `result` ("approve", "reject", "na" or None), `cust_code` and `ncr`.
|
|
|
|
|
|
|
|
|
|
|
|
Writes go through `itp_write`: check_scoped with the action, then the ITP row locked FOR UPDATE, the complete lock,
|
|
|
|
|
|
the signature when the rule needs one, then the version bump, the itp_log rows, the audit rows and the NOTIFY. ITP
|
|
|
|
|
|
writes never look at a packet's status: a filed packet in the unit does not lock the unit's ITP.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
|
from collections.abc import Iterable, Iterator, Mapping, Sequence
|
|
|
|
|
|
from contextlib import contextmanager
|
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
from datetime import date, datetime
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
|
|
|
|
|
|
|
|
from .. import audit, events, policy
|
|
|
|
|
|
from ..api import deps
|
|
|
|
|
|
from ..api.errors import ApiError
|
|
|
|
|
|
from ..auth import signatures
|
|
|
|
|
|
from ..auth.signatures import Signature
|
2026-10-21 18:27:09 -06:00
|
|
|
|
from ..notify import raise_event
|
2026-10-17 20:08:53 -06:00
|
|
|
|
from ..policy.actions import SCOPED_LABELS
|
|
|
|
|
|
from ..policy.engine import check_scoped
|
|
|
|
|
|
from ..text import LIMITS, clean_opt, reason_ok
|
|
|
|
|
|
from . import packets
|
|
|
|
|
|
|
|
|
|
|
|
A = policy.Action
|
|
|
|
|
|
|
|
|
|
|
|
PAGE_NAMES: dict[str, str] = {"WP": "Weld Parts", "C": "Code", "FA": "Final Assembly"}
|
|
|
|
|
|
PAGE_ORDER: tuple[str, ...] = ("WP", "C", "FA")
|
|
|
|
|
|
# The pages each shop department works to (the old ITP_DEPT_PAGES). A support department or an unknown code sees all.
|
|
|
|
|
|
DEPT_PAGES: dict[str, tuple[str, ...]] = {
|
|
|
|
|
|
"01": ("WP",),
|
|
|
|
|
|
"02": ("WP",),
|
|
|
|
|
|
"03": ("WP",),
|
|
|
|
|
|
"04": ("WP",),
|
|
|
|
|
|
"05": ("WP",),
|
|
|
|
|
|
"10": ("WP",),
|
|
|
|
|
|
"06": ("C",),
|
|
|
|
|
|
"07": ("C",),
|
|
|
|
|
|
"13": ("C",),
|
|
|
|
|
|
"09": ("WP", "C"),
|
|
|
|
|
|
"08": ("FA",),
|
|
|
|
|
|
"11": ("FA",),
|
|
|
|
|
|
"12": ("FA",),
|
|
|
|
|
|
"14": ("FA",),
|
|
|
|
|
|
}
|
|
|
|
|
|
CUST_CODES: tuple[str, ...] = ("R", "R/AU", "AU", "W", "H", "NA")
|
|
|
|
|
|
RX_HOLD = re.compile(r"(^|/)(H|W)$")
|
|
|
|
|
|
LOG_MAX = 600
|
|
|
|
|
|
|
|
|
|
|
|
MSG_COMPLETE = "The ITP is signed off and locked. An admin can reopen it."
|
|
|
|
|
|
MSG_NO_ITP = "No such ITP."
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- pure functions over the template and lines
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def items(template: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
|
|
|
|
"""Every line of the template in order, with its section number (`sec`) and page."""
|
|
|
|
|
|
out: list[dict[str, Any]] = []
|
|
|
|
|
|
for s in template.get("sections") or []:
|
|
|
|
|
|
for i in s.get("items") or []:
|
|
|
|
|
|
out.append({**i, "sec": s.get("n"), "page": s.get("page")})
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _line(lines: Mapping[str, Any], key: str) -> Mapping[str, Any]:
|
|
|
|
|
|
v = lines.get(key)
|
|
|
|
|
|
return v if isinstance(v, Mapping) else {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_done(line: Mapping[str, Any] | None) -> bool:
|
|
|
|
|
|
"""A line is done when it is N/A or approved."""
|
|
|
|
|
|
return bool(line) and (line or {}).get("result") in ("approve", "na")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_rejected(line: Mapping[str, Any] | None) -> bool:
|
|
|
|
|
|
"""An open reject: the line is rejected and not approved since."""
|
|
|
|
|
|
return bool(line) and (line or {}).get("result") == "reject"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_hold_code(code: Any) -> bool:
|
|
|
|
|
|
"""H or W, alone or after a slash (R/H). The old isHoldCode."""
|
|
|
|
|
|
return bool(RX_HOLD.search(str(code or "")))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cust_code(item: Mapping[str, Any], line: Mapping[str, Any] | None) -> str:
|
|
|
|
|
|
"""The customer code of a line: its own when set, else the template's."""
|
|
|
|
|
|
own = (line or {}).get("cust_code")
|
|
|
|
|
|
return str(own) if own else str(item.get("cust") or "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def hold_codes(item: Mapping[str, Any], line: Mapping[str, Any] | None) -> list[str]:
|
|
|
|
|
|
return [c for c in (str(item.get("code") or ""), cust_code(item, line)) if is_hold_code(c)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def open_holds(
|
|
|
|
|
|
template: Mapping[str, Any], lines: Mapping[str, Any], pages: Sequence[str] | None = None
|
|
|
|
|
|
) -> list[dict[str, Any]]:
|
|
|
|
|
|
"""The hold, witness or rejected points not done yet, on the given pages (None: every page)."""
|
|
|
|
|
|
out: list[dict[str, Any]] = []
|
|
|
|
|
|
for i in items(template):
|
|
|
|
|
|
if pages is not None and i["page"] not in pages:
|
|
|
|
|
|
continue
|
|
|
|
|
|
line = _line(lines, str(i["n"]))
|
|
|
|
|
|
codes = hold_codes(i, line)
|
|
|
|
|
|
rej = is_rejected(line)
|
|
|
|
|
|
if (codes or rej) and not is_done(line):
|
|
|
|
|
|
out.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"n": i["n"],
|
|
|
|
|
|
"t": i["t"],
|
|
|
|
|
|
"page": i["page"],
|
|
|
|
|
|
"codes": codes,
|
|
|
|
|
|
"rejected": rej,
|
|
|
|
|
|
"ncr": line.get("ncr") if rej else None,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def blocks(template: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
|
|
|
|
"""The signature blocks: one closes at every section with `sig` and covers the sections since the last block."""
|
|
|
|
|
|
out: list[dict[str, Any]] = []
|
|
|
|
|
|
acc: list[Mapping[str, Any]] = []
|
|
|
|
|
|
for s in template.get("sections") or []:
|
|
|
|
|
|
acc.append(s)
|
|
|
|
|
|
if s.get("sig"):
|
|
|
|
|
|
out.append({"key": str(s["n"]), "sections": acc, "final": bool(s.get("final"))})
|
|
|
|
|
|
acc = []
|
|
|
|
|
|
if acc:
|
|
|
|
|
|
out.append({"key": str(acc[-1]["n"]), "sections": acc, "final": True})
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def block_scope(block: Mapping[str, Any]) -> str:
|
|
|
|
|
|
secs = block["sections"]
|
|
|
|
|
|
if len(secs) > 1:
|
|
|
|
|
|
return f"Sections {secs[0]['n']}–{secs[-1]['n']}"
|
|
|
|
|
|
return f"Section {block['key']}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def block_of_line(template: Mapping[str, Any], key: str) -> dict[str, Any] | None:
|
|
|
|
|
|
for b in blocks(template):
|
|
|
|
|
|
if any(str(i.get("n")) == key for s in b["sections"] for i in s.get("items") or []):
|
|
|
|
|
|
return b
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _block_lines(block: Mapping[str, Any]) -> list[str]:
|
|
|
|
|
|
return [str(i.get("n")) for s in block["sections"] for i in s.get("items") or []]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def block_ready(block: Mapping[str, Any], lines: Mapping[str, Any]) -> bool:
|
|
|
|
|
|
"""Every line of the block is done (approved or N/A)."""
|
|
|
|
|
|
return all(is_done(_line(lines, k)) for k in _block_lines(block))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def block_all_na(block: Mapping[str, Any], lines: Mapping[str, Any]) -> bool:
|
|
|
|
|
|
return all(_line(lines, k).get("result") == "na" for k in _block_lines(block))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def progress(template: Mapping[str, Any], lines: Mapping[str, Any]) -> dict[str, int]:
|
|
|
|
|
|
it = items(template)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"done": sum(1 for i in it if is_done(_line(lines, str(i["n"])))),
|
|
|
|
|
|
"total": len(it),
|
|
|
|
|
|
"holds": len(open_holds(template, lines)),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def dept_pages(code: Any) -> list[str] | None:
|
|
|
|
|
|
"""The pages a department works to, or None for a support department or an unknown code (every page)."""
|
|
|
|
|
|
pages = DEPT_PAGES.get(str(code or ""))
|
|
|
|
|
|
return list(pages) if pages is not None else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def holds_for_packet(
|
|
|
|
|
|
itp_row: Mapping[str, Any] | None, lines: Mapping[str, Any], dept_code: Any
|
|
|
|
|
|
) -> dict[str, Any] | None:
|
|
|
|
|
|
"""The packet's ITP warning: open hold, witness or rejected points on its department's pages, while the unit's
|
|
|
|
|
|
ITP is open. None when there is none."""
|
|
|
|
|
|
if itp_row is None or str(itp_row.get("status")) == "complete":
|
|
|
|
|
|
return None
|
|
|
|
|
|
pages = dept_pages(dept_code)
|
|
|
|
|
|
hs = open_holds(itp_row["template"] or {}, lines, pages)
|
|
|
|
|
|
if not hs:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return {
|
|
|
|
|
|
"no": itp_row.get("no"),
|
|
|
|
|
|
"count": len(hs),
|
|
|
|
|
|
"rejected": any(h["rejected"] for h in hs),
|
|
|
|
|
|
"pages": pages,
|
|
|
|
|
|
"items": [{k: h[k] for k in ("n", "t", "codes", "rejected", "ncr")} for h in hs],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def initials(name: Any) -> str:
|
|
|
|
|
|
"""The first letter of each word of a name, upper case, at most three. The old initials()."""
|
|
|
|
|
|
return "".join(w[0].upper() for w in str(name or "").split() if w)[:3]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def site_zone(tz: str | None) -> ZoneInfo:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return ZoneInfo(tz or "America/Denver")
|
|
|
|
|
|
except (ZoneInfoNotFoundError, ValueError):
|
|
|
|
|
|
return ZoneInfo("America/Denver")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def date_text(value: Any, tz: str | None = None) -> str:
|
|
|
|
|
|
"""M/D/YYYY in the site time zone (10/17/2026)."""
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
if isinstance(value, datetime):
|
|
|
|
|
|
d = value.astimezone(site_zone(tz)).date() if value.tzinfo else value.date()
|
|
|
|
|
|
elif isinstance(value, date):
|
|
|
|
|
|
d = value
|
|
|
|
|
|
else:
|
|
|
|
|
|
return str(value)
|
|
|
|
|
|
return f"{d.month}/{d.day}/{d.year}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def today(tz: str | None = None) -> date:
|
|
|
|
|
|
return datetime.now(site_zone(tz)).date()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- loading
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_row(conn: Any, itp_id: int, *, lock: bool = False) -> dict[str, Any] | None:
|
|
|
|
|
|
sql = "SELECT *, status::text AS status FROM itps WHERE id = %s"
|
|
|
|
|
|
if lock:
|
|
|
|
|
|
sql += " FOR UPDATE"
|
|
|
|
|
|
return packets.one(conn, sql, (itp_id,))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def row_for_unit(conn: Any, unit_id: int) -> dict[str, Any] | None:
|
|
|
|
|
|
return packets.one(conn, "SELECT *, status::text AS status FROM itps WHERE unit_id = %s", (unit_id,))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_lines(conn: Any, itp_id: int) -> dict[str, dict[str, Any]]:
|
|
|
|
|
|
return {r["key"]: r for r in packets.rows(conn, "SELECT * FROM itp_lines WHERE itp_id = %s", (itp_id,))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_blocks(conn: Any, itp_id: int) -> dict[str, dict[str, Any]]:
|
|
|
|
|
|
return {r["block_key"]: r for r in packets.rows(conn, "SELECT * FROM itp_blocks WHERE itp_id = %s", (itp_id,))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def summary(itp_row: Mapping[str, Any], lines: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
|
|
|
pr = progress(itp_row["template"] or {}, lines)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": itp_row["id"],
|
|
|
|
|
|
"no": itp_row["no"],
|
|
|
|
|
|
"status": str(itp_row["status"]),
|
|
|
|
|
|
"done": pr["done"],
|
|
|
|
|
|
"total": pr["total"],
|
|
|
|
|
|
"holds": pr["holds"],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def unit_itps(conn: Any, unit_ids: Iterable[int]) -> dict[int, tuple[dict[str, Any], dict[str, dict[str, Any]]]]:
|
|
|
|
|
|
"""The ITP row and its lines per unit, for the given units, in two queries."""
|
|
|
|
|
|
ids = sorted({int(u) for u in unit_ids})
|
|
|
|
|
|
if not ids:
|
|
|
|
|
|
return {}
|
|
|
|
|
|
itps = packets.rows(conn, "SELECT *, status::text AS status FROM itps WHERE unit_id = ANY(%s)", (ids,))
|
|
|
|
|
|
if not itps:
|
|
|
|
|
|
return {}
|
|
|
|
|
|
lines: dict[int, dict[str, dict[str, Any]]] = {r["id"]: {} for r in itps}
|
|
|
|
|
|
for ln in packets.rows(
|
|
|
|
|
|
conn,
|
|
|
|
|
|
"SELECT itp_id, key, result, cust_code, ncr FROM itp_lines WHERE itp_id = ANY(%s)",
|
|
|
|
|
|
([r["id"] for r in itps],),
|
|
|
|
|
|
):
|
|
|
|
|
|
lines[ln["itp_id"]][ln["key"]] = ln
|
|
|
|
|
|
return {int(r["unit_id"]): (r, lines[r["id"]]) for r in itps}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def summaries(conn: Any, unit_ids: Iterable[int]) -> dict[int, dict[str, Any]]:
|
|
|
|
|
|
return {u: summary(row, lines) for u, (row, lines) in unit_itps(conn, unit_ids).items()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- the read
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def can_of(pp: policy.Principal, itp_row: Mapping[str, Any], blocks_signed: bool) -> dict[str, bool]:
|
|
|
|
|
|
open_ = str(itp_row["status"]) != "complete"
|
|
|
|
|
|
edit = open_ and check_scoped(pp, A.ITP_EDIT_LINE, assume_it_reason=True).ok
|
|
|
|
|
|
sign_off = open_ and blocks_signed and check_scoped(pp, A.ITP_SIGN_OFF, assume_it_reason=True).ok
|
|
|
|
|
|
reopen = (not open_) and check_scoped(pp, A.ITP_REOPEN, extra={"reason": "x" * 8}, assume_it_reason=True).ok
|
|
|
|
|
|
return {"edit": edit, "sign_off": sign_off, "reopen": reopen}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read(conn: Any, itp_row: Mapping[str, Any], pp: policy.Principal) -> dict[str, Any]:
|
|
|
|
|
|
"""The full ITP answer (GET /api/units/{unit_id}/itp and every ITP write)."""
|
|
|
|
|
|
iid = itp_row["id"]
|
|
|
|
|
|
template = itp_row["template"] or {}
|
|
|
|
|
|
lines = load_lines(conn, iid)
|
|
|
|
|
|
brows = load_blocks(conn, iid)
|
|
|
|
|
|
rejects: dict[str, list[dict[str, Any]]] = {}
|
|
|
|
|
|
for r in packets.rows(conn, "SELECT * FROM itp_rejects WHERE itp_id = %s ORDER BY id", (iid,)):
|
|
|
|
|
|
rejects.setdefault(r["line_key"], []).append(r)
|
|
|
|
|
|
log_rows = packets.rows(conn, "SELECT * FROM itp_log WHERE itp_id = %s ORDER BY id DESC LIMIT %s", (iid, LOG_MAX))
|
|
|
|
|
|
ids: list[Any] = [itp_row["signoff_by"], itp_row["reopen_by"], itp_row["created_by"]]
|
|
|
|
|
|
for ln in lines.values():
|
|
|
|
|
|
ids += [ln["by"], ln["cust_rec"]]
|
|
|
|
|
|
for b in brows.values():
|
|
|
|
|
|
ids += [b["signed_by"], b["cust_by"]]
|
|
|
|
|
|
for rs in rejects.values():
|
|
|
|
|
|
ids += [r["by"] for r in rs]
|
|
|
|
|
|
ids += [r["by"] for r in log_rows]
|
|
|
|
|
|
people = packets.People(conn, ids)
|
|
|
|
|
|
unit = packets.one(
|
|
|
|
|
|
conn,
|
|
|
|
|
|
"SELECT u.id, u.number, u.project_id, u.project_number FROM v_units u WHERE u.id = %s",
|
|
|
|
|
|
(itp_row["unit_id"],),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
out_lines: dict[str, Any] = {}
|
|
|
|
|
|
for i in items(template):
|
|
|
|
|
|
key = str(i["n"])
|
|
|
|
|
|
ln = lines.get(key) or {}
|
|
|
|
|
|
result = ln.get("result")
|
|
|
|
|
|
out_lines[key] = {
|
|
|
|
|
|
"key": key,
|
|
|
|
|
|
"result": result,
|
|
|
|
|
|
"na": result == "na",
|
|
|
|
|
|
"by": people.ref(ln.get("by")) if result == "approve" else None,
|
|
|
|
|
|
"initials": ln.get("initials") if result == "approve" else None,
|
|
|
|
|
|
"at": ln.get("at") if result == "approve" else None,
|
|
|
|
|
|
"ncr": ln.get("ncr"),
|
|
|
|
|
|
"ncr_missing": result == "reject" and not ln.get("ncr"),
|
|
|
|
|
|
"note": ln.get("note"),
|
|
|
|
|
|
"cust_code": ln.get("cust_code"),
|
|
|
|
|
|
"cust_by": ln.get("cust_by"),
|
|
|
|
|
|
"cust_at": ln.get("cust_at"),
|
|
|
|
|
|
"cust_rec": people.ref(ln.get("cust_rec")),
|
|
|
|
|
|
"rejects": [
|
|
|
|
|
|
{"at": r["at"], "by": people.ref(r["by"]), "ncr": r["ncr"], "note": r["note"], "kind": r["kind"]}
|
|
|
|
|
|
for r in rejects.get(key, [])
|
|
|
|
|
|
],
|
|
|
|
|
|
"row_version": ln.get("row_version", 0),
|
|
|
|
|
|
}
|
|
|
|
|
|
out_blocks: list[dict[str, Any]] = []
|
|
|
|
|
|
for b in blocks(template):
|
|
|
|
|
|
br = brows.get(b["key"]) or {}
|
|
|
|
|
|
out_blocks.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"key": b["key"],
|
|
|
|
|
|
"sections": [str(s["n"]) for s in b["sections"]],
|
|
|
|
|
|
"scope": block_scope(b),
|
|
|
|
|
|
"final": b["final"],
|
|
|
|
|
|
"ready": block_ready(b, lines),
|
|
|
|
|
|
"all_na": block_all_na(b, lines),
|
|
|
|
|
|
"signed": br.get("signed_at") is not None,
|
|
|
|
|
|
"signed_at": br.get("signed_at"),
|
|
|
|
|
|
"signed_by": people.ref(br.get("signed_by")) if br.get("signed_at") is not None else None,
|
|
|
|
|
|
"qn": br.get("qn"),
|
|
|
|
|
|
"remarks": br.get("remarks"),
|
|
|
|
|
|
"cust_name": br.get("cust_name"),
|
|
|
|
|
|
"cust_at": br.get("cust_at"),
|
|
|
|
|
|
"cust_by": people.ref(br.get("cust_by")),
|
|
|
|
|
|
"row_version": br.get("row_version", 0),
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
all_signed = bool(out_blocks) and all(b["signed"] for b in out_blocks)
|
|
|
|
|
|
pages_present = {s.get("page") for s in template.get("sections") or []}
|
|
|
|
|
|
status = str(itp_row["status"])
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": iid,
|
|
|
|
|
|
"unit_id": itp_row["unit_id"],
|
|
|
|
|
|
"unit": None
|
|
|
|
|
|
if unit is None
|
|
|
|
|
|
else {
|
|
|
|
|
|
"id": unit["id"],
|
|
|
|
|
|
"number": unit["number"],
|
|
|
|
|
|
"project": {"id": unit["project_id"], "number": unit["project_number"]},
|
|
|
|
|
|
},
|
|
|
|
|
|
"no": itp_row["no"],
|
|
|
|
|
|
"hdr": {k: (itp_row["hdr"] or {}).get(k) for k in HDR_KEYS},
|
|
|
|
|
|
"status": "complete" if status == "complete" else "open",
|
|
|
|
|
|
"version": itp_row["version"],
|
|
|
|
|
|
"template": template,
|
|
|
|
|
|
"pages": [{"code": c, "name": PAGE_NAMES[c]} for c in PAGE_ORDER if c in pages_present],
|
|
|
|
|
|
"lines": out_lines,
|
|
|
|
|
|
"blocks": out_blocks,
|
|
|
|
|
|
"progress": progress(template, lines),
|
|
|
|
|
|
"holds": open_holds(template, lines),
|
|
|
|
|
|
"signoff": None
|
|
|
|
|
|
if itp_row["signoff_at"] is None
|
|
|
|
|
|
else {
|
|
|
|
|
|
"at": itp_row["signoff_at"],
|
|
|
|
|
|
"by": people.ref(itp_row["signoff_by"]),
|
|
|
|
|
|
"asset_id": packets.uid_text(itp_row["signoff_asset_id"]),
|
|
|
|
|
|
},
|
|
|
|
|
|
"reopen": None
|
|
|
|
|
|
if itp_row["reopen_at"] is None
|
|
|
|
|
|
else {"at": itp_row["reopen_at"], "by": people.ref(itp_row["reopen_by"]), "reason": itp_row["reopen_reason"]},
|
|
|
|
|
|
"created_at": itp_row["created_at"],
|
|
|
|
|
|
"created_by": people.ref(itp_row["created_by"]),
|
|
|
|
|
|
"can": can_of(pp, itp_row, all_signed),
|
|
|
|
|
|
"log": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": r["id"],
|
|
|
|
|
|
"at": r["at"],
|
|
|
|
|
|
"by": people.ref(r["by"]),
|
|
|
|
|
|
"by_name": r["by_name"],
|
|
|
|
|
|
"action": r["action"],
|
|
|
|
|
|
"note": r["note"],
|
|
|
|
|
|
"signed": r["signature_id"] is not None,
|
|
|
|
|
|
}
|
|
|
|
|
|
for r in log_rows
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
HDR_KEYS: tuple[str, ...] = ("client", "pname", "pno", "po", "unit")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- the write
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class ItpWrite:
|
|
|
|
|
|
conn: Any
|
|
|
|
|
|
principal: deps.Principal
|
|
|
|
|
|
policy_principal: policy.Principal
|
|
|
|
|
|
itp: dict[str, Any]
|
|
|
|
|
|
action: policy.Action
|
|
|
|
|
|
decision: policy.Decision
|
|
|
|
|
|
request: Any
|
|
|
|
|
|
it_reason: str | None # the cleaned IT reason, kept only when the admin bypass was used
|
|
|
|
|
|
signature: Signature | None = None
|
|
|
|
|
|
logs: list[tuple[str, str | None]] = field(default_factory=list)
|
|
|
|
|
|
version: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def id(self) -> int:
|
|
|
|
|
|
return int(self.itp["id"])
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def user_id(self) -> Any:
|
|
|
|
|
|
return self.principal.user_id
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def tz(self) -> str:
|
|
|
|
|
|
return str(deps.settings_of(self.request).site_tz)
|
|
|
|
|
|
|
|
|
|
|
|
def log(self, action: str, note: str | None = None) -> None:
|
|
|
|
|
|
"""One itp_log row (and audit row) for this write. A write that logs nothing changes nothing."""
|
|
|
|
|
|
self.logs.append((action, note))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def refuse(decision: policy.Decision) -> None:
|
|
|
|
|
|
if decision.ok:
|
|
|
|
|
|
return
|
|
|
|
|
|
status = 400 if decision.code in ("it_reason_required", "reason_required") else 403
|
|
|
|
|
|
raise ApiError(status, decision.code, decision.why)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def scoped_decision(
|
|
|
|
|
|
p: deps.Principal,
|
|
|
|
|
|
pp: policy.Principal,
|
|
|
|
|
|
action: policy.Action,
|
|
|
|
|
|
*,
|
|
|
|
|
|
extra: Mapping[str, Any] | None = None,
|
|
|
|
|
|
it_reason: str | None = None,
|
|
|
|
|
|
) -> tuple[policy.Decision, str | None]:
|
|
|
|
|
|
"""check_scoped, raising the refusal. Returns the decision and the IT reason to record (bypass only)."""
|
|
|
|
|
|
del p
|
|
|
|
|
|
decision = check_scoped(pp, action, extra=extra, it_reason=it_reason)
|
|
|
|
|
|
refuse(decision)
|
|
|
|
|
|
return decision, (reason_ok(it_reason) if decision.admin_bypass else None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@contextmanager
|
|
|
|
|
|
def itp_write(
|
|
|
|
|
|
d: deps.Db,
|
|
|
|
|
|
p: deps.Principal,
|
|
|
|
|
|
itp_id: int,
|
|
|
|
|
|
action: policy.Action,
|
|
|
|
|
|
*,
|
|
|
|
|
|
request: Any,
|
|
|
|
|
|
extra: Mapping[str, Any] | None = None,
|
|
|
|
|
|
it_reason: str | None = None,
|
|
|
|
|
|
sign: Any = None,
|
|
|
|
|
|
allow_complete: bool = False,
|
|
|
|
|
|
sign_extra: Mapping[str, Any] | None = None,
|
|
|
|
|
|
) -> Iterator[ItpWrite]:
|
|
|
|
|
|
"""One ITP write, in one transaction. See the module docstring for the order of steps.
|
|
|
|
|
|
|
|
|
|
|
|
A wrong signature secret commits the step-up failure records and then raises, like the packet pipeline.
|
|
|
|
|
|
"""
|
|
|
|
|
|
conn = d.conn
|
|
|
|
|
|
pp = packets.policy_principal_for(conn, p)
|
|
|
|
|
|
decision, it_kept = scoped_decision(p, pp, action, extra=extra, it_reason=it_reason)
|
|
|
|
|
|
failure: ApiError | None = None
|
|
|
|
|
|
with conn.transaction():
|
|
|
|
|
|
row = load_row(conn, itp_id, lock=True)
|
|
|
|
|
|
if row is None:
|
|
|
|
|
|
raise ApiError(404, "not_found", MSG_NO_ITP)
|
|
|
|
|
|
if str(row["status"]) == "complete" and not allow_complete:
|
|
|
|
|
|
raise ApiError(409, "itp_complete", MSG_COMPLETE)
|
|
|
|
|
|
w = ItpWrite(
|
|
|
|
|
|
conn=conn,
|
|
|
|
|
|
principal=p,
|
|
|
|
|
|
policy_principal=pp,
|
|
|
|
|
|
itp=row,
|
|
|
|
|
|
action=action,
|
|
|
|
|
|
decision=decision,
|
|
|
|
|
|
request=request,
|
|
|
|
|
|
it_reason=it_kept,
|
|
|
|
|
|
version=int(row["version"]),
|
|
|
|
|
|
)
|
|
|
|
|
|
if decision.signature:
|
|
|
|
|
|
if sign is None:
|
|
|
|
|
|
raise ApiError(
|
|
|
|
|
|
400, "signature_required", "This step needs your signature.", extra={"meaning": decision.signature}
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
w.signature = signatures.sign(
|
|
|
|
|
|
conn,
|
|
|
|
|
|
deps.directory_of(request),
|
|
|
|
|
|
p.actor,
|
|
|
|
|
|
factor=sign.factor,
|
|
|
|
|
|
secret=sign.secret,
|
|
|
|
|
|
action=f"itp.{action}",
|
|
|
|
|
|
meaning=decision.signature,
|
|
|
|
|
|
packet_id=None,
|
|
|
|
|
|
subject_type="itp",
|
|
|
|
|
|
subject_id=itp_id,
|
|
|
|
|
|
content={
|
|
|
|
|
|
"action": str(action),
|
|
|
|
|
|
"itp_id": itp_id,
|
|
|
|
|
|
"version": int(row["version"]),
|
|
|
|
|
|
**(sign_extra or {}),
|
|
|
|
|
|
},
|
|
|
|
|
|
ip=p.ip,
|
|
|
|
|
|
settings=deps.settings_of(request),
|
|
|
|
|
|
)
|
|
|
|
|
|
except ApiError as e:
|
|
|
|
|
|
failure = e
|
|
|
|
|
|
if failure is None:
|
|
|
|
|
|
yield w
|
|
|
|
|
|
_finish(w)
|
|
|
|
|
|
if failure is not None:
|
|
|
|
|
|
raise failure
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _finish(w: ItpWrite) -> None:
|
|
|
|
|
|
if not w.logs:
|
|
|
|
|
|
return
|
|
|
|
|
|
conn = w.conn
|
|
|
|
|
|
w.version = int(
|
|
|
|
|
|
conn.execute("UPDATE itps SET version = version + 1 WHERE id = %s RETURNING version", (w.id,)).fetchone()[0]
|
|
|
|
|
|
)
|
|
|
|
|
|
sig_id = w.signature.id if w.signature else None
|
|
|
|
|
|
name = w.principal.user["display_name"]
|
|
|
|
|
|
for n, (action, note) in enumerate(w.logs):
|
|
|
|
|
|
text = clean_opt(note, LIMITS["note"]) if note else None
|
|
|
|
|
|
sid = sig_id if n == 0 else None
|
|
|
|
|
|
conn.execute(
|
|
|
|
|
|
"INSERT INTO itp_log (itp_id, by, by_name, action, note, signature_id, it_reason)"
|
|
|
|
|
|
" VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
|
|
|
|
|
(w.id, w.user_id, name, action, text, sid, w.it_reason),
|
|
|
|
|
|
)
|
|
|
|
|
|
detail: dict[str, Any] = {"text": action, "note": text, "unit_id": w.itp["unit_id"]}
|
|
|
|
|
|
if sid:
|
|
|
|
|
|
detail["signature_id"] = sid
|
|
|
|
|
|
audit.append(
|
|
|
|
|
|
conn,
|
|
|
|
|
|
action=f"itp.{w.action}",
|
|
|
|
|
|
user_id=w.user_id,
|
|
|
|
|
|
session_id=w.principal.session_id,
|
|
|
|
|
|
role_snapshot=w.principal.role_snapshot,
|
|
|
|
|
|
subject_type="itp",
|
|
|
|
|
|
subject_id=w.id,
|
|
|
|
|
|
detail=detail,
|
|
|
|
|
|
it_reason=w.it_reason,
|
|
|
|
|
|
ip=w.principal.ip,
|
|
|
|
|
|
)
|
|
|
|
|
|
events.publish(conn, {"event": "changed", "kind": "itp", "unit_id": w.itp["unit_id"], "itp_id": w.id})
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-21 18:27:09 -06:00
|
|
|
|
def reject_dept(conn: Any, unit_id: Any, page: Any) -> str | None:
|
|
|
|
|
|
"""The department of a rejected line: the one department among the unit's live packets that works to the
|
|
|
|
|
|
line's page, or None when there is none or more than one."""
|
|
|
|
|
|
codes = {
|
|
|
|
|
|
str(r[0])
|
|
|
|
|
|
for r in conn.execute(
|
|
|
|
|
|
"SELECT DISTINCT dept_code FROM packets WHERE unit_id = %s AND removed_at IS NULL", (unit_id,)
|
|
|
|
|
|
).fetchall()
|
|
|
|
|
|
}
|
|
|
|
|
|
hits = sorted(c for c in codes if page and page in DEPT_PAGES.get(c, ()))
|
|
|
|
|
|
return hits[0] if len(hits) == 1 else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def notify_reject(w: ItpWrite, key: str, ncr: str | None, note: str | None) -> int | None:
|
|
|
|
|
|
"""qc_reject for an ITP line reject, in the write's transaction (notify-send mails it)."""
|
|
|
|
|
|
item = next((i for i in items(w.itp.get("template") or {}) if str(i.get("n")) == key), {})
|
|
|
|
|
|
page = item.get("page")
|
|
|
|
|
|
dept = reject_dept(w.conn, w.itp["unit_id"], page)
|
|
|
|
|
|
who = (w.principal.user or {}).get("display_name") or ""
|
|
|
|
|
|
head = f"NCR {ncr}" if ncr else "NCR # pending"
|
|
|
|
|
|
lines = [str(item.get("t") or ""), head, note or "", f"Rejected by {who}." if who else ""]
|
|
|
|
|
|
where = f" ({PAGE_NAMES.get(str(page), page)})" if page else ""
|
|
|
|
|
|
return raise_event(
|
|
|
|
|
|
w.conn,
|
|
|
|
|
|
"qc_reject",
|
|
|
|
|
|
subject=f"itp:{w.id}:{key}",
|
|
|
|
|
|
title=f"ITP {w.itp.get('no') or w.id} line {key} rejected{where}",
|
|
|
|
|
|
body="\n".join(x for x in lines if x),
|
|
|
|
|
|
detail={"itp_id": w.id, "unit_id": w.itp["unit_id"], "line": key, "page": page, "ncr": ncr},
|
|
|
|
|
|
dept_code=dept,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-17 20:08:53 -06:00
|
|
|
|
def label(action: policy.Action) -> str:
|
|
|
|
|
|
return SCOPED_LABELS.get(action, str(action))
|