velocity-python 0.3.2__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- velocity/__init__.py +1 -1
- velocity/aws/authz.py +30 -0
- velocity/aws/capabilities/__init__.py +1 -0
- velocity/aws/capabilities/workflow.py +48 -0
- velocity/aws/handlers/mixins/__init__.py +13 -0
- velocity/aws/handlers/mixins/workflow_actions.py +491 -0
- velocity/aws/schema.py +5 -0
- velocity/rules/__init__.py +2 -0
- velocity/rules/engine.py +8 -1
- velocity/workflow/__init__.py +163 -0
- velocity/workflow/access.py +342 -0
- velocity/workflow/contract.py +1056 -0
- velocity/workflow/definitions.py +824 -0
- velocity/workflow/engine.py +3422 -0
- velocity/workflow/hooks.py +434 -0
- velocity/workflow/notices.py +410 -0
- velocity/workflow/outbox.py +266 -0
- velocity/workflow/points.py +300 -0
- velocity/workflow/schema.py +581 -0
- {velocity_python-0.3.2.dist-info → velocity_python-0.4.0.dist-info}/METADATA +1 -1
- {velocity_python-0.3.2.dist-info → velocity_python-0.4.0.dist-info}/RECORD +25 -13
- {velocity_python-0.3.2.dist-info → velocity_python-0.4.0.dist-info}/WHEEL +0 -0
- {velocity_python-0.3.2.dist-info → velocity_python-0.4.0.dist-info}/entry_points.txt +0 -0
- {velocity_python-0.3.2.dist-info → velocity_python-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {velocity_python-0.3.2.dist-info → velocity_python-0.4.0.dist-info}/top_level.txt +0 -0
velocity/__init__.py
CHANGED
velocity/aws/authz.py
CHANGED
|
@@ -961,6 +961,36 @@ SECURITY_EVENTS_TABLE = "sys_security_events"
|
|
|
961
961
|
protect_tables(SECURITY_EVENTS_TABLE)
|
|
962
962
|
confidential_tables(SECURITY_EVENTS_TABLE)
|
|
963
963
|
|
|
964
|
+
# velocity.workflow's tables, registered here for the same reason: the generic
|
|
965
|
+
# Data actions must refuse them whether or not anything imported
|
|
966
|
+
# velocity.workflow first. Route definitions change only through the
|
|
967
|
+
# workflow's own functions; dockets are read only through its contract
|
|
968
|
+
# builder (a generic read would bypass the visibility rule and expose, for
|
|
969
|
+
# one, every wait's correlation token). velocity.workflow.schema checks that
|
|
970
|
+
# it declares exactly these.
|
|
971
|
+
WORKFLOW_DEFINITION_TABLES = (
|
|
972
|
+
"sys_route",
|
|
973
|
+
"sys_route_station",
|
|
974
|
+
"sys_route_pass",
|
|
975
|
+
"sys_route_test",
|
|
976
|
+
)
|
|
977
|
+
WORKFLOW_DOCKET_TABLES = (
|
|
978
|
+
"sys_docket",
|
|
979
|
+
"sys_docket_position",
|
|
980
|
+
"sys_docket_document",
|
|
981
|
+
"sys_docket_mark",
|
|
982
|
+
"sys_docket_hold",
|
|
983
|
+
"sys_docket_flag",
|
|
984
|
+
"sys_docket_wait",
|
|
985
|
+
"sys_docket_link",
|
|
986
|
+
"sys_docket_outbox",
|
|
987
|
+
"sys_docket_seen",
|
|
988
|
+
"sys_notice",
|
|
989
|
+
"sys_tray_handover",
|
|
990
|
+
)
|
|
991
|
+
protect_tables(*WORKFLOW_DEFINITION_TABLES, *WORKFLOW_DOCKET_TABLES)
|
|
992
|
+
confidential_tables(*WORKFLOW_DOCKET_TABLES)
|
|
993
|
+
|
|
964
994
|
|
|
965
995
|
# ── the action index ───────────────────────────────────────────────────────
|
|
966
996
|
|
|
@@ -46,6 +46,7 @@ from . import security # noqa: E402,F401
|
|
|
46
46
|
from . import infra # noqa: E402,F401
|
|
47
47
|
from . import single_session # noqa: E402,F401
|
|
48
48
|
from . import jobs # noqa: E402,F401
|
|
49
|
+
from . import workflow # noqa: E402,F401
|
|
49
50
|
from .security import SecurityMixin # noqa: E402,F401
|
|
50
51
|
|
|
51
52
|
__all__ = [
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""workflow: the timers and the outbox of :mod:`velocity.workflow`, on a schedule.
|
|
2
|
+
|
|
3
|
+
Dockets keep time through this capability: once a minute ``sweep`` marks SLA
|
|
4
|
+
breaches (they stay), missed waits, expired claims and due hand-overs
|
|
5
|
+
(:func:`velocity.workflow.sweep`), then drains the outbox (program jobs,
|
|
6
|
+
notice emails; :func:`velocity.workflow.outbox.drain`). ``drain`` alone is
|
|
7
|
+
also schedulable.
|
|
8
|
+
|
|
9
|
+
Schema: the tables are the ``workflow`` infrastructure group
|
|
10
|
+
(``ensure_infrastructure(tx, include=("workflow",))``); the parts a column
|
|
11
|
+
list cannot declare — append-only, audit-required marks and the possession
|
|
12
|
+
constraint — come from :func:`velocity.workflow.ensure_schema`, which the
|
|
13
|
+
app's schema lane runs (it does both).
|
|
14
|
+
|
|
15
|
+
Handler mixins: ``WorkflowMixin`` on the Data handler (client actions),
|
|
16
|
+
``WorkflowQueueMixin`` on the queue handler.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from .base import Capability, capability
|
|
22
|
+
|
|
23
|
+
EVERY_MINUTE = "* * * * *"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@capability
|
|
27
|
+
class Workflow(Capability):
|
|
28
|
+
name = "workflow"
|
|
29
|
+
label = "Workflow"
|
|
30
|
+
description = (
|
|
31
|
+
"Dockets moving along routes of stations: SLA breaches, missed waits, "
|
|
32
|
+
"expired claims and hand-overs once a minute, and the outbox (program "
|
|
33
|
+
"jobs and notice emails) drained. No per-site policy."
|
|
34
|
+
)
|
|
35
|
+
schedules = (("sweep", EVERY_MINUTE),)
|
|
36
|
+
mixins = (
|
|
37
|
+
"velocity.aws.handlers.mixins.workflow_actions:WorkflowMixin",
|
|
38
|
+
"velocity.aws.handlers.mixins.workflow_actions:WorkflowQueueMixin",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def handle(self, action, tx, context, config):
|
|
42
|
+
from velocity.workflow import engine, outbox
|
|
43
|
+
|
|
44
|
+
if action == "sweep":
|
|
45
|
+
return {"swept": engine.sweep(tx), "drained": outbox.drain(tx, context)}
|
|
46
|
+
if action == "drain":
|
|
47
|
+
return {"drained": outbox.drain(tx, context)}
|
|
48
|
+
raise ValueError(f"workflow: unknown action {action!r}")
|
|
@@ -17,6 +17,9 @@ This package provides mixins for common Lambda handler patterns:
|
|
|
17
17
|
reportClientError / ErrorBoundary) to velocity.aws.failures
|
|
18
18
|
- DirtyPipelineMixin: the process-dirty-object queue consumer and the
|
|
19
19
|
sweep-dirty-rows action (velocity.aws.dirty_pipeline)
|
|
20
|
+
- WorkflowMixin / WorkflowQueueMixin: the workflow-* actions over dockets
|
|
21
|
+
(velocity.workflow): trays, the docket contract, acts, claims, flags,
|
|
22
|
+
notices; and the queue side (program jobs, outbox drain, timers)
|
|
20
23
|
- SchedulerMixin: run-schedules, the once-a-minute tick over sys_schedules
|
|
21
24
|
(velocity.aws.scheduler)
|
|
22
25
|
"""
|
|
@@ -30,6 +33,12 @@ from .session_control import SessionControlMixin
|
|
|
30
33
|
from .action_names import action_names
|
|
31
34
|
from .permissions_actions import PERMISSION_ACTIONS, PermissionsMixin
|
|
32
35
|
from .tiles_actions import TILE_ACTIONS, TilesMixin
|
|
36
|
+
from .workflow_actions import (
|
|
37
|
+
WORKFLOW_ACTIONS,
|
|
38
|
+
WORKFLOW_QUEUE_ACTIONS,
|
|
39
|
+
WorkflowMixin,
|
|
40
|
+
WorkflowQueueMixin,
|
|
41
|
+
)
|
|
33
42
|
from .client_errors import ClientErrorReporting, AnonymousClientErrorReporting
|
|
34
43
|
from velocity.aws.dirty_pipeline import DirtyPipelineMixin
|
|
35
44
|
from velocity.aws.scheduler import SchedulerMixin
|
|
@@ -47,6 +56,10 @@ __all__ = [
|
|
|
47
56
|
"PERMISSION_ACTIONS",
|
|
48
57
|
"TilesMixin",
|
|
49
58
|
"TILE_ACTIONS",
|
|
59
|
+
"WorkflowMixin",
|
|
60
|
+
"WorkflowQueueMixin",
|
|
61
|
+
"WORKFLOW_ACTIONS",
|
|
62
|
+
"WORKFLOW_QUEUE_ACTIONS",
|
|
50
63
|
"action_names",
|
|
51
64
|
"ClientErrorReporting",
|
|
52
65
|
"AnonymousClientErrorReporting",
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""The actions behind the workflow screens (``velocity.workflow``).
|
|
2
|
+
|
|
3
|
+
A site adds :class:`WorkflowMixin` to its Data handler and lists the actions
|
|
4
|
+
in ``client_actions`` (``client_actions = MINE | WORKFLOW_ACTIONS``)::
|
|
5
|
+
|
|
6
|
+
workflow-tray {view, station?, cursor?, limit?} -> {counts, rows, cursor?, as_of}
|
|
7
|
+
workflow-docket {docket} -> Docket
|
|
8
|
+
workflow-act {docket, action, inputs?, comment?, request_key}
|
|
9
|
+
-> {ok, docket} | 4xx {ok: false, reasons}
|
|
10
|
+
workflow-flag {docket, note?} | {docket, clear} -> {ok, docket}
|
|
11
|
+
workflow-claim {docket, position, release?} -> {ok, docket}
|
|
12
|
+
workflow-notices {unread_only?, cursor?, limit?} -> {unread, rows, cursor?}
|
|
13
|
+
workflow-notice-ack {notice} | {all: true} -> {ok, unread}
|
|
14
|
+
|
|
15
|
+
and :class:`WorkflowQueueMixin` to its queue handler (never reachable over
|
|
16
|
+
HTTP): ``workflow-program`` (a program station's job), ``workflow-drain``
|
|
17
|
+
(the outbox) and ``workflow-sweep`` (the timers).
|
|
18
|
+
|
|
19
|
+
**Authorization.** Every action is :func:`velocity.aws.authz.require` on a
|
|
20
|
+
named action (:mod:`velocity.workflow.access`) with the docket as a resource
|
|
21
|
+
whose relations come from the person's seats — the site's
|
|
22
|
+
``workflow_seats`` (default: :func:`velocity.workflow.register_seats`'s
|
|
23
|
+
hook), never from the request. An act or claim allowed only by
|
|
24
|
+
``workflow.oversee`` or ``workflow.act_any`` is a privileged use: authz
|
|
25
|
+
records it before it happens, and the engine then acts with ``override``.
|
|
26
|
+
|
|
27
|
+
**Refusals** are ``{ok: false, reasons: [Reason]}`` with HTTP 403 (not
|
|
28
|
+
yours) or 409 (the docket's state or a guard said no), or 400 for a
|
|
29
|
+
malformed request. A docket the person may not see is refused exactly like
|
|
30
|
+
one that does not exist.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import dataclasses
|
|
36
|
+
import logging
|
|
37
|
+
|
|
38
|
+
from velocity.aws import authz
|
|
39
|
+
from velocity.aws.handlers.exceptions import AccessError
|
|
40
|
+
from .action_names import action_names
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
FORBIDDEN = 403
|
|
45
|
+
CONFLICT = 409
|
|
46
|
+
BAD_REQUEST = 400
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _workflow():
|
|
50
|
+
# imported lazily: velocity.workflow pulls in the rules package
|
|
51
|
+
from velocity import workflow
|
|
52
|
+
|
|
53
|
+
return workflow
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _Refusal(Exception):
|
|
57
|
+
def __init__(self, status, reasons):
|
|
58
|
+
super().__init__(status)
|
|
59
|
+
self.status = status
|
|
60
|
+
self.reasons = reasons
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _reasons_status(reasons) -> int:
|
|
64
|
+
codes = {r.get("code") for r in reasons or ()}
|
|
65
|
+
return (
|
|
66
|
+
FORBIDDEN if codes & {"not_available", "not_yours", "forbidden"} else CONFLICT
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class WorkflowMixin:
|
|
71
|
+
"""See the module docstring."""
|
|
72
|
+
|
|
73
|
+
workflow_tray_limit = 50
|
|
74
|
+
|
|
75
|
+
# ── seams ──────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
def workflow_seats(self, tx, context, principal):
|
|
78
|
+
"""Where the signed-in person sits (override, or register a seats
|
|
79
|
+
hook). Seats without a ``person`` get the user row id."""
|
|
80
|
+
return _workflow().hooks.seats_for(tx, principal)
|
|
81
|
+
|
|
82
|
+
# ── helpers ────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
def _wf_principal(self, context):
|
|
85
|
+
principal = authz.principal_for(self, context)
|
|
86
|
+
if principal.user_sys_id is None:
|
|
87
|
+
raise AccessError(authz.REFUSED)
|
|
88
|
+
return principal
|
|
89
|
+
|
|
90
|
+
def _wf_seats(self, tx, context, principal):
|
|
91
|
+
seats = self.workflow_seats(tx, context, principal)
|
|
92
|
+
wf = _workflow()
|
|
93
|
+
if seats is None:
|
|
94
|
+
seats = wf.NO_SEATS
|
|
95
|
+
if not isinstance(seats, wf.Seats):
|
|
96
|
+
raise AccessError(authz.REFUSED)
|
|
97
|
+
if not seats.person:
|
|
98
|
+
seats = dataclasses.replace(seats, person=str(principal.user_sys_id))
|
|
99
|
+
return seats
|
|
100
|
+
|
|
101
|
+
def _wf_docket(self, tx, payload, key="docket"):
|
|
102
|
+
wf = _workflow()
|
|
103
|
+
try:
|
|
104
|
+
docket = wf.engine.docket_row(tx, (payload or {}).get(key))
|
|
105
|
+
except wf.WorkflowError:
|
|
106
|
+
# never say whether a docket the person can't see exists
|
|
107
|
+
raise AccessError(authz.REFUSED) from None
|
|
108
|
+
route = wf.engine._Ctx(tx).route(docket["route_id"])
|
|
109
|
+
return docket, route
|
|
110
|
+
|
|
111
|
+
def _wf_require(self, tx, context, action, docket, relations):
|
|
112
|
+
"""``authz.require`` on a docket-scoped action. Every refusal says the
|
|
113
|
+
same sentence (``authz.REFUSED``): not which permission was missing,
|
|
114
|
+
and not whether the docket exists."""
|
|
115
|
+
wf = _workflow()
|
|
116
|
+
try:
|
|
117
|
+
return authz.require(
|
|
118
|
+
self, tx, context, action, wf.access.resource(docket, relations)
|
|
119
|
+
)
|
|
120
|
+
except AccessError:
|
|
121
|
+
raise AccessError(authz.REFUSED) from None
|
|
122
|
+
|
|
123
|
+
def _wf_masquerading(self, principal) -> bool:
|
|
124
|
+
return principal.real_user is not None
|
|
125
|
+
|
|
126
|
+
def _wf_decider(self, context):
|
|
127
|
+
"""The viewer's ``may_view``: the site's authz decision on
|
|
128
|
+
``workflow.docket.view`` for one docket (a forbid rule hides it from
|
|
129
|
+
trays and links too). A privileged permit (``view_any``) is not
|
|
130
|
+
used here: listing is never a recorded use."""
|
|
131
|
+
wf = _workflow()
|
|
132
|
+
|
|
133
|
+
def decide(docket, relations):
|
|
134
|
+
decision = authz.decide(
|
|
135
|
+
self, wf.access.VIEW, wf.access.resource(docket, relations), context
|
|
136
|
+
)
|
|
137
|
+
return decision.allowed and not decision.audit
|
|
138
|
+
|
|
139
|
+
return decide
|
|
140
|
+
|
|
141
|
+
def _wf_allows(self, tx, context, *, record=True):
|
|
142
|
+
"""The actor's write decisions: ``authz.require`` on the named action
|
|
143
|
+
(``workflow.sheet.write`` / ``workflow.hold.release``) with the
|
|
144
|
+
docket's entity scope and the permission it needs, so scoped grants,
|
|
145
|
+
denials, forbids (and masquerade) apply. A refusal answers False."""
|
|
146
|
+
wf = _workflow()
|
|
147
|
+
|
|
148
|
+
def allows(action, permission, docket):
|
|
149
|
+
if not record:
|
|
150
|
+
# for display only (the action list): never a recorded use
|
|
151
|
+
decision = authz.decide(
|
|
152
|
+
self,
|
|
153
|
+
action,
|
|
154
|
+
wf.access.resource(docket, (), permission=permission),
|
|
155
|
+
context,
|
|
156
|
+
)
|
|
157
|
+
return decision.allowed and not decision.audit
|
|
158
|
+
try:
|
|
159
|
+
authz.require(
|
|
160
|
+
self,
|
|
161
|
+
tx,
|
|
162
|
+
context,
|
|
163
|
+
action,
|
|
164
|
+
wf.access.resource(docket, (), permission=permission),
|
|
165
|
+
)
|
|
166
|
+
except AccessError:
|
|
167
|
+
return False
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
return allows
|
|
171
|
+
|
|
172
|
+
def _wf_actor(self, seats, decision, principal, tx=None, context=None):
|
|
173
|
+
"""The actor a decision allows: ``override`` for a route owner or
|
|
174
|
+
``workflow.act_any`` (both recorded by authz), ``cross_entity`` only
|
|
175
|
+
for ``workflow.act_any``."""
|
|
176
|
+
wf = _workflow()
|
|
177
|
+
return wf.engine.Actor.person(
|
|
178
|
+
seats,
|
|
179
|
+
override=decision.rule in wf.access.OVERRIDE_RULES,
|
|
180
|
+
cross_entity=decision.rule == "workflow.act_any",
|
|
181
|
+
allows=self._wf_allows(tx, context) if tx is not None else None,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def _wf_viewer(self, tx, context, docket, route, principal, seats):
|
|
185
|
+
"""Require ``workflow.docket.view`` and build the viewer; whether an
|
|
186
|
+
override would let them act is asked (not recorded) for the action
|
|
187
|
+
list."""
|
|
188
|
+
wf = _workflow()
|
|
189
|
+
relations = wf.access.view_relations(tx, docket, route, seats)
|
|
190
|
+
self._wf_require(tx, context, wf.access.VIEW, docket, relations)
|
|
191
|
+
probe = authz.decide(
|
|
192
|
+
self,
|
|
193
|
+
wf.access.ACT,
|
|
194
|
+
wf.access.resource(docket, relations & {wf.access.OVERSEES}),
|
|
195
|
+
context,
|
|
196
|
+
)
|
|
197
|
+
override = probe.allowed and probe.rule in wf.access.OVERRIDE_RULES
|
|
198
|
+
return wf.contract.Viewer(
|
|
199
|
+
seats=seats,
|
|
200
|
+
principal=principal,
|
|
201
|
+
act_override=override,
|
|
202
|
+
cross_entity=override and probe.rule == "workflow.act_any",
|
|
203
|
+
view_decider=self._wf_decider(context),
|
|
204
|
+
allows=self._wf_allows(tx, context, record=False),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def _wf_refuse(self, context, status, reasons):
|
|
208
|
+
context.response().set_status(status)
|
|
209
|
+
context.response().set_body({"ok": False, "reasons": list(reasons)})
|
|
210
|
+
|
|
211
|
+
def _wf_after_change(self, tx, context, docket_id):
|
|
212
|
+
"""Ask for the outbox to be drained once this transaction commits."""
|
|
213
|
+
wf = _workflow()
|
|
214
|
+
if not wf.outbox.has_pending(tx, docket_id):
|
|
215
|
+
return
|
|
216
|
+
try:
|
|
217
|
+
context.enqueue(wf.outbox.DRAIN_ACTION, {}, suppress_job_activity=True)
|
|
218
|
+
except Exception:
|
|
219
|
+
# the scheduled drain picks it up within a minute
|
|
220
|
+
logger.warning("workflow drain enqueue failed", exc_info=True)
|
|
221
|
+
|
|
222
|
+
def _wf_guarded(self, tx, context, work):
|
|
223
|
+
"""Run ``work`` in a savepoint; a refusal rolls back everything it
|
|
224
|
+
wrote and answers ``{ok: false, reasons}`` with its status."""
|
|
225
|
+
wf = _workflow()
|
|
226
|
+
try:
|
|
227
|
+
with tx.savepoint():
|
|
228
|
+
return work()
|
|
229
|
+
except AccessError:
|
|
230
|
+
self._wf_refuse(
|
|
231
|
+
context, FORBIDDEN, [{"code": "forbidden", "text": authz.REFUSED}]
|
|
232
|
+
)
|
|
233
|
+
except _Refusal as exc:
|
|
234
|
+
self._wf_refuse(context, exc.status, exc.reasons)
|
|
235
|
+
except wf.WorkflowRefused as exc:
|
|
236
|
+
status = exc.status if exc.status in (400, 403, 409) else CONFLICT
|
|
237
|
+
self._wf_refuse(context, status, exc.reasons)
|
|
238
|
+
except wf.WorkflowError as exc:
|
|
239
|
+
self._wf_refuse(
|
|
240
|
+
context, BAD_REQUEST, [{"code": "invalid", "text": str(exc)}]
|
|
241
|
+
)
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
def _wf_docket_body(self, tx, context, docket_id, principal, seats):
|
|
245
|
+
wf = _workflow()
|
|
246
|
+
docket, route = self._wf_docket(tx, {"docket": docket_id})
|
|
247
|
+
viewer = self._wf_viewer(tx, context, docket, route, principal, seats)
|
|
248
|
+
# under masquerade nothing is written, not even "seen"
|
|
249
|
+
return wf.contract.build_docket(
|
|
250
|
+
tx,
|
|
251
|
+
docket["sys_id"],
|
|
252
|
+
viewer,
|
|
253
|
+
record_seen=not self._wf_masquerading(principal),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# ── reading ────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
def OnActionWorkflowTray(self, tx, context):
|
|
259
|
+
wf = _workflow()
|
|
260
|
+
payload = context.payload() or {}
|
|
261
|
+
principal = self._wf_principal(context)
|
|
262
|
+
authz.require(self, tx, context, wf.access.TRAY)
|
|
263
|
+
seats = self._wf_seats(tx, context, principal)
|
|
264
|
+
viewer = wf.contract.Viewer(
|
|
265
|
+
seats=seats, principal=principal, view_decider=self._wf_decider(context)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def work():
|
|
269
|
+
body = wf.contract.tray(
|
|
270
|
+
tx,
|
|
271
|
+
viewer,
|
|
272
|
+
payload.get("view") or "mine",
|
|
273
|
+
station=payload.get("station"),
|
|
274
|
+
cursor=payload.get("cursor"),
|
|
275
|
+
limit=payload.get("limit") or self.workflow_tray_limit,
|
|
276
|
+
counts=payload.get("counts") is not False,
|
|
277
|
+
)
|
|
278
|
+
context.response().set_body(body)
|
|
279
|
+
|
|
280
|
+
self._wf_guarded(tx, context, work)
|
|
281
|
+
|
|
282
|
+
def OnActionWorkflowDocket(self, tx, context):
|
|
283
|
+
payload = context.payload() or {}
|
|
284
|
+
principal = self._wf_principal(context)
|
|
285
|
+
seats = self._wf_seats(tx, context, principal)
|
|
286
|
+
body = self._wf_docket_body(
|
|
287
|
+
tx, context, payload.get("docket"), principal, seats
|
|
288
|
+
)
|
|
289
|
+
context.response().set_body(body)
|
|
290
|
+
|
|
291
|
+
# ── acting ─────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
def OnActionWorkflowAct(self, tx, context):
|
|
294
|
+
wf = _workflow()
|
|
295
|
+
payload = context.payload() or {}
|
|
296
|
+
principal = self._wf_principal(context)
|
|
297
|
+
seats = self._wf_seats(tx, context, principal)
|
|
298
|
+
|
|
299
|
+
def work():
|
|
300
|
+
action = payload.get("action")
|
|
301
|
+
# a malformed action id is a bad request, before anything is
|
|
302
|
+
# looked up (it names no target to authorize)
|
|
303
|
+
wf.engine.parse_action(action)
|
|
304
|
+
docket, route = self._wf_docket(tx, payload)
|
|
305
|
+
request_key = payload.get("request_key")
|
|
306
|
+
if isinstance(request_key, str) and wf.engine.already_acted(
|
|
307
|
+
tx, docket["sys_id"], wf.engine.Actor.person(seats), request_key
|
|
308
|
+
):
|
|
309
|
+
# a retry of this person's own request that succeeded: answer
|
|
310
|
+
# with the docket as it is (seeing it is still checked)
|
|
311
|
+
body = self._wf_docket_body(
|
|
312
|
+
tx, context, docket["sys_id"], principal, seats
|
|
313
|
+
)
|
|
314
|
+
context.response().set_body({"ok": True, "docket": body})
|
|
315
|
+
return
|
|
316
|
+
relations = wf.access.act_relations(tx, docket, route, seats, action)
|
|
317
|
+
decision = self._wf_require(tx, context, wf.access.ACT, docket, relations)
|
|
318
|
+
# the rest of the payload is checked only once the act is allowed
|
|
319
|
+
if not isinstance(request_key, str) or not request_key.strip():
|
|
320
|
+
raise _Refusal(
|
|
321
|
+
BAD_REQUEST,
|
|
322
|
+
[{"code": "request_key", "text": "A request key is required."}],
|
|
323
|
+
)
|
|
324
|
+
inputs = payload.get("inputs")
|
|
325
|
+
if inputs is not None and not isinstance(inputs, dict):
|
|
326
|
+
raise _Refusal(
|
|
327
|
+
BAD_REQUEST,
|
|
328
|
+
[{"code": "inputs", "text": "inputs must be an object."}],
|
|
329
|
+
)
|
|
330
|
+
comment = payload.get("comment")
|
|
331
|
+
if comment is not None and not isinstance(comment, str):
|
|
332
|
+
raise _Refusal(
|
|
333
|
+
BAD_REQUEST, [{"code": "comment", "text": "comment must be text."}]
|
|
334
|
+
)
|
|
335
|
+
result = wf.engine.act(
|
|
336
|
+
tx,
|
|
337
|
+
docket["sys_id"],
|
|
338
|
+
action,
|
|
339
|
+
inputs,
|
|
340
|
+
self._wf_actor(seats, decision, principal, tx, context),
|
|
341
|
+
request_key,
|
|
342
|
+
comment=comment,
|
|
343
|
+
)
|
|
344
|
+
if not result.ok:
|
|
345
|
+
raise _Refusal(_reasons_status(result.reasons), result.reasons)
|
|
346
|
+
self._wf_after_change(tx, context, docket["sys_id"])
|
|
347
|
+
body = self._wf_docket_body(tx, context, docket["sys_id"], principal, seats)
|
|
348
|
+
context.response().set_body({"ok": True, "docket": body})
|
|
349
|
+
|
|
350
|
+
self._wf_guarded(tx, context, work)
|
|
351
|
+
|
|
352
|
+
def OnActionWorkflowClaim(self, tx, context):
|
|
353
|
+
wf = _workflow()
|
|
354
|
+
payload = context.payload() or {}
|
|
355
|
+
principal = self._wf_principal(context)
|
|
356
|
+
seats = self._wf_seats(tx, context, principal)
|
|
357
|
+
|
|
358
|
+
def work():
|
|
359
|
+
docket, route = self._wf_docket(tx, payload)
|
|
360
|
+
try:
|
|
361
|
+
position = wf.engine._position(tx, payload.get("position"))
|
|
362
|
+
except wf.WorkflowError:
|
|
363
|
+
raise AccessError(authz.REFUSED) from None
|
|
364
|
+
if position["docket_id"] != docket["sys_id"]:
|
|
365
|
+
raise AccessError(authz.REFUSED)
|
|
366
|
+
relations = wf.access.act_relations(
|
|
367
|
+
tx, docket, route, seats, position=position
|
|
368
|
+
)
|
|
369
|
+
decision = self._wf_require(tx, context, wf.access.CLAIM, docket, relations)
|
|
370
|
+
wf.engine.claim(
|
|
371
|
+
tx,
|
|
372
|
+
docket["sys_id"],
|
|
373
|
+
position["sys_id"],
|
|
374
|
+
self._wf_actor(seats, decision, principal, tx, context),
|
|
375
|
+
release=payload.get("release") is True,
|
|
376
|
+
)
|
|
377
|
+
self._wf_after_change(tx, context, docket["sys_id"])
|
|
378
|
+
body = self._wf_docket_body(tx, context, docket["sys_id"], principal, seats)
|
|
379
|
+
context.response().set_body({"ok": True, "docket": body})
|
|
380
|
+
|
|
381
|
+
self._wf_guarded(tx, context, work)
|
|
382
|
+
|
|
383
|
+
def OnActionWorkflowFlag(self, tx, context):
|
|
384
|
+
wf = _workflow()
|
|
385
|
+
payload = context.payload() or {}
|
|
386
|
+
principal = self._wf_principal(context)
|
|
387
|
+
seats = self._wf_seats(tx, context, principal)
|
|
388
|
+
|
|
389
|
+
def work():
|
|
390
|
+
docket, route = self._wf_docket(tx, payload)
|
|
391
|
+
relations = wf.access.view_relations(tx, docket, route, seats)
|
|
392
|
+
self._wf_require(tx, context, wf.access.FLAG, docket, relations)
|
|
393
|
+
actor = wf.engine.Actor.person(seats)
|
|
394
|
+
if payload.get("clear") is not None:
|
|
395
|
+
wf.engine.clear_flag(tx, docket["sys_id"], payload.get("clear"), actor)
|
|
396
|
+
else:
|
|
397
|
+
note = payload.get("note")
|
|
398
|
+
if note is not None and not isinstance(note, str):
|
|
399
|
+
raise _Refusal(
|
|
400
|
+
BAD_REQUEST, [{"code": "note", "text": "note must be text."}]
|
|
401
|
+
)
|
|
402
|
+
wf.engine.flag(tx, docket["sys_id"], actor, note)
|
|
403
|
+
body = self._wf_docket_body(tx, context, docket["sys_id"], principal, seats)
|
|
404
|
+
context.response().set_body({"ok": True, "docket": body})
|
|
405
|
+
|
|
406
|
+
self._wf_guarded(tx, context, work)
|
|
407
|
+
|
|
408
|
+
# ── notices ────────────────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
def OnActionWorkflowNotices(self, tx, context):
|
|
411
|
+
wf = _workflow()
|
|
412
|
+
payload = context.payload() or {}
|
|
413
|
+
principal = self._wf_principal(context)
|
|
414
|
+
authz.require(self, tx, context, wf.access.NOTICES)
|
|
415
|
+
seats = self._wf_seats(tx, context, principal)
|
|
416
|
+
|
|
417
|
+
def work():
|
|
418
|
+
body = wf.notices.list_for(
|
|
419
|
+
tx,
|
|
420
|
+
seats.person,
|
|
421
|
+
unread_only=bool(payload.get("unread_only")),
|
|
422
|
+
cursor=payload.get("cursor"),
|
|
423
|
+
limit=payload.get("limit") or 50,
|
|
424
|
+
)
|
|
425
|
+
context.response().set_body(body)
|
|
426
|
+
|
|
427
|
+
self._wf_guarded(tx, context, work)
|
|
428
|
+
|
|
429
|
+
def OnActionWorkflowNoticeAck(self, tx, context):
|
|
430
|
+
wf = _workflow()
|
|
431
|
+
payload = context.payload() or {}
|
|
432
|
+
principal = self._wf_principal(context)
|
|
433
|
+
authz.require(self, tx, context, wf.access.NOTICE_ACK)
|
|
434
|
+
seats = self._wf_seats(tx, context, principal)
|
|
435
|
+
|
|
436
|
+
def work():
|
|
437
|
+
unread = wf.notices.ack(
|
|
438
|
+
tx,
|
|
439
|
+
seats.person,
|
|
440
|
+
notice_id=payload.get("notice"),
|
|
441
|
+
all=payload.get("all") is True,
|
|
442
|
+
)
|
|
443
|
+
context.response().set_body({"ok": True, "unread": unread})
|
|
444
|
+
|
|
445
|
+
self._wf_guarded(tx, context, work)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class WorkflowQueueMixin:
|
|
449
|
+
"""Queue-handler actions: program jobs, the outbox drain, the timers.
|
|
450
|
+
Refused over HTTP (queue records and scheduled ticks only)."""
|
|
451
|
+
|
|
452
|
+
def OnActionWorkflowProgram(self, tx, context):
|
|
453
|
+
from velocity.aws.dirty_pipeline import refuse_http
|
|
454
|
+
|
|
455
|
+
refuse_http(self, "workflow-program")
|
|
456
|
+
wf = _workflow()
|
|
457
|
+
payload = context.payload() or {}
|
|
458
|
+
result = wf.engine.run_program(
|
|
459
|
+
tx, payload.get("position"), payload.get("attempt")
|
|
460
|
+
)
|
|
461
|
+
drained = wf.outbox.drain(tx, context)
|
|
462
|
+
context.response().set_body({"result": result, "drained": drained})
|
|
463
|
+
|
|
464
|
+
def OnActionWorkflowDrain(self, tx, context):
|
|
465
|
+
from velocity.aws.dirty_pipeline import refuse_http
|
|
466
|
+
|
|
467
|
+
refuse_http(self, "workflow-drain")
|
|
468
|
+
context.response().set_body({"drained": _workflow().outbox.drain(tx, context)})
|
|
469
|
+
|
|
470
|
+
def OnActionWorkflowSweep(self, tx, context):
|
|
471
|
+
from velocity.aws.dirty_pipeline import refuse_http
|
|
472
|
+
|
|
473
|
+
refuse_http(self, "workflow-sweep")
|
|
474
|
+
wf = _workflow()
|
|
475
|
+
swept = wf.engine.sweep(tx)
|
|
476
|
+
context.response().set_body(
|
|
477
|
+
{"swept": swept, "drained": wf.outbox.drain(tx, context)}
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# every client action WorkflowMixin serves: ``client_actions = MINE | WORKFLOW_ACTIONS``
|
|
482
|
+
WORKFLOW_ACTIONS = action_names(WorkflowMixin)
|
|
483
|
+
# the queue handler's (never list these in client_actions)
|
|
484
|
+
WORKFLOW_QUEUE_ACTIONS = action_names(WorkflowQueueMixin)
|
|
485
|
+
|
|
486
|
+
__all__ = [
|
|
487
|
+
"WORKFLOW_ACTIONS",
|
|
488
|
+
"WORKFLOW_QUEUE_ACTIONS",
|
|
489
|
+
"WorkflowMixin",
|
|
490
|
+
"WorkflowQueueMixin",
|
|
491
|
+
]
|
velocity/aws/schema.py
CHANGED
|
@@ -29,6 +29,9 @@ Groups:
|
|
|
29
29
|
* ``tiles`` — tiles and who sees them (:mod:`velocity.aws.tiles`).
|
|
30
30
|
* ``scheduler`` — ``sys_schedules`` (:mod:`velocity.aws.scheduler`).
|
|
31
31
|
* ``rules`` — rules, rule tests and the decision log (:mod:`velocity.rules.storage`).
|
|
32
|
+
* ``workflow`` — routes, dockets, marks, notices (:mod:`velocity.workflow.schema`);
|
|
33
|
+
run :func:`velocity.workflow.ensure_schema` too for the append-only marks
|
|
34
|
+
and the possession constraint.
|
|
32
35
|
|
|
33
36
|
Each module's ``SCHEMA`` maps a table name to ``Table.ensure_schema`` keyword
|
|
34
37
|
arguments (``columns`` plus optional ``indexes`` / ``unique_indexes``). Two
|
|
@@ -67,6 +70,8 @@ SCHEMA_SOURCES: Dict[str, Tuple[str, ...]] = {
|
|
|
67
70
|
"scheduler": ("velocity.aws.scheduler",),
|
|
68
71
|
# data-driven rules (velocity.rules); opt-in, for apps that evaluate them
|
|
69
72
|
"rules": ("velocity.rules.storage",),
|
|
73
|
+
# dockets on routes (velocity.workflow); opt-in, for apps that use them
|
|
74
|
+
"workflow": ("velocity.workflow.schema",),
|
|
70
75
|
"payment": ("velocity.payment.schema",),
|
|
71
76
|
}
|
|
72
77
|
GROUPS = tuple(SCHEMA_SOURCES)
|
velocity/rules/__init__.py
CHANGED
|
@@ -37,6 +37,7 @@ from .engine import ( # noqa: F401
|
|
|
37
37
|
evaluate,
|
|
38
38
|
run_rule_tests,
|
|
39
39
|
set_status,
|
|
40
|
+
valid_rule_id,
|
|
40
41
|
validate_rules,
|
|
41
42
|
)
|
|
42
43
|
from .expressions import ExpressionError # noqa: F401
|
|
@@ -107,5 +108,6 @@ __all__ = [
|
|
|
107
108
|
"run_rule_tests",
|
|
108
109
|
"save_rule",
|
|
109
110
|
"set_status",
|
|
111
|
+
"valid_rule_id",
|
|
110
112
|
"validate_rules",
|
|
111
113
|
]
|
velocity/rules/engine.py
CHANGED
|
@@ -120,6 +120,13 @@ DEFAULT_PRIORITY = 100
|
|
|
120
120
|
DIGEST_DOMAIN = "velocity.rules.tested/v1\n"
|
|
121
121
|
|
|
122
122
|
|
|
123
|
+
def valid_rule_id(value) -> bool:
|
|
124
|
+
"""Whether ``value`` is a rule id the engine accepts (a non-empty
|
|
125
|
+
string: ``"credit_limit"``, ``"co.excise_due"``). The one definition other
|
|
126
|
+
modules (velocity.workflow's route rows) validate rule references with."""
|
|
127
|
+
return isinstance(value, str) and bool(value)
|
|
128
|
+
|
|
129
|
+
|
|
123
130
|
class RuleTestError(RuleError):
|
|
124
131
|
"""A rule cannot change status because its tests fail."""
|
|
125
132
|
|
|
@@ -485,7 +492,7 @@ class CompiledRule:
|
|
|
485
492
|
self.point = point
|
|
486
493
|
if rule.point != point.name:
|
|
487
494
|
raise RuleError(f"{label} is for {rule.point!r}, not {point.name!r}")
|
|
488
|
-
if not
|
|
495
|
+
if not valid_rule_id(rule.id):
|
|
489
496
|
raise RuleError("a rule needs an id")
|
|
490
497
|
point.layer_rank(rule.layer)
|
|
491
498
|
if rule.status not in STATUSES:
|