django-maker-checker 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.
- django_maker_checker/__init__.py +3 -0
- django_maker_checker/actions.py +171 -0
- django_maker_checker/adapter.py +115 -0
- django_maker_checker/admin.py +86 -0
- django_maker_checker/apps.py +14 -0
- django_maker_checker/audit.py +17 -0
- django_maker_checker/conf.py +54 -0
- django_maker_checker/constants.py +126 -0
- django_maker_checker/exceptions.py +21 -0
- django_maker_checker/management/__init__.py +0 -0
- django_maker_checker/management/commands/__init__.py +0 -0
- django_maker_checker/management/commands/process_approval_slas.py +20 -0
- django_maker_checker/migrations/0001_initial.py +193 -0
- django_maker_checker/migrations/0002_policy_revisions.py +44 -0
- django_maker_checker/migrations/__init__.py +0 -0
- django_maker_checker/model_change.py +127 -0
- django_maker_checker/models.py +424 -0
- django_maker_checker/notify.py +179 -0
- django_maker_checker/permissions.py +193 -0
- django_maker_checker/policies.py +333 -0
- django_maker_checker/py.typed +0 -0
- django_maker_checker/queries.py +371 -0
- django_maker_checker/registry.py +167 -0
- django_maker_checker/services.py +681 -0
- django_maker_checker/tasks.py +22 -0
- django_maker_checker-0.4.0.dist-info/METADATA +521 -0
- django_maker_checker-0.4.0.dist-info/RECORD +30 -0
- django_maker_checker-0.4.0.dist-info/WHEEL +5 -0
- django_maker_checker-0.4.0.dist-info/licenses/LICENSE +21 -0
- django_maker_checker-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""``@approval_action``: put a service function behind maker-checker without writing a handler class.
|
|
2
|
+
|
|
3
|
+
This is the Command pattern. Calling the decorated function captures the call (its keyword arguments, made
|
|
4
|
+
JSON-safe) as an Approval Request when a policy applies, and runs the function only once the request is approved.
|
|
5
|
+
With no policy the function runs immediately, so the call site is the same either way.
|
|
6
|
+
|
|
7
|
+
The decorated function must take keyword-only arguments: ``tenant``, ``maker`` and ``context`` are supplied by the
|
|
8
|
+
framework; everything else is the payload and must survive a JSON round trip (``Decimal``/dates arrive as strings).
|
|
9
|
+
|
|
10
|
+
.. code-block:: python
|
|
11
|
+
|
|
12
|
+
@approval_action(
|
|
13
|
+
"balance_update",
|
|
14
|
+
label="Balance Update",
|
|
15
|
+
category="Balances",
|
|
16
|
+
lock_key=lambda p: f"{p['account_id']}:{p['balance_date']}",
|
|
17
|
+
target_label=lambda p: f"Account {p['account_id']} on {p['balance_date']}",
|
|
18
|
+
)
|
|
19
|
+
def update_balance(*, tenant, maker, context, account_id, balance_date, new_balance):
|
|
20
|
+
...
|
|
21
|
+
return {"account_id": account_id}
|
|
22
|
+
|
|
23
|
+
outcome = update_balance(tenant=tenant, maker=request.user, request=request,
|
|
24
|
+
justification="Bank restated the balance",
|
|
25
|
+
account_id=1, balance_date="2026-09-30", new_balance="100.00")
|
|
26
|
+
if outcome.pending:
|
|
27
|
+
... # outcome.approval_request.reference
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from functools import wraps
|
|
33
|
+
from typing import Any, Callable, Optional
|
|
34
|
+
|
|
35
|
+
from django.core.serializers.json import DjangoJSONEncoder
|
|
36
|
+
|
|
37
|
+
from django_maker_checker import services
|
|
38
|
+
from django_maker_checker.registry import ActionHandler, ExecutionContext, Scope, diff_rows, register_handler
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Outcome:
|
|
43
|
+
"""Result of calling an approval action: either pending approval or already applied."""
|
|
44
|
+
|
|
45
|
+
approval_request: Optional[Any] = None
|
|
46
|
+
result: Any = None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def pending(self):
|
|
50
|
+
return self.approval_request is not None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def to_payload(values: dict) -> dict:
|
|
54
|
+
"""The JSON form a payload is stored in; raises TypeError naming the offending value.
|
|
55
|
+
|
|
56
|
+
Payloads go through this round trip on both paths (approved later, or applied immediately), so the action's
|
|
57
|
+
function always receives the same JSON values (``Decimal`` and dates as strings) whether or not approval was
|
|
58
|
+
required. That keeps behaviour identical when a tenant turns a rule on or off.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
return json.loads(json.dumps(values, cls=DjangoJSONEncoder))
|
|
62
|
+
except TypeError as err:
|
|
63
|
+
raise TypeError(f"Maker-checker payloads must be JSON-serialisable: {err}") from err
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class FunctionHandler(ActionHandler):
|
|
67
|
+
"""ActionHandler whose behaviour comes from a function plus optional hook callables.
|
|
68
|
+
|
|
69
|
+
Each hook argument of ``@approval_action`` replaces the matching ``ActionHandler`` method; omitted hooks keep the
|
|
70
|
+
neutral defaults (no label, empty snapshot, no scopes, every change matches, nothing to re-validate).
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
func,
|
|
76
|
+
*,
|
|
77
|
+
action_type,
|
|
78
|
+
label,
|
|
79
|
+
category,
|
|
80
|
+
lock_key,
|
|
81
|
+
description="",
|
|
82
|
+
icon="shield",
|
|
83
|
+
sub_options=(),
|
|
84
|
+
scopes=(Scope.GLOBAL,),
|
|
85
|
+
review_template="approvals/review/generic.html",
|
|
86
|
+
target_label=None,
|
|
87
|
+
snapshot_before=None,
|
|
88
|
+
scope_keys=None,
|
|
89
|
+
matches=None,
|
|
90
|
+
validate=None,
|
|
91
|
+
):
|
|
92
|
+
self.func = func
|
|
93
|
+
self.action_type = action_type
|
|
94
|
+
self.label = label
|
|
95
|
+
self.category = category
|
|
96
|
+
self.description = description
|
|
97
|
+
self.icon = icon
|
|
98
|
+
self.sub_options = tuple(sub_options)
|
|
99
|
+
self.scopes = tuple(scopes)
|
|
100
|
+
self.review_template = review_template
|
|
101
|
+
self._lock_key = lock_key
|
|
102
|
+
self._target_label = target_label
|
|
103
|
+
self._snapshot_before = snapshot_before
|
|
104
|
+
self._scope_keys = scope_keys
|
|
105
|
+
self._matches = matches
|
|
106
|
+
self._validate = validate
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def origin(self):
|
|
110
|
+
# All decorated functions share this class, so the function (not the class) identifies the definition.
|
|
111
|
+
return f"{self.func.__module__}.{self.func.__qualname__}"
|
|
112
|
+
|
|
113
|
+
def lock_key(self, payload):
|
|
114
|
+
return str(self._lock_key(payload))
|
|
115
|
+
|
|
116
|
+
def target_label(self, payload):
|
|
117
|
+
return str(self._target_label(payload)) if self._target_label else ""
|
|
118
|
+
|
|
119
|
+
def snapshot_before(self, tenant, payload):
|
|
120
|
+
return to_payload(self._snapshot_before(tenant, payload)) if self._snapshot_before else {}
|
|
121
|
+
|
|
122
|
+
def scope_keys(self, tenant, payload):
|
|
123
|
+
return list(self._scope_keys(tenant, payload)) if self._scope_keys else []
|
|
124
|
+
|
|
125
|
+
def matches(self, policy, payload):
|
|
126
|
+
return self._matches(policy, payload) if self._matches else True
|
|
127
|
+
|
|
128
|
+
def validate(self, approval_request):
|
|
129
|
+
if self._validate:
|
|
130
|
+
self._validate(approval_request)
|
|
131
|
+
|
|
132
|
+
def execute(self, approval_request, context: ExecutionContext):
|
|
133
|
+
"""Replay the captured call. ``maker`` is the person who asked for the change, so the change is attributed
|
|
134
|
+
to them; the final checker is ``context.actor``."""
|
|
135
|
+
return self.func(
|
|
136
|
+
tenant=approval_request.tenant,
|
|
137
|
+
maker=approval_request.maker,
|
|
138
|
+
context=context,
|
|
139
|
+
**approval_request.payload,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def review_context(self, approval_request):
|
|
143
|
+
return {"rows": diff_rows(approval_request.before_snapshot or {}, approval_request.payload)}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def approval_action(action_type: str, *, label: str, category: str, lock_key: Callable[[dict], str], **options):
|
|
147
|
+
"""Register ``func`` as a maker-checker action and return a callable that routes through approval."""
|
|
148
|
+
|
|
149
|
+
def decorator(func):
|
|
150
|
+
handler = register_handler(
|
|
151
|
+
FunctionHandler(func, action_type=action_type, label=label, category=category, lock_key=lock_key, **options)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
@wraps(func)
|
|
155
|
+
def submit(*, tenant, maker, justification="", request=None, **payload):
|
|
156
|
+
"""Call the action: returns an ``Outcome`` that is pending (a request was raised) or holds the result."""
|
|
157
|
+
approval_request, result = services.submit_or_execute(
|
|
158
|
+
action_type,
|
|
159
|
+
tenant=tenant,
|
|
160
|
+
maker=maker,
|
|
161
|
+
payload=to_payload(payload),
|
|
162
|
+
justification=justification,
|
|
163
|
+
request=request,
|
|
164
|
+
)
|
|
165
|
+
return Outcome(approval_request, result)
|
|
166
|
+
|
|
167
|
+
submit.handler = handler
|
|
168
|
+
submit.apply = func # the undecorated function, for callers that must bypass approval (tests, admin tools)
|
|
169
|
+
return submit
|
|
170
|
+
|
|
171
|
+
return decorator
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""The contract between the approvals engine and the host application.
|
|
2
|
+
|
|
3
|
+
The engine never imports the host. Everything it needs to know about tenancy, roles, permissions, scopes,
|
|
4
|
+
notifications and audit comes through one ``ApprovalsAdapter`` instance, named by the ``APPROVALS_ADAPTER``
|
|
5
|
+
setting. Subclass it in the host and implement every abstract method.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Iterable, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Notice:
|
|
15
|
+
"""One notification event, delivered by the host to ``recipients`` over its own channels."""
|
|
16
|
+
|
|
17
|
+
kind: str # requested | decided | escalated | cancelled | sla_warning
|
|
18
|
+
title: str
|
|
19
|
+
text: str
|
|
20
|
+
severity: str # info | warning | error
|
|
21
|
+
recipients: tuple
|
|
22
|
+
approval_request: Any
|
|
23
|
+
link: str = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class AuditRecord:
|
|
28
|
+
"""One audit-trail entry for the host's central audit log."""
|
|
29
|
+
|
|
30
|
+
action: str # created | updated
|
|
31
|
+
resource_type: str # ApprovalRequest | ApprovalDelegation
|
|
32
|
+
resource_id: str
|
|
33
|
+
tenant_id: Any
|
|
34
|
+
actor_id: Any = None
|
|
35
|
+
before: Optional[dict] = None
|
|
36
|
+
after: Optional[dict] = None
|
|
37
|
+
metadata: dict = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ApprovalsAdapter(ABC):
|
|
41
|
+
"""Host integration.
|
|
42
|
+
|
|
43
|
+
``tenant`` arguments are instances of ``APPROVALS_TENANT_MODEL`` and ``role`` arguments of ``APPROVALS_ROLE_MODEL``.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
# --- tenancy and roles ----------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def tenant_of(self, user) -> Optional[Any]:
|
|
50
|
+
"""The tenant ``user`` acts for, or None if they are not an active member of one."""
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def members(self, tenant, user_ids: Optional[Iterable] = None, role_ids: Optional[Iterable] = None):
|
|
54
|
+
"""Active users of ``tenant``. When ids are given, only users with one of ``user_ids`` or holding one of
|
|
55
|
+
the (active) roles in ``role_ids``. Returns a queryset of ``AUTH_USER_MODEL``."""
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def role_ids_of(self, user) -> set:
|
|
59
|
+
"""Ids of the active roles ``user`` holds."""
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def roles(self, tenant):
|
|
63
|
+
"""Active roles of ``tenant`` (queryset of ``APPROVALS_ROLE_MODEL``)."""
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def role_member_count(self, role) -> int:
|
|
67
|
+
"""Number of active members holding ``role`` (shown in approver pickers)."""
|
|
68
|
+
|
|
69
|
+
# --- permissions ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
def has_permission(self, user, codename: str) -> bool:
|
|
73
|
+
"""Whether ``user`` holds the approvals permission ``codename`` (e.g. ``approve_user_create``)."""
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
def role_has_permission(self, role, codename: str) -> bool:
|
|
77
|
+
"""Whether members of ``role`` get the approvals permission ``codename`` through it."""
|
|
78
|
+
|
|
79
|
+
# --- scopes --------------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def is_enabled(self, tenant) -> bool:
|
|
82
|
+
"""Whether maker-checker is switched on for ``tenant`` (e.g. a feature flag or a plan the host sells).
|
|
83
|
+
|
|
84
|
+
While it is off the tenant is treated as having no rules: guarded actions run immediately and the SLA job
|
|
85
|
+
leaves its requests alone. Rules, history and requests are kept, so switching it back on resumes where it
|
|
86
|
+
stopped. Requests still open when it is switched off stay open; cancel them first if they must not linger.
|
|
87
|
+
"""
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
def scope_label(self, tenant, scope_key: str) -> str:
|
|
91
|
+
"""Human name of a scope key returned by a handler's ``scope_keys`` (e.g. a department or account group)."""
|
|
92
|
+
return scope_key
|
|
93
|
+
|
|
94
|
+
# --- side effects --------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
@abstractmethod
|
|
97
|
+
def notify(self, notice: Notice) -> None:
|
|
98
|
+
"""Deliver ``notice`` (in-app, email, chat…). Called after the transaction commits."""
|
|
99
|
+
|
|
100
|
+
@abstractmethod
|
|
101
|
+
def audit(self, record: AuditRecord) -> None:
|
|
102
|
+
"""Write ``record`` to the host's audit log."""
|
|
103
|
+
|
|
104
|
+
def request_url(self, approval_request) -> str:
|
|
105
|
+
"""Absolute or site-relative link to the request in the host UI (used in notifications)."""
|
|
106
|
+
return ""
|
|
107
|
+
|
|
108
|
+
# --- presentation helpers ------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def user_label(self, user) -> str:
|
|
111
|
+
"""How a user is named in timelines, pickers and history; ``None`` means an automated (system) actor."""
|
|
112
|
+
return (user.get_full_name() or user.get_username()) if user else "System"
|
|
113
|
+
|
|
114
|
+
def role_label(self, role) -> str:
|
|
115
|
+
return str(role)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Django admin views of maker-checker data, for inspection by staff.
|
|
2
|
+
|
|
3
|
+
Every page here is read-only. Changes must go through ``services`` and ``policies``, which enforce the rules
|
|
4
|
+
(segregation of duties, approver eligibility) and record the audit trail (request events, policy revisions, host
|
|
5
|
+
audit records). An editable admin would let staff bypass both.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from django.contrib import admin
|
|
9
|
+
|
|
10
|
+
from django_maker_checker import models
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ReadOnlyAdmin(admin.ModelAdmin):
|
|
14
|
+
"""Shows every field and allows no add, change or delete."""
|
|
15
|
+
|
|
16
|
+
def get_readonly_fields(self, request, obj=None):
|
|
17
|
+
return [f.name for f in self.model._meta.fields]
|
|
18
|
+
|
|
19
|
+
def has_add_permission(self, request):
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
def has_change_permission(self, request, obj=None):
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
def has_delete_permission(self, request, obj=None):
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ReadOnlyInline(admin.TabularInline):
|
|
30
|
+
extra = 0
|
|
31
|
+
can_delete = False
|
|
32
|
+
|
|
33
|
+
def get_readonly_fields(self, request, obj=None):
|
|
34
|
+
return [f.name for f in self.model._meta.fields]
|
|
35
|
+
|
|
36
|
+
def has_add_permission(self, request, obj=None):
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PolicyApproverInline(ReadOnlyInline):
|
|
41
|
+
model = models.ApprovalPolicyApprover
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RevisionInline(ReadOnlyInline):
|
|
45
|
+
model = models.ApprovalPolicyRevision
|
|
46
|
+
fields = ("version", "change_type", "changes", "reason", "actor", "created")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@admin.register(models.ApprovalPolicy)
|
|
50
|
+
class ApprovalPolicyAdmin(ReadOnlyAdmin):
|
|
51
|
+
list_display = ("tenant", "action_type", "scope_key", "workflow_type", "is_enabled", "sla_enabled", "updated")
|
|
52
|
+
list_filter = ("action_type", "workflow_type", "is_enabled")
|
|
53
|
+
inlines = [PolicyApproverInline, RevisionInline]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@admin.register(models.ApprovalPolicyRevision)
|
|
57
|
+
class ApprovalPolicyRevisionAdmin(ReadOnlyAdmin):
|
|
58
|
+
"""All revisions, including those of deleted rules (which no longer appear under a policy)."""
|
|
59
|
+
|
|
60
|
+
list_display = ("tenant", "action_type", "scope_key", "version", "change_type", "actor", "created")
|
|
61
|
+
list_filter = ("action_type", "change_type")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class StageInline(ReadOnlyInline):
|
|
65
|
+
model = models.ApprovalStage
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class DecisionInline(ReadOnlyInline):
|
|
69
|
+
model = models.ApprovalDecision
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class EventInline(ReadOnlyInline):
|
|
73
|
+
model = models.ApprovalEvent
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@admin.register(models.ApprovalRequest)
|
|
77
|
+
class ApprovalRequestAdmin(ReadOnlyAdmin):
|
|
78
|
+
list_display = ("reference", "tenant", "action_type", "target_label", "maker", "status", "submitted_at")
|
|
79
|
+
list_filter = ("status", "action_type")
|
|
80
|
+
search_fields = ("reference", "target_label", "maker__email", "maker__username")
|
|
81
|
+
inlines = [StageInline, DecisionInline, EventInline]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@admin.register(models.ApprovalDelegation)
|
|
85
|
+
class ApprovalDelegationAdmin(ReadOnlyAdmin):
|
|
86
|
+
list_display = ("tenant", "delegator", "delegate", "starts_at", "ends_at", "revoked_at")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Django app configuration.
|
|
2
|
+
|
|
3
|
+
The package is ``django_maker_checker`` but the app label is ``approvals``, which keeps table names (``approvals_*``)
|
|
4
|
+
and migration history stable for hosts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from django.apps import AppConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ApprovalsConfig(AppConfig):
|
|
11
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
12
|
+
name = "django_maker_checker"
|
|
13
|
+
label = "approvals"
|
|
14
|
+
verbose_name = "Approvals (Maker-Checker)"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Hand audit records to the host without letting an audit-sink failure break the operation being audited."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from django_maker_checker.conf import get_adapter
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def send(record):
|
|
11
|
+
"""Pass ``record`` to ``adapter.audit``. Failures are logged and swallowed: the engine's own records (request
|
|
12
|
+
events, policy revisions) are written in the same transaction and stay authoritative, so an unavailable audit
|
|
13
|
+
sink must not roll back a decision or a rule change."""
|
|
14
|
+
try:
|
|
15
|
+
get_adapter().audit(record)
|
|
16
|
+
except Exception: # noqa: BLE001 - the engine's own records (events, revisions) are authoritative
|
|
17
|
+
logger.exception("Audit sink failed for %s %s", record.resource_type, record.resource_id)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Settings read by the engine.
|
|
2
|
+
|
|
3
|
+
``APPROVALS_TENANT_MODEL`` "app_label.Model" of the tenant (required; referenced by migrations)
|
|
4
|
+
``APPROVALS_ROLE_MODEL`` "app_label.Model" of a tenant role that can be named as an approver (required)
|
|
5
|
+
``APPROVALS_ADAPTER`` dotted path of the ``ApprovalsAdapter`` subclass (required)
|
|
6
|
+
``APPROVALS_CACHE_TIMEOUT`` seconds the per-user pending count is cached (default 60)
|
|
7
|
+
``APPROVALS_CACHE_ALIAS`` cache used for pending counts (default "default")
|
|
8
|
+
``APPROVALS_REFERENCE_PREFIX`` prefix of request references, as in ``REQ-2610-00042`` (default "REQ")
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from functools import cache
|
|
12
|
+
|
|
13
|
+
from django.conf import settings
|
|
14
|
+
from django.core.cache import caches
|
|
15
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
16
|
+
from django.utils.module_loading import import_string
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _required(name):
|
|
20
|
+
value = getattr(settings, name, None)
|
|
21
|
+
if not value:
|
|
22
|
+
raise ImproperlyConfigured(f"django_maker_checker requires the {name} setting")
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def tenant_model_name():
|
|
27
|
+
return _required("APPROVALS_TENANT_MODEL")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def role_model_name():
|
|
31
|
+
return _required("APPROVALS_ROLE_MODEL")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cache_timeout():
|
|
35
|
+
return getattr(settings, "APPROVALS_CACHE_TIMEOUT", 60)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def counts_cache():
|
|
39
|
+
return caches[getattr(settings, "APPROVALS_CACHE_ALIAS", "default")]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def reference_prefix():
|
|
43
|
+
return getattr(settings, "APPROVALS_REFERENCE_PREFIX", "REQ")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Adapters are stateless, so one instance per dotted path is reused for the life of the process.
|
|
47
|
+
@cache
|
|
48
|
+
def _adapter_for(path):
|
|
49
|
+
return import_string(path)()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_adapter():
|
|
53
|
+
"""The configured adapter instance (one per process)."""
|
|
54
|
+
return _adapter_for(_required("APPROVALS_ADAPTER"))
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Fixed vocabularies of the engine: request statuses, workflows, SLA units and states, risk levels, decisions,
|
|
2
|
+
timeline event types and policy change types. Values are stored in the database, so they must not be renamed."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RequestStatus:
|
|
6
|
+
PENDING = "pending"
|
|
7
|
+
APPROVED = "approved"
|
|
8
|
+
EXECUTED = "executed"
|
|
9
|
+
REJECTED = "rejected"
|
|
10
|
+
CANCELLED = "cancelled"
|
|
11
|
+
FAILED = "failed"
|
|
12
|
+
|
|
13
|
+
CHOICES = [
|
|
14
|
+
(PENDING, "Pending"),
|
|
15
|
+
(APPROVED, "Approved"),
|
|
16
|
+
(EXECUTED, "Approved & Executed"),
|
|
17
|
+
(REJECTED, "Rejected"),
|
|
18
|
+
(CANCELLED, "Cancelled"),
|
|
19
|
+
(FAILED, "Execution Failed"),
|
|
20
|
+
]
|
|
21
|
+
# Statuses that hold the resource lock: a second request for the same target is refused.
|
|
22
|
+
OPEN = (PENDING, APPROVED)
|
|
23
|
+
TERMINAL = (EXECUTED, REJECTED, CANCELLED, FAILED)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class WorkflowType:
|
|
27
|
+
"""How a policy's approver slots become stages: SINGLE and MULTI are one stage (MULTI with any-one or all
|
|
28
|
+
approvers, per ``require_all``); SEQUENTIAL is one stage per slot, in order."""
|
|
29
|
+
|
|
30
|
+
SINGLE = "single"
|
|
31
|
+
MULTI = "multi"
|
|
32
|
+
SEQUENTIAL = "sequential"
|
|
33
|
+
|
|
34
|
+
CHOICES = [
|
|
35
|
+
(SINGLE, "Single Approver"),
|
|
36
|
+
(MULTI, "Multiple Approvers"),
|
|
37
|
+
(SEQUENTIAL, "Approval Flow"),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SlaUnit:
|
|
42
|
+
MINUTES = "minutes"
|
|
43
|
+
HOURS = "hours"
|
|
44
|
+
DAYS = "days"
|
|
45
|
+
|
|
46
|
+
CHOICES = [(MINUTES, "Minutes"), (HOURS, "Hours"), (DAYS, "Days")]
|
|
47
|
+
TO_MINUTES = {MINUTES: 1, HOURS: 60, DAYS: 60 * 24}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RiskLevel:
|
|
51
|
+
LOW = "low"
|
|
52
|
+
MEDIUM = "medium"
|
|
53
|
+
HIGH = "high"
|
|
54
|
+
|
|
55
|
+
CHOICES = [(LOW, "Low"), (MEDIUM, "Medium"), (HIGH, "High")]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Decision:
|
|
59
|
+
APPROVE = "approve"
|
|
60
|
+
REJECT = "reject"
|
|
61
|
+
|
|
62
|
+
CHOICES = [(APPROVE, "Approve"), (REJECT, "Reject")]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class EventType:
|
|
66
|
+
CREATED = "created"
|
|
67
|
+
NOTIFIED = "notified"
|
|
68
|
+
VIEWED = "viewed"
|
|
69
|
+
SLA_WARNING = "sla_warning"
|
|
70
|
+
ESCALATED = "escalated"
|
|
71
|
+
STAGE_APPROVED = "stage_approved"
|
|
72
|
+
APPROVED = "approved"
|
|
73
|
+
REJECTED = "rejected"
|
|
74
|
+
CANCELLED = "cancelled"
|
|
75
|
+
COMMENT = "comment"
|
|
76
|
+
DELEGATED = "delegated"
|
|
77
|
+
EXECUTED = "executed"
|
|
78
|
+
FAILED = "failed"
|
|
79
|
+
RETRIED = "retried"
|
|
80
|
+
|
|
81
|
+
CHOICES = [
|
|
82
|
+
(CREATED, "Submitted"),
|
|
83
|
+
(NOTIFIED, "Notified"),
|
|
84
|
+
(VIEWED, "Viewed"),
|
|
85
|
+
(SLA_WARNING, "SLA Warning"),
|
|
86
|
+
(ESCALATED, "Escalated"),
|
|
87
|
+
(STAGE_APPROVED, "Stage Approved"),
|
|
88
|
+
(APPROVED, "Approved"),
|
|
89
|
+
(REJECTED, "Rejected"),
|
|
90
|
+
(CANCELLED, "Cancelled"),
|
|
91
|
+
(COMMENT, "Comment"),
|
|
92
|
+
(DELEGATED, "Delegated"),
|
|
93
|
+
(EXECUTED, "Executed"),
|
|
94
|
+
(FAILED, "Execution Failed"),
|
|
95
|
+
(RETRIED, "Retried"),
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class SlaState:
|
|
100
|
+
"""Display state of a stage's SLA, derived from elapsed time; it is never stored."""
|
|
101
|
+
|
|
102
|
+
ON_TRACK = "on_track"
|
|
103
|
+
AT_RISK = "at_risk"
|
|
104
|
+
ESCALATED = "escalated"
|
|
105
|
+
NONE = "none"
|
|
106
|
+
|
|
107
|
+
LABELS = {ON_TRACK: "On Track", AT_RISK: "At Risk", ESCALATED: "Escalated", NONE: "No SLA"}
|
|
108
|
+
# Share of the SLA window after which a request is shown as at risk / gets a reminder.
|
|
109
|
+
AT_RISK_RATIO = 0.6
|
|
110
|
+
WARNING_RATIO = 0.75
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class PolicyChange:
|
|
114
|
+
CREATED = "created"
|
|
115
|
+
UPDATED = "updated"
|
|
116
|
+
ENABLED = "enabled"
|
|
117
|
+
DISABLED = "disabled"
|
|
118
|
+
DELETED = "deleted"
|
|
119
|
+
|
|
120
|
+
CHOICES = [
|
|
121
|
+
(CREATED, "Created"),
|
|
122
|
+
(UPDATED, "Updated"),
|
|
123
|
+
(ENABLED, "Enabled"),
|
|
124
|
+
(DISABLED, "Disabled"),
|
|
125
|
+
(DELETED, "Deleted"),
|
|
126
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Exceptions raised to callers. Messages are written for end users, since hosts usually show them as-is."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ApprovalError(Exception):
|
|
5
|
+
"""A maker-checker operation was refused; the message is safe to show to the user."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ApprovalPending(ValueError):
|
|
9
|
+
"""Raised from inside a guarded action when a maker-checker request was created instead.
|
|
10
|
+
|
|
11
|
+
Subclasses ValueError so callers that already surface ValueError messages degrade gracefully;
|
|
12
|
+
known callers catch it explicitly and report it as a success ("submitted for approval").
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, approval_request):
|
|
16
|
+
# Keep the request so callers can show its reference or link to it.
|
|
17
|
+
self.approval_request = approval_request
|
|
18
|
+
super().__init__(
|
|
19
|
+
f"Submitted for approval as {approval_request.reference}. "
|
|
20
|
+
"The change will be applied once a checker approves it."
|
|
21
|
+
)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Run the SLA pass once: reminders and escalation of overdue approval steps.
|
|
2
|
+
|
|
3
|
+
For projects without Celery, schedule it with cron or any job runner, e.g. every 5 minutes:
|
|
4
|
+
|
|
5
|
+
*/5 * * * * python manage.py process_approval_slas
|
|
6
|
+
|
|
7
|
+
It is safe to run concurrently and repeatedly (see ``services.process_sla``).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from django.core.management.base import BaseCommand
|
|
11
|
+
|
|
12
|
+
from django_maker_checker.services import process_sla
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Command(BaseCommand):
|
|
16
|
+
help = "Send SLA reminders and escalate overdue approval steps."
|
|
17
|
+
|
|
18
|
+
def handle(self, *args, **options):
|
|
19
|
+
summary = process_sla()
|
|
20
|
+
self.stdout.write(f"Warned {summary['warned']}, escalated {summary['escalated']}.")
|