Coverage for apps/workflow/models.py: 87%
143 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, milestone trackers, key dates, tasks, and automation rules (spec §8.5, §13.2, §16.4)."""
3from __future__ import annotations
5import copy
6from datetime import timedelta
7from typing import TYPE_CHECKING, Any
9from django.conf import settings
10from django.db import models
11from django.utils import timezone
13from domain.enums import (
14 TrackerStatus,
15 tracker_status_choices,
16)
17from domain.trackers import fresh_trackers
19if TYPE_CHECKING:
20 from domain.rules import AutomationRuleDef
23class LoanTracker(models.Model):
24 """Operational / compliance milestone tracker associated with a Loan (spec §13.2, §16.4)."""
26 TRACKER_STATUS_CHOICES = tracker_status_choices()
28 loan = models.ForeignKey(
29 "core_los.Loan",
30 on_delete=models.CASCADE,
31 related_name="trackers",
32 )
33 key = models.CharField(max_length=50)
34 label = models.CharField(max_length=100)
35 category = models.CharField(max_length=50)
36 status = models.CharField(
37 max_length=20,
38 choices=TRACKER_STATUS_CHOICES,
39 default=TrackerStatus.NOT_STARTED.value,
40 )
41 target_date = models.DateField(null=True, blank=True)
42 completed_at = models.DateTimeField(null=True, blank=True)
43 # ADR-002 tracker-mirror seam: read-only supplementary signal from Tireless, populated ONLY
44 # by TirelessTrackerPort (for trid/title/hoi), never by the staff update endpoint, and it
45 # never overrides `status`. Empty until the integration is connected.
46 external_status = models.CharField(max_length=30, null=True, blank=True)
47 external_updated_at = models.DateTimeField(null=True, blank=True)
48 external_source = models.CharField(max_length=30, null=True, blank=True)
49 created_at = models.DateTimeField(auto_now_add=True)
50 updated_at = models.DateTimeField(auto_now=True)
52 class Meta:
53 db_table = "workflow_trackers"
54 indexes = [
55 models.Index(fields=["loan", "key"]),
56 models.Index(fields=["loan", "status"]),
57 ]
58 unique_together = ("loan", "key")
60 def __str__(self) -> str:
61 return f"{self.loan_id} - {self.key}: {self.status}"
63 def complete(self) -> None:
64 """Advance status to Completed and stamp completion time."""
65 self.status = TrackerStatus.COMPLETED.value
66 self.completed_at = timezone.now()
67 self.save(update_fields=["status", "completed_at", "updated_at"])
70class KeyDates(models.Model):
71 """TRID and operational statutory milestone dates (1:1 with Loan)."""
73 loan = models.OneToOneField(
74 "core_los.Loan",
75 on_delete=models.CASCADE,
76 primary_key=True,
77 related_name="key_dates",
78 )
79 application_date = models.DateField(null=True, blank=True)
80 le_disclosed_date = models.DateField(null=True, blank=True)
81 le_signed_date = models.DateField(null=True, blank=True)
82 intent_to_proceed_date = models.DateField(null=True, blank=True)
83 appraisal_ordered_date = models.DateField(null=True, blank=True)
84 appraisal_received_date = models.DateField(null=True, blank=True)
85 lock_date = models.DateField(null=True, blank=True)
86 lock_expiration_date = models.DateField(null=True, blank=True)
87 submitted_to_uw_date = models.DateField(null=True, blank=True)
88 approved_date = models.DateField(null=True, blank=True)
89 clear_to_close_date = models.DateField(null=True, blank=True)
90 cd_disclosed_date = models.DateField(null=True, blank=True)
91 cd_signed_date = models.DateField(null=True, blank=True)
92 closing_date = models.DateField(null=True, blank=True)
93 funding_date = models.DateField(null=True, blank=True)
94 first_payment_date = models.DateField(null=True, blank=True)
95 created_at = models.DateTimeField(auto_now_add=True)
96 updated_at = models.DateTimeField(auto_now=True)
98 class Meta:
99 db_table = "workflow_key_dates"
101 def __str__(self) -> str:
102 return f"KeyDates for Loan {self.loan_id}"
105class LoanTask(models.Model):
106 """Operational task assigned on a loan with checklist and auto-follow-up support."""
108 loan = models.ForeignKey(
109 "core_los.Loan",
110 on_delete=models.CASCADE,
111 related_name="tasks",
112 )
113 title = models.CharField(max_length=200)
114 description = models.TextField(blank=True)
115 assignee = models.ForeignKey(
116 settings.AUTH_USER_MODEL,
117 on_delete=models.SET_NULL,
118 null=True,
119 blank=True,
120 related_name="workflow_tasks",
121 )
122 due_date = models.DateField(null=True, blank=True)
123 completed = models.BooleanField(default=False)
124 completed_at = models.DateTimeField(null=True, blank=True)
125 checklists = models.JSONField(default=list, blank=True)
126 follow_up_on_done = models.BooleanField(default=False)
127 follow_up_offset_days = models.PositiveIntegerField(default=7)
128 created_at = models.DateTimeField(auto_now_add=True)
129 updated_at = models.DateTimeField(auto_now=True)
131 class Meta:
132 db_table = "workflow_tasks"
133 indexes = [
134 models.Index(fields=["loan", "completed"]),
135 models.Index(fields=["assignee", "completed"]),
136 ]
138 def __str__(self) -> str:
139 status_label = "DONE" if self.completed else "PENDING"
140 return f"[{status_label}] {self.title} (Loan {self.loan_id})"
142 def complete(self) -> LoanTask | None:
143 """Mark task completed and spawn follow-up task if configured."""
144 self.completed = True
145 if not self.completed_at: 145 ↛ 147line 145 didn't jump to line 147 because the condition on line 145 was always true
146 self.completed_at = timezone.now()
147 self.save(update_fields=["completed", "completed_at", "updated_at"])
149 if self.follow_up_on_done:
150 base_date = self.completed_at.date() if self.completed_at else timezone.now().date()
151 follow_up_due = base_date + timedelta(days=self.follow_up_offset_days)
152 follow_up = LoanTask.objects.create(
153 loan=self.loan,
154 title=f"Follow-up: {self.title}",
155 description=self.description,
156 assignee=self.assignee,
157 due_date=follow_up_due,
158 checklists=[],
159 follow_up_on_done=False,
160 follow_up_offset_days=self.follow_up_offset_days,
161 )
162 return follow_up
163 return None
166class TaskTemplate(models.Model):
167 """Reusable task template with checklist and relative due date offset."""
169 title = models.CharField(max_length=200)
170 description = models.TextField(blank=True)
171 due_offset_days = models.PositiveIntegerField(default=3)
172 checklists = models.JSONField(default=list, blank=True)
173 follow_up = models.JSONField(default=dict, blank=True)
174 active = models.BooleanField(default=True)
175 created_at = models.DateTimeField(auto_now_add=True)
176 updated_at = models.DateTimeField(auto_now=True)
178 class Meta:
179 db_table = "workflow_task_templates"
181 def __str__(self) -> str:
182 return self.title
184 def apply(self, loan: Any, assignee: Any = None, base_date: Any = None) -> LoanTask:
185 """Instantiate a new LoanTask from this template for the given loan."""
186 if base_date is None: 186 ↛ 188line 186 didn't jump to line 188 because the condition on line 186 was always true
187 base_date = timezone.now().date()
188 due_date = base_date + timedelta(days=self.due_offset_days) if self.due_offset_days is not None else None
190 follow_up_cfg = self.follow_up or {}
191 create_on_done = bool(follow_up_cfg.get("createOnDone") or follow_up_cfg.get("create_on_done", False))
192 offset_days = int(follow_up_cfg.get("dueOffsetDays") or follow_up_cfg.get("due_offset_days", 7))
194 return LoanTask.objects.create(
195 loan=loan,
196 title=self.title,
197 description=self.description,
198 assignee=assignee,
199 due_date=due_date,
200 checklists=copy.deepcopy(self.checklists),
201 follow_up_on_done=create_on_done,
202 follow_up_offset_days=offset_days,
203 )
206class AutomationRule(models.Model):
207 """Configurable automation rule executed on loan lifecycle triggers (spec §8.5)."""
209 rule_id = models.CharField(max_length=20, unique=True)
210 name = models.CharField(max_length=200)
211 trigger = models.CharField(max_length=50)
212 conditions = models.JSONField(default=list, blank=True)
213 actions = models.JSONField(default=list, blank=True)
214 is_active = models.BooleanField(default=True)
215 created_at = models.DateTimeField(auto_now_add=True)
216 updated_at = models.DateTimeField(auto_now=True)
218 class Meta:
219 db_table = "workflow_automation_rules"
221 def __str__(self) -> str:
222 return f"{self.rule_id}: {self.name} ({self.trigger})"
224 def to_domain(self) -> AutomationRuleDef:
225 """Convert persistence model to pure domain AutomationRuleDef."""
226 from domain.rules import AutomationRuleDef, RuleAction, RuleCondition
228 conds = []
229 for c in self.conditions:
230 conds.append(
231 RuleCondition(
232 kind=c.get("kind", ""),
233 field_name=c.get("field", "") or c.get("field_name", ""),
234 value=c.get("value"),
235 values=tuple(c.get("values", ())),
236 )
237 )
239 acts = []
240 for a in self.actions:
241 action_type = a.get("type", "") or a.get("action_type", "") or a.get("actionType", "")
242 # Canonicalize actionType (e.g. "createTask" -> "CreateTask")
243 if action_type.lower() == "createtask": 243 ↛ 245line 243 didn't jump to line 245 because the condition on line 243 was always true
244 action_type = "CreateTask"
245 elif action_type.lower() == "requirefields":
246 action_type = "RequireFields"
247 elif action_type.lower() == "senddocument":
248 action_type = "SendDocument"
249 elif action_type.lower() == "stop":
250 action_type = "Stop"
252 acts.append(
253 RuleAction(
254 action_type=action_type,
255 message=a.get("message", ""),
256 fields=tuple(a.get("fields", ())),
257 document_classification_id=a.get("documentClassificationId", "")
258 or a.get("document_classification_id", ""),
259 task_title=a.get("taskTitle", "") or a.get("task_title", ""),
260 )
261 )
263 return AutomationRuleDef(
264 id=self.rule_id,
265 name=self.name,
266 trigger=self.trigger,
267 conditions=tuple(conds),
268 actions=tuple(acts),
269 active=self.is_active,
270 )
273def seed_trackers(loan: Any) -> list[LoanTracker]:
274 """Seed 16 initial milestone trackers for a loan."""
275 rows = fresh_trackers(loan.id)
276 trackers = [
277 LoanTracker(
278 loan=loan,
279 key=row.key,
280 label=row.label,
281 category=row.category,
282 status=row.status.value,
283 )
284 for row in rows
285 ]
286 return LoanTracker.objects.bulk_create(trackers, ignore_conflicts=True)