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

1from __future__ import annotations 

2 

3import re 

4 

5from ninja import Router 

6 

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 

29 

30router = Router(tags=["communications"]) 

31 

32_EVENT_TO_STATUS = EVENT_TO_STATUS 

33_advance_status = advance_status 

34 

35 

36def _render_text(text: str, values: dict[str, str]) -> tuple[str, list[str]]: 

37 missing = [] 

38 

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

45 

46 rendered = re.sub(r"\{\{([^}]+)\}\}", replacer, text) 

47 return rendered, missing 

48 

49 

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 } 

68 

69 

70def _seed_templates_if_empty(): 

71 if MessageTemplate.objects.count() == 0: 

72 for t in SEED_TEMPLATES: 

73 MessageTemplate.objects.create(**t) 

74 

75 

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) 

92 

93 

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} 

106 

107 

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

115 

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 

121 

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

124 

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 

135 

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

140 

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 

143 

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} 

172 

173 

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

184 

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 

190 

191 

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) 

197 

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

204 

205 sms_qs = qs.filter(channel="sms") 

206 sms_sent = sms_qs.count() 

207 sms_deliv = sms_qs.filter(status__in=["Delivered", "Replied"]).count() 

208 

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

213 

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 } 

241 

242 

243@router.get("/comm/merge-fields", response=list[MergeFieldOut], auth=requires("LOAN_VIEW")) 

244def get_merge_fields(request): 

245 return MERGE_FIELDS 

246 

247 

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) 

255 

256 

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) 

262 

263 

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 

273 

274 

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 

284 

285 

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} 

300 

301 

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

306 

307 

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