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

1"""Workflow routers: trackers, key dates, tasks (loan-scoped under /api/loans) and the 

2task-template / automation-rule config (under /api/workflow). 

3 

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""" 

8 

9from __future__ import annotations 

10 

11import logging 

12from uuid import UUID 

13 

14from django.utils import timezone 

15from django.utils.dateparse import parse_datetime 

16from ninja import Router 

17 

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 

48 

49logger = logging.getLogger(__name__) 

50 

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"]) 

56 

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} 

64 

65 

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 

72 

73 

74def sync_tracker_mirror(loan: Loan) -> None: 

75 """Fold Tireless's tracker snapshot into the read-only ``external_*`` fields (ADR-002). 

76 

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 ) 

103 

104 

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()) 

113 

114 

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 

135 

136 

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 

143 

144 

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 

160 

161 

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()) 

167 

168 

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 

174 

175 

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) 

186 

187 

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 

204 

205 

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) 

220 

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) 

239 

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 } 

247 

248 

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()) 

255 

256 

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()) 

262 

263 

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 

273 

274 

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()) 

281 

282 

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())