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.
@@ -0,0 +1,3 @@
1
+ """Maker-checker approvals engine for Django (headless: models, rules, services and queries)."""
2
+
3
+ __version__ = "0.4.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']}.")