Coverage for apps/borrower_portal/upload_api.py: 91%

157 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-04 17:24 +0000

1"""Borrower documents and signing (add-borrower-portal-api spec borrower-documents; design.md 

2D12, D13). 

3 

4- ``staff_router`` (mounted at /api/loans): create and list upload links for a loan. 

5- ``revoke_router`` (mounted at /api): revoke an upload link. 

6- ``public_router`` (mounted at /api): open a link and upload through it — no sign-in. 

7- ``portal_router`` (mounted at /api/pos): the signed-in borrower's needs, documents and 

8 disclosures. 

9 

10Documents stay references (ADR-001): a name, a classification code and a placeholder hash. 

11""" 

12 

13from __future__ import annotations 

14 

15import secrets 

16from uuid import UUID 

17 

18from django.db import transaction 

19from django.utils import timezone 

20from ninja import Router 

21 

22from apps.accounts.admin_api import _get_company 

23from apps.accounts.auth import borrower_loan, ensure_staff_scope, portal, requires 

24from apps.borrower_portal.models import UploadInvite 

25from apps.borrower_portal.schemas import ( 

26 DocumentUploadInput, 

27 PortalDisclosureOut, 

28 PortalDocumentOut, 

29 PortalNeedOut, 

30 UploadCountOut, 

31 UploadInviteCreatedOut, 

32 UploadInviteInput, 

33 UploadInviteOut, 

34 UploadPageOut, 

35) 

36from apps.borrower_portal.services import ( 

37 company_name, 

38 hash_token, 

39 new_link_token, 

40 portal_url, 

41 send_borrower_message, 

42) 

43from apps.client_needs.models import ClientNeed, Document, DocumentClassification 

44from apps.communications import emails 

45from apps.core_los.models import Loan 

46from apps.disclosures.api import _SIGNED_DATE_FIELD 

47from apps.disclosures.models import DisclosureRecipient 

48from apps.workflow.models import KeyDates 

49from domain.enums import ClientNeedStatus 

50from domain.pos import EMAIL_MSG, blank, is_email 

51from domain.result import AppError, DomainError 

52 

53staff_router = Router(tags=["borrower-documents"]) 

54revoke_router = Router(tags=["borrower-documents"]) 

55public_router = Router(tags=["borrower-documents"]) 

56portal_router = Router(tags=["borrower-documents"], auth=portal()) 

57 

58LINK_INVALID = "This upload link is no longer valid." 

59 

60 

61# --- shared --------------------------------------------------------------------------- 

62def record_document(loan: Loan, payload: DocumentUploadInput, via: str) -> Document: 

63 """Record a document reference on ``loan`` and fulfil the named need, if any. The need must 

64 be on the same loan and the classification must exist; otherwise nothing is recorded.""" 

65 name = (payload.name or "").strip() 

66 if blank(name) or len(name) > 255: 66 ↛ 67line 66 didn't jump to line 67 because the condition on line 66 was never true

67 raise DomainError([AppError("VALIDATION", "Enter the document name.", "name")]) 

68 if not DocumentClassification.objects.filter( 

69 code=payload.classification, is_active=True 

70 ).exists(): 

71 raise DomainError( 

72 [AppError("VALIDATION", "Choose a known document type.", "classification")] 

73 ) 

74 need = None 

75 if payload.need_id is not None: 75 ↛ 81line 75 didn't jump to line 81 because the condition on line 75 was always true

76 need = ClientNeed.objects.filter(pk=payload.need_id, loan=loan).first() 

77 if need is None: 

78 raise DomainError( 

79 [AppError("VALIDATION", "That request is not on this loan.", "need_id")] 

80 ) 

81 with transaction.atomic(): 

82 document = Document.objects.create( 

83 loan=loan, 

84 name=name, 

85 classification=payload.classification, 

86 # No file bytes are stored yet: a unique placeholder, honest about being one. 

87 content_hash=hash_token(f"{name}:{secrets.token_hex(16)}"), 

88 uploaded_via=via, 

89 ) 

90 if need is not None: 90 ↛ 92line 90 didn't jump to line 92

91 need.fulfill_with(document) 

92 return document 

93 

94 

95def _need_out(need: ClientNeed) -> dict: 

96 return { 

97 "id": need.id, 

98 "name": need.name, 

99 "type": need.type, 

100 "status": need.status, 

101 "description": need.description, 

102 "due_date": need.due_date.isoformat() if need.due_date else None, 

103 } 

104 

105 

106# --- staff: upload links -------------------------------------------------------------- 

107def _scoped_loan(request, guid: UUID) -> Loan: 

108 loan = Loan.objects.filter(guid=guid).first() 

109 if loan is None: 109 ↛ 110line 109 didn't jump to line 110 because the condition on line 109 was never true

110 raise DomainError([AppError("NOT_FOUND", "Loan not found")]) 

111 ensure_staff_scope(request.principal, loan) 

112 return loan 

113 

114 

115def _invite_out(invite: UploadInvite) -> dict: 

116 return { 

117 "id": invite.id, 

118 "email": invite.email, 

119 "message": invite.message, 

120 "status": invite.status, 

121 "created_at": invite.created_at.isoformat(), 

122 } 

123 

124 

125def _upload_invite_email(loan: Loan, email: str, message: str, link: str): 

126 """Arive's ``doc:invite`` email, plus the loan officer's note when there is one.""" 

127 borrower = loan.borrowers.filter(email__iexact=email).first() 

128 lo = loan.loan_officer 

129 return emails.upload_request( 

130 company=company_name(), 

131 greeting=f"Hi {borrower.first_name}," if borrower else "Hi,", 

132 officer=f"{lo.first_name} {lo.last_name}".strip() if lo else "Your loan officer", 

133 loan_number=loan.arive_loan_no, 

134 message=message, 

135 link=link, 

136 ) 

137 

138 

139@staff_router.post( 

140 "/{guid}/upload-invites", response={201: UploadInviteCreatedOut}, auth=requires("LOAN_EDIT") 

141) 

142def create_upload_invite(request, guid: UUID, payload: UploadInviteInput): 

143 loan = _scoped_loan(request, guid) 

144 email = (payload.email or "").strip().lower() 

145 if not is_email(email): 

146 raise DomainError([AppError("VALIDATION", EMAIL_MSG, "email")]) 

147 raw, token_hash = new_link_token() 

148 link = portal_url(f"/upload/{raw}") 

149 with transaction.atomic(): 

150 invite = UploadInvite.objects.create( 

151 loan=loan, 

152 email=email, 

153 message=(payload.message or "").strip(), 

154 token_hash=token_hash, 

155 created_by_id=getattr(request.principal, "user_id", None), 

156 ) 

157 subject, content = _upload_invite_email(loan, email, invite.message, link) 

158 send_borrower_message( 

159 channel="email", 

160 to=email, 

161 subject=subject, 

162 content=content, 

163 trigger="doc:invite", 

164 loan=loan, 

165 ) 

166 return 201, {"invite": _invite_out(invite), "uploadLink": link} 

167 

168 

169@staff_router.get( 

170 "/{guid}/upload-invites", response=list[UploadInviteOut], auth=requires("LOAN_VIEW") 

171) 

172def list_upload_invites(request, guid: UUID): 

173 loan = _scoped_loan(request, guid) 

174 return [_invite_out(i) for i in loan.upload_invites.order_by("-created_at", "-pk")] 

175 

176 

177@revoke_router.post( 

178 "/upload-invites/{invite_id}/revoke", response=UploadInviteOut, auth=requires("LOAN_EDIT") 

179) 

180def revoke_upload_invite(request, invite_id: int): 

181 invite = UploadInvite.objects.select_related("loan").filter(pk=invite_id).first() 

182 if invite 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", "Upload link not found")]) 

184 ensure_staff_scope(request.principal, invite.loan) 

185 invite.status = UploadInvite.STATUS_REVOKED 

186 invite.save(update_fields=["status", "updated_at"]) 

187 return _invite_out(invite) 

188 

189 

190# --- public: upload through a link --------------------------------------------------- 

191def _live_invite(token: str) -> UploadInvite: 

192 invite = ( 

193 UploadInvite.objects.select_related("loan").filter(token_hash=hash_token(token)).first() 

194 ) 

195 if invite is None or invite.status == UploadInvite.STATUS_REVOKED: 

196 raise DomainError([AppError("NOT_FOUND", LINK_INVALID)]) 

197 return invite 

198 

199 

200@public_router.get("/upload/{token}", response=UploadPageOut, auth=None) 

201def open_upload_link(request, token: str): 

202 invite = _live_invite(token) 

203 if invite.status == UploadInvite.STATUS_SENT: 203 ↛ 206line 203 didn't jump to line 206 because the condition on line 203 was always true

204 invite.status = UploadInvite.STATUS_OPENED 

205 invite.save(update_fields=["status", "updated_at"]) 

206 loan = invite.loan 

207 company = _get_company() 

208 primary = loan.borrowers.filter(is_primary=True).first() or loan.borrowers.first() 

209 requested = loan.needs.filter(status=ClientNeedStatus.REQUESTED.value).order_by("pk") 

210 return { 

211 "loanNumber": loan.arive_loan_no, 

212 "company": company.dba or company.name, 

213 "borrowerName": f"{primary.first_name} {primary.last_name}".strip() if primary else "", 

214 "message": invite.message, 

215 "requested": [{"id": n.id, "name": n.name} for n in requested], 

216 "uploadCount": loan.documents.filter(uploaded_via="invite").count(), 

217 "catalog": [ 

218 {"code": c.code, "name": c.name} 

219 for c in DocumentClassification.objects.filter(is_active=True).order_by("name") 

220 ], 

221 } 

222 

223 

224@public_router.post("/upload/{token}", response={201: UploadCountOut}, auth=None) 

225def upload_through_link(request, token: str, payload: DocumentUploadInput): 

226 invite = _live_invite(token) 

227 record_document(invite.loan, payload, via="invite") 

228 invite.status = UploadInvite.STATUS_COMPLETED 

229 invite.save(update_fields=["status", "updated_at"]) 

230 return 201, {"uploadCount": invite.loan.documents.filter(uploaded_via="invite").count()} 

231 

232 

233# --- signed-in borrower --------------------------------------------------------------- 

234def _require_loan(request) -> Loan: 

235 loan = borrower_loan(request) 

236 if loan is None: 236 ↛ 237line 236 didn't jump to line 237 because the condition on line 236 was never true

237 raise DomainError([AppError("NOT_FOUND", "No application found.")]) 

238 return loan 

239 

240 

241@portal_router.get("/needs", response=list[PortalNeedOut]) 

242def my_needs(request): 

243 loan = borrower_loan(request) 

244 if loan is None: 244 ↛ 245line 244 didn't jump to line 245 because the condition on line 244 was never true

245 return [] 

246 return [ 

247 _need_out(n) 

248 for n in loan.needs.filter(status=ClientNeedStatus.REQUESTED.value).order_by("pk") 

249 ] 

250 

251 

252def _document_out(d: Document) -> dict: 

253 return { 

254 "id": d.id, 

255 "name": d.name, 

256 "classification": d.classification, 

257 "uploaded_via": d.uploaded_via, 

258 "created_at": d.created_at.isoformat(), 

259 } 

260 

261 

262@portal_router.get("/documents", response=list[PortalDocumentOut]) 

263def my_documents(request): 

264 loan = borrower_loan(request) 

265 if loan is None: 265 ↛ 266line 265 didn't jump to line 266 because the condition on line 265 was never true

266 return [] 

267 return [_document_out(d) for d in loan.documents.order_by("-created_at", "-pk")] 

268 

269 

270@portal_router.post("/documents", response={201: PortalDocumentOut}) 

271def add_my_document(request, payload: DocumentUploadInput): 

272 loan = _require_loan(request) 

273 return 201, _document_out(record_document(loan, payload, via="portal")) 

274 

275 

276def _my_recipients(request): 

277 principal = request.principal 

278 return DisclosureRecipient.objects.select_related("disclosure").filter( 

279 borrower__loan_id__in=principal.loan_ids, 

280 borrower__email__iexact=principal.borrower_email, 

281 ) 

282 

283 

284def _disclosure_out(recipient: DisclosureRecipient) -> dict: 

285 d = recipient.disclosure 

286 return { 

287 "id": d.id, 

288 "package_type": d.package_type, 

289 "status": d.status, 

290 "sent_at": d.sent_at.isoformat() if d.sent_at else None, 

291 "signed_at": recipient.signed_at.isoformat() if recipient.signed_at else None, 

292 } 

293 

294 

295@portal_router.get("/disclosures", response=list[PortalDisclosureOut]) 

296def my_disclosures(request): 

297 return [_disclosure_out(r) for r in _my_recipients(request).order_by("-disclosure__pk")] 

298 

299 

300@portal_router.post("/disclosures/{disclosure_id}/sign", response=PortalDisclosureOut) 

301def sign_my_disclosure(request, disclosure_id: int): 

302 recipient = _my_recipients(request).filter(disclosure_id=disclosure_id).first() 

303 if recipient is None: 

304 raise DomainError([AppError("NOT_FOUND", "Disclosure not found.")]) 

305 if recipient.signed_at is None: # signing again keeps the first time 

306 now = timezone.now() 

307 with transaction.atomic(): 

308 recipient.signed_at = now 

309 recipient.save(update_fields=["signed_at"]) 

310 disclosure = recipient.disclosure 

311 # Same completion rule as staff signing (apps/disclosures/api.sign_recipient). 

312 if not disclosure.recipients.filter(signed_at__isnull=True).exists(): 312 ↛ 321line 312 didn't jump to line 321

313 disclosure.status = "Completed" 

314 disclosure.completed_at = now 

315 disclosure.save(update_fields=["status", "completed_at"]) 

316 signed_field = _SIGNED_DATE_FIELD.get(disclosure.package_type) 

317 if signed_field: 317 ↛ 321line 317 didn't jump to line 321

318 key_dates, _ = KeyDates.objects.get_or_create(loan=disclosure.loan) 

319 setattr(key_dates, signed_field, now.date()) 

320 key_dates.save(update_fields=[signed_field, "updated_at"]) 

321 return _disclosure_out(recipient)