Coverage for apps/workflow/api.py: 84%
160 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 17:24 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 17:24 +0000
1"""Workflow routers: trackers, key dates, tasks (loan-scoped under /api/loans) and the
2task-template / automation-rule config (under /api/workflow).
4Thin ORM<->domain adapter. Follows the credit/AUS/pricing/fees shape (`_scoped_loan` +
5`ensure_staff_scope` + `requires(...)`). The Tireless tracker mirror (ADR-002) is read-only,
6defensive, and port-populated — never set by the staff endpoints, never overriding `status`.
7"""
9from __future__ import annotations
11import logging
12from uuid import UUID
14from django.utils import timezone
15from django.utils.dateparse import parse_datetime
16from ninja import Router
18from apps.accounts.auth import ensure_staff_scope, requires
19from apps.accounts.models import User
20from apps.core_los.models import Loan
21from apps.workflow.models import (
22 AutomationRule,
23 KeyDates,
24 LoanTask,
25 TaskTemplate,
26 seed_trackers,
27)
28from apps.workflow.ports import get_tracker_port
29from apps.workflow.schemas import (
30 AutomationRuleCreate,
31 AutomationRuleOut,
32 KeyDatesOut,
33 KeyDatesPatch,
34 RuleEvaluateIn,
35 RuleOutcomeOut,
36 TaskCreate,
37 TaskFromTemplateIn,
38 TaskOut,
39 TaskPatch,
40 TaskTemplateCreate,
41 TaskTemplateOut,
42 TrackerOut,
43 TrackerPatch,
44)
45from domain.enums import TrackerStatus
46from domain.result import AppError, DomainError
47from domain.rules import RuleEvent, evaluate_rules
49logger = logging.getLogger(__name__)
51router = Router(tags=["workflow"])
52config_router = Router(tags=["workflow-config"])
53# Mounted at "" (root) so the full path is /api/task-templates — the Admin Console's own
54# prefix (fix-task-templates-admin-path), not /api/workflow/task-templates.
55task_templates_router = Router(tags=["workflow-config"])
57# Lumen tracker key -> (Tireless overview status field, timestamp field). Only these 3 carry a
58# real Tireless pipeline counterpart (ADR-002 / details/07-trackers-bridge.md).
59_MIRROR_FIELDS = {
60 "trid": ("disclosureStatus", "tridUpdatedTime"),
61 "title": ("titleStatus", "titleUpdatedTime"),
62 "hoi": ("hoiStatus", "hoiUpdatedTime"),
63}
66def _scoped_loan(request, guid: UUID) -> Loan:
67 loan = Loan.objects.filter(guid=guid).first()
68 if loan is None: 68 ↛ 69line 68 didn't jump to line 69 because the condition on line 68 was never true
69 raise DomainError([AppError("NOT_FOUND", "Loan not found")])
70 ensure_staff_scope(request.principal, loan)
71 return loan
74def sync_tracker_mirror(loan: Loan) -> None:
75 """Fold Tireless's tracker snapshot into the read-only ``external_*`` fields (ADR-002).
77 Defensive by construction: a null/stale/malformed snapshot is logged and ignored, never
78 raised, and it NEVER touches ``status``. No-op today — the port returns None until wired.
79 """
80 try:
81 overview = get_tracker_port().fetch_overview(loan.tireless_loan_id)
82 except Exception: # a real port must never break the read
83 logger.warning("Tireless tracker fetch failed; ignoring", exc_info=True)
84 return
85 if not overview:
86 return
87 for key, (status_field, time_field) in _MIRROR_FIELDS.items():
88 status = overview.get(status_field)
89 if not status:
90 continue
91 tracker = loan.trackers.filter(key=key).first()
92 if tracker is None: 92 ↛ 93line 92 didn't jump to line 93 because the condition on line 92 was never true
93 continue
94 tracker.external_status = str(status)
95 tracker.external_source = "tireless"
96 raw_ts = overview.get(time_field)
97 tracker.external_updated_at = parse_datetime(raw_ts) if raw_ts else None
98 tracker.save(
99 update_fields=[
100 "external_status", "external_source", "external_updated_at", "updated_at"
101 ]
102 )
105# --- Trackers ------------------------------------------------------------------
106@router.get("/{guid}/trackers", response=list[TrackerOut], auth=requires("LOAN_VIEW"))
107def list_trackers(request, guid: UUID):
108 loan = _scoped_loan(request, guid)
109 if not loan.trackers.exists():
110 seed_trackers(loan)
111 sync_tracker_mirror(loan)
112 return list(loan.trackers.all())
115@router.patch("/{guid}/trackers/{key}", response=TrackerOut, auth=requires("LOAN_EDIT"))
116def update_tracker(request, guid: UUID, key: str, payload: TrackerPatch):
117 loan = _scoped_loan(request, guid)
118 tracker = None
119 if key.isdigit():
120 tracker = loan.trackers.filter(pk=int(key)).first()
121 if tracker is None:
122 tracker = loan.trackers.filter(key=key).first()
123 if tracker is None: 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true
124 raise DomainError([AppError("NOT_FOUND", "Tracker not found")])
125 if payload.status is not None: 125 ↛ 131line 125 didn't jump to line 131 because the condition on line 125 was always true
126 if payload.status not in {s.value for s in TrackerStatus}: 126 ↛ 127line 126 didn't jump to line 127 because the condition on line 126 was never true
127 raise DomainError([AppError("VALIDATION", f"Invalid tracker status: {payload.status}")])
128 tracker.status = payload.status
129 if payload.status == TrackerStatus.COMPLETED.value and tracker.completed_at is None:
130 tracker.completed_at = timezone.now()
131 if payload.target_date is not None: 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true
132 tracker.target_date = payload.target_date
133 tracker.save()
134 return tracker
137# --- Key dates -----------------------------------------------------------------
138@router.get("/{guid}/key-dates", response=KeyDatesOut, auth=requires("LOAN_VIEW"))
139def get_key_dates(request, guid: UUID):
140 loan = _scoped_loan(request, guid)
141 kd, _ = KeyDates.objects.get_or_create(loan=loan)
142 return kd
145@router.patch("/{guid}/key-dates", response=KeyDatesOut, auth=requires("LOAN_EDIT"))
146def update_key_dates(request, guid: UUID, payload: KeyDatesPatch):
147 loan = _scoped_loan(request, guid)
148 kd, _ = KeyDates.objects.get_or_create(loan=loan)
149 data = payload.dict(exclude_unset=True)
150 if "key" in data and "date" in data: 150 ↛ 151line 150 didn't jump to line 151 because the condition on line 150 was never true
151 key_name = data.pop("key")
152 date_val = data.pop("date")
153 if hasattr(kd, key_name):
154 setattr(kd, key_name, date_val)
155 for field_name, value in data.items():
156 if hasattr(kd, field_name): 156 ↛ 155line 156 didn't jump to line 155 because the condition on line 156 was always true
157 setattr(kd, field_name, value)
158 kd.save()
159 return kd
162# --- Tasks ---------------------------------------------------------------------
163@router.get("/{guid}/tasks", response=list[TaskOut], auth=requires("LOAN_VIEW"))
164def list_tasks(request, guid: UUID):
165 loan = _scoped_loan(request, guid)
166 return list(loan.tasks.all())
169@router.post("/{guid}/tasks", response={201: TaskOut}, auth=requires("LOAN_EDIT"))
170def create_task(request, guid: UUID, payload: TaskCreate):
171 loan = _scoped_loan(request, guid)
172 task = LoanTask.objects.create(loan=loan, **payload.dict())
173 return 201, task
176# NOTE: the literal `/tasks/from-template` route is registered BEFORE `/tasks/{task_id}` so it
177# is matched first (otherwise "from-template" resolves as a task_id).
178@router.post("/{guid}/tasks/from-template", response={201: TaskOut}, auth=requires("LOAN_EDIT"))
179def task_from_template(request, guid: UUID, payload: TaskFromTemplateIn):
180 loan = _scoped_loan(request, guid)
181 template = TaskTemplate.objects.filter(id=payload.template_id).first()
182 if template is None: 182 ↛ 183line 182 didn't jump to line 183 because the condition on line 182 was never true
183 raise DomainError([AppError("NOT_FOUND", "Task template not found")])
184 assignee = User.objects.filter(id=payload.assignee_id).first() if payload.assignee_id else None
185 return 201, template.apply(loan, assignee=assignee)
188@router.patch("/{guid}/tasks/{task_id}", response=TaskOut, auth=requires("LOAN_EDIT"))
189def update_task(request, guid: UUID, task_id: int, payload: TaskPatch):
190 loan = _scoped_loan(request, guid)
191 task = loan.tasks.filter(id=task_id).first()
192 if task is None: 192 ↛ 193line 192 didn't jump to line 193 because the condition on line 192 was never true
193 raise DomainError([AppError("NOT_FOUND", "Task not found")])
194 data = payload.dict(exclude_unset=True)
195 completing = data.pop("completed", None)
196 for field_name, value in data.items(): 196 ↛ 197line 196 didn't jump to line 197 because the loop on line 196 never started
197 setattr(task, field_name, value)
198 if completing is False: 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true
199 task.completed = False
200 task.save()
201 if completing: 201 ↛ 203line 201 didn't jump to line 203 because the condition on line 201 was always true
202 task.complete() # stamps completed_at and spawns the configured follow-up
203 return task
206# --- Automation rule evaluation (loan-scoped) ----------------------------------
207@router.post(
208 "/{guid}/automation-rules/evaluate", response=RuleOutcomeOut, auth=requires("LOAN_EDIT")
209)
210def evaluate_loan_rules(request, guid: UUID, payload: RuleEvaluateIn):
211 loan = _scoped_loan(request, guid)
212 rules = [r.to_domain() for r in AutomationRule.objects.filter(is_active=True)]
213 event = RuleEvent(
214 event_type=payload.trigger,
215 loan=loan,
216 target_stage=payload.target_stage,
217 date_key=payload.date_key,
218 )
219 outcome = evaluate_rules(rules, event)
221 effects_out = []
222 created_task_ids = []
223 for eff in outcome.effects:
224 effects_out.append(
225 {
226 "rule_id": eff.rule_id,
227 "action_type": eff.action.action_type,
228 "message": eff.action.message,
229 "task_title": eff.action.task_title,
230 }
231 )
232 if eff.action.action_type == "CreateTask": 232 ↛ 223line 232 didn't jump to line 223 because the condition on line 232 was always true
233 task = LoanTask.objects.create(
234 loan=loan,
235 title=eff.action.task_title or f"Rule {eff.rule_id}",
236 description=eff.action.message,
237 )
238 created_task_ids.append(task.id)
240 errors_out = [{"code": e.code, "message": e.message, "field": e.field} for e in outcome.errors]
241 return {
242 "ok": outcome.ok,
243 "errors": errors_out,
244 "effects": effects_out,
245 "created_task_ids": created_task_ids,
246 }
249# --- Config: task templates (not loan-scoped) -----------------------------------
250@task_templates_router.get(
251 "/task-templates", response=list[TaskTemplateOut], auth=requires("LOAN_VIEW")
252)
253def list_templates(request):
254 return list(TaskTemplate.objects.all())
257@task_templates_router.post(
258 "/task-templates", response={201: TaskTemplateOut}, auth=requires("LOAN_EDIT")
259)
260def create_template(request, payload: TaskTemplateCreate):
261 return 201, TaskTemplate.objects.create(**payload.dict())
264@task_templates_router.delete(
265 "/task-templates/{template_id}", response={204: None}, auth=requires("LOAN_EDIT")
266)
267def delete_template(request, template_id: int):
268 template = TaskTemplate.objects.filter(pk=template_id).first()
269 if template is None:
270 raise DomainError([AppError("NOT_FOUND", f"Task template {template_id} not found.")])
271 template.delete()
272 return 204, None
275# --- Config: automation rules (not loan-scoped) ----------------------------------
276@config_router.get(
277 "/automation-rules", response=list[AutomationRuleOut], auth=requires("LOAN_VIEW")
278)
279def list_rules(request):
280 return list(AutomationRule.objects.all())
283@config_router.post(
284 "/automation-rules", response={201: AutomationRuleOut}, auth=requires("LOAN_EDIT")
285)
286def create_rule(request, payload: AutomationRuleCreate):
287 return 201, AutomationRule.objects.create(**payload.dict())