Coverage for apps/communications/api.py: 68%
177 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
1from __future__ import annotations
3import re
5from ninja import Router
7from apps.accounts.auth import requires
8from apps.communications.delivery import deliver_email
9from apps.communications.email_content import company_brand
10from apps.communications.models import Communication, CommunicationEvent, MessageTemplate
11from apps.communications.schemas import (
12 AutoSendPatch,
13 CommEventInput,
14 CommStatsOut,
15 CommunicationDetailOut,
16 CommunicationOut,
17 MergeFieldOut,
18 MessageTemplateOut,
19 SendCommunicationInput,
20 TemplateInput,
21 TemplatePatch,
22 TemplatePreviewIn,
23 TemplatePreviewOut,
24)
25from apps.communications.seed_data import MERGE_FIELDS, SEED_TEMPLATES
26from apps.communications.status import EVENT_TO_STATUS, advance_status
27from apps.core_los.models import Loan
28from domain.result import AppError, DomainError
30router = Router(tags=["communications"])
32_EVENT_TO_STATUS = EVENT_TO_STATUS
33_advance_status = advance_status
36def _render_text(text: str, values: dict[str, str]) -> tuple[str, list[str]]:
37 missing = []
39 def replacer(match):
40 key = match.group(1).strip()
41 if key in values: 41 ↛ 43line 41 didn't jump to line 43 because the condition on line 41 was always true
42 return values[key]
43 missing.append(key)
44 return f"[{key}]"
46 rendered = re.sub(r"\{\{([^}]+)\}\}", replacer, text)
47 return rendered, missing
50def _get_loan_values(loan: Loan | None) -> dict[str, str]:
51 if loan is None: 51 ↛ 52line 51 didn't jump to line 52 because the condition on line 51 was never true
52 return {f["key"]: f["sample"] for f in MERGE_FIELDS}
53 primary = loan.borrowers.filter(is_primary=True).first() or loan.borrowers.first()
54 lo = loan.loan_officer
55 txn = getattr(loan, "transaction", None)
56 return {
57 "borrowerFirstName": primary.first_name if primary else "Borrower",
58 "borrowerName": f"{primary.first_name} {primary.last_name}" if primary else "Borrower",
59 "loanNumber": loan.arive_loan_no,
60 "loanOfficer": f"{lo.first_name} {lo.last_name}" if lo else "Loan Officer",
61 "loanOfficerPhone": lo.phone if lo and lo.phone else "(561) 555-0142",
62 "loanAmount": f"${int(txn.base_loan_amt):,}" if txn and txn.base_loan_amt else "$400,000",
63 "rate": f"{txn.note_rate_percent}%" if txn and txn.note_rate_percent else "6.5%",
64 "stage": loan.stage,
65 "propertyState": loan.state_code,
66 "company": company_brand().name,
67 }
70def _seed_templates_if_empty():
71 if MessageTemplate.objects.count() == 0:
72 for t in SEED_TEMPLATES:
73 MessageTemplate.objects.create(**t)
76@router.get("/communications", response=list[CommunicationOut], auth=requires("LOAN_VIEW"))
77def list_communications(
78 request,
79 loan_guid: str | None = None,
80 channel: str | None = None,
81 status: str | None = None,
82):
83 _seed_templates_if_empty()
84 qs = Communication.objects.select_related("loan", "template").all()
85 if loan_guid:
86 qs = qs.filter(loan__guid=loan_guid)
87 if channel:
88 qs = qs.filter(channel=channel)
89 if status:
90 qs = qs.filter(status=status)
91 return list(qs)
94@router.get(
95 "/communications/{comm_id}", response=CommunicationDetailOut, auth=requires("LOAN_VIEW")
96)
97def get_communication(request, comm_id: int):
98 comm = Communication.objects.select_related("loan", "template").filter(pk=comm_id).first()
99 if comm is None:
100 raise DomainError([AppError("NOT_FOUND", "Communication not found")])
101 events = [
102 {"id": e.id, "communication_id": e.communication_id, "type": e.event_type, "at": e.created_at.isoformat()}
103 for e in comm.events.all()
104 ]
105 return {"communication": comm, "events": events}
108@router.post(
109 "/communications", response={201: CommunicationDetailOut}, auth=requires("LOAN_EDIT")
110)
111def send_communication(request, payload: SendCommunicationInput):
112 loan = None
113 if payload.loan_guid: 113 ↛ 116line 113 didn't jump to line 116 because the condition on line 113 was always true
114 loan = Loan.objects.filter(guid=payload.loan_guid).first()
116 recipient = payload.to
117 if not recipient and loan: 117 ↛ 122line 117 didn't jump to line 122 because the condition on line 117 was always true
118 primary = loan.borrowers.filter(is_primary=True).first() or loan.borrowers.first()
119 if primary: 119 ↛ 122line 119 didn't jump to line 122 because the condition on line 119 was always true
120 recipient = primary.email if payload.channel == "email" else primary.cell_phone
122 if not recipient: 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true
123 raise DomainError([AppError("COMM_NO_RECIPIENT", "Recipient is required", "to")])
125 body = payload.body or ""
126 subject = payload.subject
127 template = None
128 if payload.template_id: 128 ↛ 129line 128 didn't jump to line 129 because the condition on line 128 was never true
129 template = MessageTemplate.objects.filter(pk=payload.template_id).first()
130 if template:
131 if not body:
132 body = template.body
133 if payload.channel == "email" and not subject:
134 subject = template.subject
136 if payload.channel == "email" and not (subject and subject.strip()): 136 ↛ 137line 136 didn't jump to line 137 because the condition on line 136 was never true
137 raise DomainError([AppError("VALIDATION", "Email subject is required", "subject")])
138 if not (body and body.strip()): 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true
139 raise DomainError([AppError("VALIDATION", "Message body is required", "body")])
141 rendered_body, _ = _render_text(body, _get_loan_values(loan))
142 rendered_subject = _render_text(subject, _get_loan_values(loan))[0] if subject else None
144 if payload.channel == "email": 144 ↛ 156line 144 didn't jump to line 156 because the condition on line 144 was always true
145 # Real delivery through SendGrid when configured (add-sendgrid-email); a failed send is
146 # recorded with status Failed rather than raising, so it shows in the message list.
147 comm = deliver_email(
148 to=recipient,
149 subject=rendered_subject or "",
150 body=rendered_body,
151 loan=loan,
152 template=template,
153 )
154 else:
155 # SMS / voice have no provider yet: recorded as simulated.
156 comm = Communication.objects.create(
157 loan=loan,
158 channel=payload.channel,
159 template=template,
160 to=recipient,
161 subject=rendered_subject,
162 body=rendered_body,
163 status="Sent",
164 provider="simulated",
165 )
166 CommunicationEvent.objects.create(communication=comm, event_type="sent")
167 event_out = [
168 {"id": e.id, "communication_id": comm.id, "type": e.event_type, "at": e.created_at.isoformat()}
169 for e in comm.events.all()
170 ]
171 return 201, {"communication": comm, "events": event_out}
174@router.post(
175 "/communications/{comm_id}/events", response=CommunicationOut, auth=requires("LOAN_EDIT")
176)
177def record_comm_event(request, comm_id: int, payload: CommEventInput):
178 comm = Communication.objects.filter(pk=comm_id).first()
179 if comm is None: 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true
180 raise DomainError([AppError("NOT_FOUND", "Communication not found")])
181 event_type = payload.type.lower()
182 if event_type not in _EVENT_TO_STATUS: 182 ↛ 183line 182 didn't jump to line 183 because the condition on line 182 was never true
183 raise DomainError([AppError("VALIDATION", f"Invalid event type: {payload.type}")])
185 CommunicationEvent.objects.create(communication=comm, event_type=event_type)
186 new_status = _EVENT_TO_STATUS[event_type]
187 comm.status = _advance_status(comm.status, new_status)
188 comm.save()
189 return comm
192@router.get("/comm/stats", response=CommStatsOut, auth=requires("LOAN_VIEW"))
193def get_comm_stats(request, loan_guid: str | None = None):
194 qs = Communication.objects.all()
195 if loan_guid: 195 ↛ 198line 195 didn't jump to line 198 because the condition on line 195 was always true
196 qs = qs.filter(loan__guid=loan_guid)
198 total = qs.count()
199 email_qs = qs.filter(channel="email")
200 email_sent = email_qs.count()
201 email_deliv = email_qs.filter(status__in=["Delivered", "Opened", "Clicked", "Replied"]).count()
202 email_open = email_qs.filter(status__in=["Opened", "Clicked", "Replied"]).count()
203 email_click = email_qs.filter(status__in=["Clicked", "Replied"]).count()
205 sms_qs = qs.filter(channel="sms")
206 sms_sent = sms_qs.count()
207 sms_deliv = sms_qs.filter(status__in=["Delivered", "Replied"]).count()
209 voice_qs = qs.filter(channel="voice")
210 voice_placed = voice_qs.count()
211 voice_ans = voice_qs.filter(status__in=["Answered", "Completed"]).count()
212 voice_vm = voice_qs.filter(status="Voicemail").count()
214 return {
215 "total": total,
216 "by_channel": {
217 "email": email_sent,
218 "sms": sms_sent,
219 "voice": voice_placed,
220 },
221 "email": {
222 "sent": email_sent,
223 "delivered": email_deliv,
224 "opened": email_open,
225 "clicked": email_click,
226 "open_rate": int(round((email_open / email_sent) * 100)) if email_sent else 0,
227 "click_rate": int(round((email_click / email_sent) * 100)) if email_sent else 0,
228 },
229 "sms": {
230 "sent": sms_sent,
231 "delivered": sms_deliv,
232 "delivery_rate": int(round((sms_deliv / sms_sent) * 100)) if sms_sent else 0,
233 },
234 "voice": {
235 "placed": voice_placed,
236 "answered": voice_ans,
237 "voicemail": voice_vm,
238 "answer_rate": int(round((voice_ans / voice_placed) * 100)) if voice_placed else 0,
239 },
240 }
243@router.get("/comm/merge-fields", response=list[MergeFieldOut], auth=requires("LOAN_VIEW"))
244def get_merge_fields(request):
245 return MERGE_FIELDS
248@router.get("/templates", response=list[MessageTemplateOut], auth=requires("LOAN_VIEW"))
249def list_templates(request, channel: str | None = None):
250 _seed_templates_if_empty()
251 qs = MessageTemplate.objects.all()
252 if channel:
253 qs = qs.filter(channel=channel)
254 return list(qs)
257@router.post("/templates", response={201: MessageTemplateOut}, auth=requires("LOAN_EDIT"))
258def create_template(request, payload: TemplateInput):
259 data = payload.model_dump()
260 data["system"] = False
261 return 201, MessageTemplate.objects.create(**data)
264@router.patch("/templates/{template_id}", response=MessageTemplateOut, auth=requires("LOAN_EDIT"))
265def update_template(request, template_id: int, payload: TemplatePatch):
266 tpl = MessageTemplate.objects.filter(pk=template_id).first()
267 if tpl is None:
268 raise DomainError([AppError("NOT_FOUND", "Template not found")])
269 for attr, val in payload.model_dump(exclude_unset=True).items():
270 setattr(tpl, attr, val)
271 tpl.save()
272 return tpl
275@router.delete("/templates/{template_id}", response={204: None}, auth=requires("LOAN_EDIT"))
276def delete_template(request, template_id: int):
277 tpl = MessageTemplate.objects.filter(pk=template_id).first()
278 if tpl is None: 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true
279 raise DomainError([AppError("NOT_FOUND", "Template not found")])
280 if tpl.system:
281 raise DomainError([AppError("TEMPLATE_PROTECTED", "System templates cannot be deleted")])
282 tpl.delete()
283 return 204, None
286@router.post(
287 "/templates/{template_id}/preview", response=TemplatePreviewOut, auth=requires("LOAN_VIEW")
288)
289def preview_template(request, template_id: int, payload: TemplatePreviewIn | None = None):
290 tpl = MessageTemplate.objects.filter(pk=template_id).first()
291 if tpl is None: 291 ↛ 292line 291 didn't jump to line 292 because the condition on line 291 was never true
292 raise DomainError([AppError("NOT_FOUND", "Template not found")])
293 loan = None
294 if payload and payload.loan_guid: 294 ↛ 296line 294 didn't jump to line 296 because the condition on line 294 was always true
295 loan = Loan.objects.filter(guid=payload.loan_guid).first()
296 vals = _get_loan_values(loan)
297 body, missing = _render_text(tpl.body, vals)
298 subject = _render_text(tpl.subject, vals)[0] if tpl.subject else None
299 return {"subject": subject, "body": body, "missing": missing}
302@router.get("/comm/automations", response=list[MessageTemplateOut], auth=requires("LOAN_VIEW"))
303def list_automations(request):
304 _seed_templates_if_empty()
305 return list(MessageTemplate.objects.exclude(trigger__isnull=True).exclude(trigger=""))
308@router.patch(
309 "/comm/automations/{template_id}", response=MessageTemplateOut, auth=requires("LOAN_EDIT")
310)
311def set_automation_toggle(request, template_id: int, payload: AutoSendPatch):
312 tpl = MessageTemplate.objects.filter(pk=template_id).first()
313 if tpl is None: 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true
314 raise DomainError([AppError("NOT_FOUND", "Template not found")])
315 tpl.auto_send = payload.auto_send
316 tpl.save(update_fields=["auto_send", "updated_at"])
317 return tpl