Coverage for apps/borrower_portal/upload_api.py: 91%
157 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 14:06 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 14:06 +0000
1"""Borrower documents and signing (add-borrower-portal-api spec borrower-documents; design.md
2D12, D13).
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.
10Documents stay references (ADR-001): a name, a classification code and a placeholder hash.
11"""
13from __future__ import annotations
15import secrets
16from uuid import UUID
18from django.db import transaction
19from django.utils import timezone
20from ninja import Router
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
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())
58LINK_INVALID = "This upload link is no longer valid."
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
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 }
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
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 }
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 )
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}
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")]
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)
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
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 }
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()}
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
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 ]
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 }
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")]
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"))
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 )
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 }
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")]
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)