Phase 2: core domain, policy engine, packet API, Clerical screen

Migration 0003: projects, aliases, renumbers, units, packets and every child table,
change orders, assets, documents, ITPs, station limits, department weld flag.
Policy package: Principal, PacketState, Action rules, custody, preconditions,
CO hold, order of checks, can/blocked/holder on reads. docs/POLICY.md with the
tightenings table for Seth. Write pipeline: FOR UPDATE, CAS on status and dept,
If-Match row versions, server-assigned numbers, field CAS, version bump, packet
log, audit, NOTIFY, signatures, IT reasons, filed lock and HMAC seal.
API: /api/packets (header, transitions, work orders, pages, docs, move, remove,
BOM, shorts, welds, heat tags, heat table, stage, checklist, clock, warehouse
stage, Q.C., change orders, log, seal), /api/projects, /api/units, /api/assets,
/_blob. Frontend: Clerical home, projects and units, packet page with pdf.js
and QR codes, hand draft, heat map, send to warehouse, department or Q.C.
Tests: policy matrix, API, concurrency, schema, projects; Playwright Clerical
draft scenario. Verified on docker-1 with the CI stack and the Samba DC.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 09:31:45 -06:00
co-authored by Claude Fable 5.1
parent 97b0973130
commit 37d9a0cb50
83 changed files with 16490 additions and 55 deletions
+34
View File
@@ -0,0 +1,34 @@
"""Packet policy: who may do what to a packet, and when. Pure functions, no I/O. See docs/POLICY.md."""
from __future__ import annotations
from .actions import ACTIVE, RULES, SHOP, STATUSES, TRANSITIONS, Action, Rule, Who
from .custody import holder_name, holds, works_on
from .engine import Decision, can, check, evaluate
from .preconditions import INPUT_ONLY, MESSAGES, PRECONDITIONS
from .principal import SYSTEM_FEED, PacketState, Principal, principal_from_roles
__all__ = [
"ACTIVE",
"INPUT_ONLY",
"MESSAGES",
"PRECONDITIONS",
"RULES",
"SHOP",
"STATUSES",
"SYSTEM_FEED",
"TRANSITIONS",
"Action",
"Decision",
"PacketState",
"Principal",
"Rule",
"Who",
"can",
"check",
"evaluate",
"holder_name",
"holds",
"principal_from_roles",
"works_on",
]
+318
View File
@@ -0,0 +1,318 @@
"""The Action enum, the Who predicates and the rule table. Plain data; the engine reads it.
A port of the old guard.js POLICY_TRANSITIONS, POLICY_FIELDS, SIGN_OFF and CO_HOLD tables, with the
tightenings listed in docs/POLICY.md.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
# Statuses in workflow order (the packet_status enum declaration order).
STATUSES: tuple[str, ...] = (
"draft",
"warehouse",
"ready",
"assigned",
"in_progress",
"review",
"qc",
"complete",
"filed",
)
SHOP: frozenset[str] = frozenset({"ready", "assigned", "in_progress", "review"})
ACTIVE: frozenset[str] = frozenset({"assigned", "in_progress"})
# Short labels for messages.
STATUS_LABELS: dict[str, str] = {
"draft": "Draft",
"warehouse": "With warehouse",
"ready": "Ready to assign",
"assigned": "At a station",
"in_progress": "In progress",
"review": "Supervisor review",
"qc": "At Q.C.",
"complete": "Complete",
"filed": "Filed",
}
# Department codes that are not shop departments.
SUPPORT_CODES: frozenset[str] = frozenset({"CL", "WH", "QC", "PM"})
class Action(StrEnum):
# transitions
SEND_TO_WAREHOUSE = "send_to_warehouse"
RELEASE_TO_SHOP = "release_to_shop"
SEND_DRAFT_TO_QC = "send_draft_to_qc"
WH_SEND_BACK = "wh_send_back"
RETURN_TO_CLERICAL = "return_to_clerical"
WH_RELEASE = "wh_release"
SEND_BACK_TO_WAREHOUSE = "send_back_to_warehouse"
ASSIGN = "assign"
UNASSIGN = "unassign"
PULL_BACK = "pull_back"
START = "start"
SUBMIT = "submit"
RETURN_TO_STATION = "return_to_station"
SEND_TO_QC = "send_to_qc"
ROUTE_FROM_REVIEW = "route_from_review"
QC_REJECT = "qc_reject"
QC_ACCEPT = "qc_accept"
QC_ACCEPT_AND_ROUTE = "qc_accept_and_route"
FILE = "file"
ADMIN_REOPEN = "admin_reopen"
# edits (no status change)
EDIT_HEADER = "edit_header"
EDIT_DRAWING_REF = "edit_drawing_ref"
EDIT_DEPT = "edit_dept"
MOVE_UNIT = "move_unit"
EDIT_WOS = "edit_wos"
EDIT_BOM = "edit_bom"
WH_RECEIVE_SHORT = "wh_receive_short"
EDIT_HEATS = "edit_heats"
EDIT_HEAT_TABLE = "edit_heat_table"
EDIT_WELDS = "edit_welds"
EDIT_CHECKLIST = "edit_checklist"
EDIT_STAGE_FIELDS = "edit_stage_fields"
CLOCK = "clock"
EDIT_QC = "edit_qc"
SET_WH_STAGE = "set_wh_stage"
EDIT_DOCUMENT_SET = "edit_document_set"
DOC_ADD = "doc_add"
DOC_REMOVE = "doc_remove"
REV_APPROVE = "rev_approve"
SET_VCLOSED = "set_vclosed"
SET_DATABOOK_AT = "set_databook_at"
REMOVE = "remove"
CO_ISSUE = "co_issue"
CO_APPROVE = "co_approve"
CO_REJECT = "co_reject"
CO_HOLD_TOGGLE = "co_hold_toggle"
CO_VERIFY = "co_verify"
ITP_EDIT_LINE = "itp_edit_line"
ITP_BLOCK_SIGN = "itp_block_sign"
ITP_SIGN_OFF = "itp_sign_off"
ITP_REOPEN = "itp_reopen"
VIS_UPDATE_DRAFT = "vis_update_draft"
VIS_OPEN_CO = "vis_open_co"
VIS_AUTO_RELEASE = "vis_auto_release"
VIS_RETIRE_DRAFT = "vis_retire_draft"
class Who(StrEnum):
"""The closed set of who predicates. custody.allows() evaluates them."""
CL = "CL"
CL_SUP = "CL_SUP"
WH = "WH"
WH_SUP = "WH_SUP"
QC = "QC"
QC_SUP = "QC_SUP"
CL_OR_QC = "CL_OR_QC"
HOLDER = "HOLDER"
DEPT_LEAD = "DEPT_LEAD"
DEPT_SUP = "DEPT_SUP"
WORKS_ON = "WORKS_ON"
CO_VERIFIER = "CO_VERIFIER"
ADMIN = "ADMIN"
SYSTEM = "SYSTEM"
@dataclass(frozen=True)
class Rule:
who: Who
from_status: frozenset[str] # empty = any status (edits); the filed lock is applied separately
to_status: str | None # None for edits
dept_change: str = "no" # no | required | allowed
co_hold: bool = False
preconditions: tuple[str, ...] = ()
signature: str | None = None # the meaning text when a signature is required
reason_required: bool = False
filed_ok: bool = False # only SET_DATABOOK_AT and ADMIN_REOPEN
system_only: bool = False
@property
def transition(self) -> bool:
return self.to_status is not None
def _s(*statuses: str | frozenset[str]) -> frozenset[str]:
out: set[str] = set()
for s in statuses:
if isinstance(s, str):
out.add(s)
else:
out.update(s)
return frozenset(out)
ANY: frozenset[str] = frozenset()
_REASON = ("reason_given",)
_DRAFT_OUT = ("heat_map_answered", "drawing_attached")
A = Action
RULES: dict[Action, Rule] = {
# transitions
A.SEND_TO_WAREHOUSE: Rule(Who.CL, _s("draft"), "warehouse", preconditions=_DRAFT_OUT),
A.RELEASE_TO_SHOP: Rule(
Who.CL, _s("draft"), "ready", dept_change="allowed", preconditions=(*_DRAFT_OUT, "target_dept_given")
),
A.SEND_DRAFT_TO_QC: Rule(Who.CL, _s("draft"), "qc", preconditions=_DRAFT_OUT),
A.WH_SEND_BACK: Rule(Who.WH, _s("warehouse"), "draft", preconditions=_REASON, reason_required=True),
A.RETURN_TO_CLERICAL: Rule(Who.DEPT_LEAD, _s("ready"), "draft", preconditions=_REASON, reason_required=True),
A.WH_RELEASE: Rule(Who.WH, _s("warehouse"), "ready", co_hold=True, preconditions=("bom_all_pulled_or_short",)),
A.SEND_BACK_TO_WAREHOUSE: Rule(
Who.DEPT_LEAD, _s("ready"), "warehouse", preconditions=_REASON, reason_required=True
),
A.ASSIGN: Rule(Who.DEPT_LEAD, _s("ready"), "assigned", preconditions=("welder_set",)),
A.UNASSIGN: Rule(Who.DEPT_LEAD, _s("assigned"), "ready"),
A.PULL_BACK: Rule(Who.DEPT_LEAD, _s("in_progress"), "ready"),
A.START: Rule(Who.WORKS_ON, _s("assigned"), "in_progress"),
A.SUBMIT: Rule(
Who.WORKS_ON,
_s("assigned", "in_progress"),
"review",
co_hold=True,
preconditions=("checklist_complete", "welds_recorded", "filler_set"),
signature="Submit work to the supervisor",
),
A.RETURN_TO_STATION: Rule(Who.DEPT_SUP, _s("review"), "assigned", preconditions=_REASON, reason_required=True),
A.SEND_TO_QC: Rule(
Who.DEPT_SUP,
_s("review"),
"qc",
co_hold=True,
preconditions=("qc_book_spool_location",),
signature="Send to Q.C.",
),
A.ROUTE_FROM_REVIEW: Rule(
Who.DEPT_SUP,
_s("review"),
"ready",
dept_change="required",
co_hold=True,
preconditions=("target_dept_given",),
signature="Route to another department",
),
A.QC_REJECT: Rule(Who.QC, _s("qc"), "review", preconditions=_REASON, signature="Q.C. reject", reason_required=True),
A.QC_ACCEPT: Rule(
Who.QC,
_s("qc"),
"complete",
co_hold=True,
preconditions=("qc_missing_empty", "route_given"),
signature="Q.C. accept",
),
A.QC_ACCEPT_AND_ROUTE: Rule(
Who.QC,
_s("qc"),
"ready",
dept_change="required",
co_hold=True,
preconditions=("qc_missing_empty", "target_dept_given"),
signature="Q.C. accept and route",
),
A.FILE: Rule(
Who.CL,
_s("complete"),
"filed",
co_hold=True,
preconditions=("vclosed_if_databook",),
signature="File in the data book",
),
A.ADMIN_REOPEN: Rule(
Who.ADMIN,
_s("filed"),
"complete",
preconditions=_REASON,
signature="Reopen a filed packet",
reason_required=True,
filed_ok=True,
),
# edits
A.EDIT_HEADER: Rule(Who.CL, _s("draft"), None),
A.EDIT_DRAWING_REF: Rule(Who.CL, ANY, None),
A.EDIT_DEPT: Rule(Who.CL, _s("draft"), None),
A.MOVE_UNIT: Rule(Who.CL, ANY, None),
A.EDIT_WOS: Rule(Who.CL, _s("draft"), None),
A.EDIT_BOM: Rule(Who.HOLDER, _s("draft", "warehouse", SHOP), None),
A.WH_RECEIVE_SHORT: Rule(Who.WH, ANY, None, preconditions=("short_open",)),
A.EDIT_HEATS: Rule(Who.HOLDER, _s("warehouse", SHOP), None),
A.EDIT_HEAT_TABLE: Rule(Who.HOLDER, _s("warehouse", SHOP), None),
A.EDIT_WELDS: Rule(Who.HOLDER, SHOP, None),
A.EDIT_CHECKLIST: Rule(Who.HOLDER, SHOP, None),
A.EDIT_STAGE_FIELDS: Rule(Who.HOLDER, SHOP, None),
A.CLOCK: Rule(Who.HOLDER, SHOP, None),
A.EDIT_QC: Rule(Who.QC, _s("qc"), None),
A.SET_WH_STAGE: Rule(Who.WH, _s("warehouse"), None),
A.EDIT_DOCUMENT_SET: Rule(Who.CL, ANY, None, signature="Attach a drawing by hand"),
A.DOC_ADD: Rule(Who.CL_OR_QC, ANY, None),
A.DOC_REMOVE: Rule(Who.CL_OR_QC, ANY, None),
A.REV_APPROVE: Rule(
Who.CL_SUP,
_s("draft"),
None,
preconditions=_REASON,
signature="Approve the M-Files revision",
reason_required=True,
),
A.SET_VCLOSED: Rule(Who.CL, _s("complete"), None),
A.SET_DATABOOK_AT: Rule(Who.CL_OR_QC, ANY, None, filed_ok=True),
A.REMOVE: Rule(Who.CL, _s("draft"), None, preconditions=_REASON, reason_required=True),
A.CO_ISSUE: Rule(Who.CL, ANY, None, signature="Issue a change order"),
A.CO_APPROVE: Rule(Who.CL, ANY, None, signature="Approve a change order"),
A.CO_REJECT: Rule(
Who.CL, ANY, None, preconditions=_REASON, signature="Reject a change order", reason_required=True
),
A.CO_HOLD_TOGGLE: Rule(Who.CL, ANY, None),
A.CO_VERIFY: Rule(Who.CO_VERIFIER, ANY, None, signature="Verify a change order for the department"),
A.ITP_EDIT_LINE: Rule(Who.QC, ANY, None),
A.ITP_BLOCK_SIGN: Rule(Who.QC, ANY, None, signature="Sign an ITP block"),
A.ITP_SIGN_OFF: Rule(Who.QC, ANY, None, signature="Sign off the ITP"),
# QC supervisor; an admin passes through the bypass with an IT reason.
A.ITP_REOPEN: Rule(Who.QC_SUP, ANY, None, preconditions=_REASON, reason_required=True),
A.VIS_UPDATE_DRAFT: Rule(Who.SYSTEM, _s("draft"), None, system_only=True),
A.VIS_OPEN_CO: Rule(Who.SYSTEM, frozenset(STATUSES) - {"draft", "filed"}, None, system_only=True),
A.VIS_AUTO_RELEASE: Rule(Who.SYSTEM, _s("draft"), "warehouse", preconditions=_DRAFT_OUT, system_only=True),
A.VIS_RETIRE_DRAFT: Rule(Who.SYSTEM, _s("draft"), None, system_only=True),
}
# The user transitions (not the system ones), in table order.
TRANSITIONS: tuple[Action, ...] = tuple(a for a, r in RULES.items() if r.transition and not r.system_only)
# What an edit changes, for the "Your access doesn't allow changing <what>" message.
EDIT_WHAT: dict[Action, str] = {
A.EDIT_HEADER: "the packet details",
A.EDIT_DRAWING_REF: "the drawing number",
A.EDIT_DEPT: "the department",
A.MOVE_UNIT: "the unit",
A.EDIT_WOS: "the work orders",
A.EDIT_BOM: "the materials",
A.WH_RECEIVE_SHORT: "short parts",
A.EDIT_HEATS: "heat tags",
A.EDIT_HEAT_TABLE: "the heat table",
A.EDIT_WELDS: "welds",
A.EDIT_CHECKLIST: "the checklist",
A.EDIT_STAGE_FIELDS: "the stage details",
A.CLOCK: "the clock",
A.EDIT_QC: "the Q.C. record",
A.SET_WH_STAGE: "the warehouse stage",
A.EDIT_DOCUMENT_SET: "the drawing pages",
A.DOC_ADD: "the documents",
A.DOC_REMOVE: "the documents",
A.REV_APPROVE: "the drawing revision",
A.SET_VCLOSED: "the VISUAL close",
A.SET_DATABOOK_AT: "the data book date",
A.REMOVE: "whether it is removed",
A.CO_ISSUE: "change orders",
A.CO_APPROVE: "change orders",
A.CO_REJECT: "change orders",
A.CO_HOLD_TOGGLE: "change orders",
A.CO_VERIFY: "change orders",
A.ITP_EDIT_LINE: "the ITP",
A.ITP_BLOCK_SIGN: "the ITP",
A.ITP_SIGN_OFF: "the ITP",
A.ITP_REOPEN: "the ITP",
}
+123
View File
@@ -0,0 +1,123 @@
"""Custody: who holds a packet right now, and the Who predicates. A direct port of guard.js holds()."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from .actions import ACTIVE, SUPPORT_CODES, Who
from .principal import PacketState, Principal
WORKER, LEAD, SUPERVISOR = 1, 2, 3
def works_on(principal: Principal, state: PacketState) -> bool:
"""A member of the packet's department whose station limits allow the packet's station.
Leads and supervisors always pass. A worker passes when they have no station limits or the limits hold
the packet's station. This is the old worksOn(); START and SUBMIT add the assigned-welder check.
"""
level = principal.level(state.dept)
if level == 0:
return False
if level >= LEAD:
return True
return not principal.station_limits or (
state.station_id is not None and state.station_id in principal.station_limits
)
def holds(principal: Principal, state: PacketState) -> bool:
"""True when the principal holds the packet in its current status. Admin is not a holder; see the bypass."""
st = state.status
if st == "draft":
return "CL" in principal.support
if st == "warehouse":
return "WH" in principal.support
if st in ("ready", "review"):
return principal.level(state.dept) >= LEAD
if st in ACTIVE:
return principal.level(state.dept) >= LEAD or works_on(principal, state)
if st == "qc":
return "QC" in principal.support
if st == "complete":
return "CL" in principal.support
return False
def holder_name(state: PacketState, dept_name: str | None = None, station_name: str | None = None) -> str:
"""Who has the packet, for messages. The department name falls back to its code."""
st = state.status
if st == "draft":
return "Clerical"
if st == "warehouse":
return "Warehouse"
if st == "qc":
return "Q.C."
if st == "complete":
return "Clerical (data book)"
if st == "filed":
return "the data book (filed)"
name = dept_name or state.dept
if st in ACTIVE and station_name:
return f"{name} · {station_name}"
return name
def _works_on_assigned(principal: Principal, state: PacketState) -> bool:
"""START and SUBMIT: a lead or above in the department, or the assigned welder within their station limits."""
level = principal.level(state.dept)
if level >= LEAD:
return True
return (
level == WORKER
and principal.user_id is not None
and state.welder_id is not None
and str(state.welder_id) == str(principal.user_id)
and works_on(principal, state)
)
def _co_verifier(principal: Principal, extra: Mapping[str, Any]) -> bool:
"""A supervisor of the department named in the request (extra["dept"])."""
dept = extra.get("dept")
if not isinstance(dept, str) or not dept:
return False
if dept in ("WH", "QC"):
return dept in principal.support_sup
if dept in SUPPORT_CODES:
return False # Clerical issues change orders; it never verifies them. PM has no supervisor.
return principal.level(dept) >= SUPERVISOR
def allows(who: Who, principal: Principal, state: PacketState, extra: Mapping[str, Any]) -> bool:
"""Evaluate one Who predicate. Admin is never allowed here except by Who.ADMIN; the bypass is a rule."""
if who is Who.CL:
return "CL" in principal.support
if who is Who.CL_SUP:
return "CL" in principal.support_sup
if who is Who.WH:
return "WH" in principal.support
if who is Who.WH_SUP:
return "WH" in principal.support_sup
if who is Who.QC:
return "QC" in principal.support
if who is Who.QC_SUP:
return "QC" in principal.support_sup
if who is Who.CL_OR_QC:
return "CL" in principal.support or "QC" in principal.support
if who is Who.HOLDER:
return holds(principal, state)
if who is Who.DEPT_LEAD:
return principal.level(state.dept) >= LEAD
if who is Who.DEPT_SUP:
return principal.level(state.dept) >= SUPERVISOR
if who is Who.WORKS_ON:
return _works_on_assigned(principal, state)
if who is Who.CO_VERIFIER:
return _co_verifier(principal, extra)
if who is Who.ADMIN:
return principal.admin
if who is Who.SYSTEM:
return principal.system is not None
return False
+183
View File
@@ -0,0 +1,183 @@
"""The policy engine: check one action, or list what a principal can do on a packet.
Order of checks, exactly:
1. removed packet: nothing passes, Admin included.
2. status: the rule's from statuses and the status conditions (RETURN_TO_CLERICAL needs wh_skipped,
SET_VCLOSED needs routed_to databook). An unknown status fails here.
3. change-order hold, Admin included.
4. system-only rules refuse people; the system principal passes only system-only rules.
5. admin bypass of who and custody, with an IT reason (SET_DATABOOK_AT needs none).
6. filed lock: only SET_DATABOOK_AT and ADMIN_REOPEN, Admin included.
7. who and custody.
8. preconditions in rule order, never bypassed.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any
from ..text import reason_ok
from .actions import EDIT_WHAT, RULES, STATUS_LABELS, STATUSES, Action, Rule
from .custody import allows, holder_name
from .preconditions import INPUT_ONLY, PRECONDITIONS
from .principal import PacketState, Principal
EMPTY: Mapping[str, Any] = MappingProxyType({})
MSG_REMOVED = "This packet was removed."
MSG_UNKNOWN_STATUS = "This packet has an unknown status."
MSG_CO_HOLD = "A change order holds this step."
MSG_SYSTEM_ONLY = "Only the VISUAL feed does this."
MSG_SYSTEM_FORBIDDEN = "The VISUAL feed cannot do this."
MSG_IT_REASON = "Give an IT reason of at least 8 characters."
MSG_FILED = "This packet is filed and locked. IT can reopen it if a correction is needed."
MSG_FORBIDDEN_STEP = "Your access doesn't allow this step."
MSG_NOT_WH_SKIPPED = "This packet came through the warehouse. Send it back to the warehouse instead."
MSG_NOT_DATABOOK = "Only packets routed to the data book are closed in VISUAL here."
@dataclass(frozen=True)
class Decision:
ok: bool
code: str = "" # removed | wrong_status | co_hold | filed | it_reason_required | forbidden | precondition
# | reason_required | system_only
why: str = "" # the message for the user
admin_bypass: bool = False # an admin passed who and custody only through the bypass (an IT edit)
signature: str | None = None # the rule's signature meaning, when one is required
reasons: tuple[str, ...] = () # read mode only: every blocking reason (CO hold and state preconditions)
# Status conditions beyond the from statuses: (predicate, message when it fails).
STATUS_CONDITIONS: dict[Action, tuple[Callable[[PacketState], bool], str]] = {
Action.RETURN_TO_CLERICAL: (lambda s: s.wh_skipped, MSG_NOT_WH_SKIPPED),
Action.SET_VCLOSED: (lambda s: s.routed_to == "databook", MSG_NOT_DATABOOK),
}
def _wrong_status(state: PacketState, action: Action, rule: Rule) -> str | None:
if state.status not in STATUSES:
return MSG_UNKNOWN_STATUS
if rule.from_status and state.status not in rule.from_status:
return f"This step isn't open while the packet is {STATUS_LABELS[state.status]}."
cond = STATUS_CONDITIONS.get(action)
if cond is not None and not cond[0](state):
return cond[1]
return None
def _forbidden_why(action: Action, rule: Rule, state: PacketState, extra: Mapping[str, Any]) -> str:
what = EDIT_WHAT.get(action)
if rule.transition or what is None:
return MSG_FORBIDDEN_STEP
holder = extra.get("holder_name")
if not isinstance(holder, str) or not holder:
holder = holder_name(state)
return f"Your access doesn't allow changing {what} on this packet. It's with {holder} right now."
def _evaluate(
principal: Principal,
state: PacketState,
action: Action,
extra: Mapping[str, Any],
it_reason: str | None,
*,
read: bool,
assume_it_reason: bool,
) -> Decision:
rule = RULES[action]
# (1) removed
if state.removed:
return Decision(False, "removed", MSG_REMOVED)
# (2) status
why = _wrong_status(state, action, rule)
if why is not None:
return Decision(False, "wrong_status", why)
# (3) change-order hold (deferred in read mode so the UI can show it as blocked after who passes)
blockers: list[str] = []
if rule.co_hold and state.co_hold:
hold_why = state.co_hold_why or MSG_CO_HOLD
if not read:
return Decision(False, "co_hold", hold_why)
blockers.append(hold_why)
# (4) system
if rule.system_only and principal.system is None:
return Decision(False, "system_only", MSG_SYSTEM_ONLY)
if principal.system is not None and not rule.system_only:
return Decision(False, "forbidden", MSG_SYSTEM_FORBIDDEN)
# (5) admin bypass
who_ok = allows(rule.who, principal, state, extra)
bypass = False
if not who_ok and principal.admin:
if action is Action.SET_DATABOOK_AT:
bypass = True
elif (read and assume_it_reason) or reason_ok(it_reason) is not None:
bypass = True
else:
return Decision(False, "it_reason_required", MSG_IT_REASON)
# (6) filed lock
if state.status == "filed" and not rule.filed_ok:
return Decision(False, "filed", MSG_FILED)
# (7) who and custody
if not who_ok and not bypass:
return Decision(False, "forbidden", _forbidden_why(action, rule, state, extra))
# (8) preconditions, never bypassed
for name in rule.preconditions:
if read and name in INPUT_ONLY:
continue
failed = PRECONDITIONS[name](state, extra)
if failed is None:
continue
if read:
blockers.append(failed)
continue
code = "reason_required" if rule.reason_required and name == "reason_given" else "precondition"
return Decision(False, code, failed, admin_bypass=bypass)
if blockers:
code = "co_hold" if rule.co_hold and state.co_hold else "precondition"
return Decision(False, code, blockers[0], admin_bypass=bypass, reasons=tuple(blockers))
return Decision(True, admin_bypass=bypass, signature=rule.signature)
def check(
principal: Principal,
state: PacketState,
action: Action,
*,
extra: Mapping[str, Any] = EMPTY,
it_reason: str | None = None,
) -> Decision:
"""Decide one action with the request input in `extra`. Never raises for an unknown status."""
return _evaluate(principal, state, action, extra, it_reason, read=False, assume_it_reason=False)
def evaluate(principal: Principal, state: PacketState, action: Action, *, assume_it_reason: bool = True) -> Decision:
"""Read-mode decision: input-only preconditions skipped, CO hold and state preconditions collected in reasons.
For an admin the IT reason is assumed when `assume_it_reason` is true; admin_bypass says the UI will ask.
"""
return _evaluate(principal, state, action, EMPTY, None, read=True, assume_it_reason=assume_it_reason)
def can(
principal: Principal, state: PacketState, assume_it_reason: bool = True
) -> tuple[list[Action], dict[Action, list[str]]]:
"""What the principal can do now, and what is blocked with the reasons. System-only actions are never listed.
`can` holds the actions that pass with an empty extra (input-only preconditions skipped). `blocked` holds the
actions that pass who and custody but fail a state precondition or the CO hold.
"""
allowed: list[Action] = []
blocked: dict[Action, list[str]] = {}
for action, rule in RULES.items():
if rule.system_only:
continue
d = evaluate(principal, state, action, assume_it_reason=assume_it_reason)
if d.ok:
allowed.append(action)
elif d.reasons:
blocked[action] = list(d.reasons)
return allowed, blocked
@@ -0,0 +1,146 @@
"""Named preconditions. Each takes (state, extra) and returns the reason when it fails, else None.
They are checked on the server and never bypassed, including by Admin. Some read request input (extra) rather
than packet state; `can()` skips those, because they are inputs the user gives with the step.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from typing import Any
from ..text import clean_text, reason_ok
from .actions import SUPPORT_CODES
from .principal import PacketState
Precondition = Callable[[PacketState, Mapping[str, Any]], str | None]
MESSAGES: dict[str, str] = {
"heat_map_answered": "Say whether this packet needs a heat map.",
"drawing_attached": "Attach the drawing first.",
"bom_all_pulled_or_short": "Pull every material line or mark it short first.",
"short_open": "No part is short on this packet.",
"welder_set": "Pick a station and a welder.",
"checklist_complete": "Finish the checklist first.",
"welds_recorded": "Record at least one weld first.",
"filler_set": "Enter the filler metal first.",
"qc_book_spool_location": "Confirm the book and the spool, and give the location.",
"qc_missing_empty": "Q.C. is not finished.",
"reason_given": "Give a reason of at least 8 characters.",
"vclosed_if_databook": "Close the job in VISUAL first.",
"co_staged_if_dwg_change": "Stage the new drawing for the change order first.",
"route_given": "Choose Data Book or Shipping.",
"target_dept_given": "Choose a different department.",
}
# Preconditions that read request input, not packet state. can() skips them.
INPUT_ONLY: frozenset[str] = frozenset(
{
"welder_set",
"target_dept_given",
"route_given",
"reason_given",
"qc_book_spool_location",
"co_staged_if_dwg_change",
}
)
ROUTES = ("databook", "shipping")
def _fail(name: str, ok: bool) -> str | None:
return None if ok else MESSAGES[name]
def heat_map_answered(state: PacketState, extra: Mapping[str, Any]) -> str | None:
return _fail("heat_map_answered", state.heat_map is not None)
def drawing_attached(state: PacketState, extra: Mapping[str, Any]) -> str | None:
return _fail("drawing_attached", state.drawing_attached)
def bom_all_pulled_or_short(state: PacketState, extra: Mapping[str, Any]) -> str | None:
return _fail("bom_all_pulled_or_short", state.bom_ready >= state.bom_total)
def short_open(state: PacketState, extra: Mapping[str, Any]) -> str | None:
"""The old whCanReceive: the warehouse may only receive into a packet with an open short."""
return _fail("short_open", state.open_shorts > 0)
def welder_set(state: PacketState, extra: Mapping[str, Any]) -> str | None:
return _fail("welder_set", extra.get("station_id") not in (None, "") and extra.get("welder_id") not in (None, ""))
def checklist_complete(state: PacketState, extra: Mapping[str, Any]) -> str | None:
return _fail("checklist_complete", state.checklist_complete)
def welds_recorded(state: PacketState, extra: Mapping[str, Any]) -> str | None:
return _fail("welds_recorded", not state.weld_dept or state.welds > 0)
def filler_set(state: PacketState, extra: Mapping[str, Any]) -> str | None:
return _fail("filler_set", not state.weld_dept or state.filler_set)
def qc_book_spool_location(state: PacketState, extra: Mapping[str, Any]) -> str | None:
ok = extra.get("book") is True and extra.get("spool") is True and bool(clean_text(extra.get("location"), 120))
return _fail("qc_book_spool_location", ok)
def qc_missing_empty(state: PacketState, extra: Mapping[str, Any]) -> str | None:
if not state.qc_missing:
return None
return "Q.C. still needs: " + ", ".join(state.qc_missing) + "."
def reason_given(state: PacketState, extra: Mapping[str, Any]) -> str | None:
return _fail("reason_given", reason_ok(extra.get("reason")) is not None)
def vclosed_if_databook(state: PacketState, extra: Mapping[str, Any]) -> str | None:
return _fail("vclosed_if_databook", state.routed_to != "databook" or state.vclosed)
def co_staged_if_dwg_change(state: PacketState, extra: Mapping[str, Any]) -> str | None:
return _fail("co_staged_if_dwg_change", extra.get("dwg_change") is not True or extra.get("staged") is True)
def route_given(state: PacketState, extra: Mapping[str, Any]) -> str | None:
return _fail("route_given", extra.get("routed_to") in ROUTES)
def is_shop_code(value: Any) -> bool:
"""A non-empty department code that is not a support code. The API checks it exists."""
return isinstance(value, str) and bool(value.strip()) and value not in SUPPORT_CODES
def target_dept_given(state: PacketState, extra: Mapping[str, Any]) -> str | None:
"""A shop department different from the packet's. For a draft (RELEASE_TO_SHOP) any shop department."""
dept = extra.get("dept")
if not is_shop_code(dept):
return "Choose a department."
if state.status != "draft" and dept == state.dept:
return MESSAGES["target_dept_given"]
return None
PRECONDITIONS: dict[str, Precondition] = {
"heat_map_answered": heat_map_answered,
"drawing_attached": drawing_attached,
"bom_all_pulled_or_short": bom_all_pulled_or_short,
"short_open": short_open,
"welder_set": welder_set,
"checklist_complete": checklist_complete,
"welds_recorded": welds_recorded,
"filler_set": filler_set,
"qc_book_spool_location": qc_book_spool_location,
"qc_missing_empty": qc_missing_empty,
"reason_given": reason_given,
"vclosed_if_databook": vclosed_if_databook,
"co_staged_if_dwg_change": co_staged_if_dwg_change,
"route_given": route_given,
"target_dept_given": target_dept_given,
}
+99
View File
@@ -0,0 +1,99 @@
"""The policy's view of a person (Principal) and of a packet (PacketState)."""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from types import MappingProxyType
from ..auth.groups import SHOP_LEVELS, parse_role
SUPPORT = ("CL", "WH", "QC")
LEVELS: dict[str, int] = {level: i + 1 for i, level in enumerate(SHOP_LEVELS)} # worker 1, lead 2, supervisor 3
@dataclass(frozen=True)
class Principal:
user_id: str | None # uuid as text; None for system
admin: bool = False
support: frozenset[str] = frozenset() # codes among CL, WH, QC the user is a member of (any level)
support_sup: frozenset[str] = frozenset() # codes among CL, WH, QC where the user is Supervisor
shop: Mapping[str, int] = field(default_factory=lambda: MappingProxyType({})) # code -> 1 worker, 2 lead, 3 sup
station_limits: frozenset[int] = frozenset() # station ids a worker is limited to; empty = any
pm: bool = False
session_kind: str = "password" # password | pin
system: str | None = None # "visual-feed" for the worker; never from a session
def level(self, dept: str) -> int:
"""Shop level in a department: 0 none, 1 worker, 2 lead, 3 supervisor."""
return int(self.shop.get(dept, 0))
@dataclass(frozen=True)
class PacketState:
id: int
status: str # packet_status value
dept: str # dept_code
station_id: int | None
welder_id: str | None # uuid text of the assigned welder
wh_skipped: bool
routed_to: str | None # databook | shipping | None
vclosed: bool
removed: bool
source_kind: str # visual | hand
weld_dept: bool # departments.weld
co_hold: bool # an approved CO with pending verifications, or a pending CO with hold=true
co_hold_why: str = "" # text for the error
open_shorts: int = 0
drawing_attached: bool = False
heat_map: bool | None = None
bom_total: int = 0 # lines not marked n/a
bom_ready: int = 0 # lines pulled, or with an open short
welds: int = 0
checklist_complete: bool = False # every key of the department checklist is done (empty list is complete)
filler_set: bool = False
qc_missing: tuple[str, ...] = () # the old qcMissing() list, computed by the read model
has_qc_sent: bool = False # qc_inspections row exists for the current stage
SYSTEM_FEED = Principal(user_id=None, system="visual-feed")
def principal_from_roles(
user_id: str | None,
roles: Iterable[str],
station_limits: Iterable[int] = (),
session_kind: str = "password",
) -> Principal:
"""Build the policy Principal from phase 1 role strings. A role that does not parse is ignored."""
admin = False
pm = False
support: set[str] = set()
support_sup: set[str] = set()
shop: dict[str, int] = {}
for text in roles:
try:
role = parse_role(text)
except ValueError:
continue
if role.kind == "admin":
admin = True
elif role.kind == "pm":
pm = True
elif role.kind.startswith("shop_") and role.dept_code:
level = LEVELS[role.kind.removeprefix("shop_")]
shop[role.dept_code] = max(level, shop.get(role.dept_code, 0))
elif role.dept_code in SUPPORT:
support.add(role.dept_code)
if role.kind.endswith("_supervisor"):
support_sup.add(role.dept_code)
return Principal(
user_id=user_id,
admin=admin,
support=frozenset(support),
support_sup=frozenset(support_sup),
shop=MappingProxyType(shop),
station_limits=frozenset(int(s) for s in station_limits),
pm=pm,
session_kind=session_kind,
)