Phase 6: Q.C. inspection, project folders, packet documents and the digital ITP
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:
@@ -0,0 +1,597 @@
|
||||
"""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
|
||||
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})
|
||||
|
||||
|
||||
def label(action: policy.Action) -> str:
|
||||
return SCOPED_LABELS.get(action, str(action))
|
||||
Reference in New Issue
Block a user