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:
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user