Coverage for apps/borrower_portal/models.py: 99%
69 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-05 19:46 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-05 19:46 +0000
1"""Borrower portal (Borrower POS) records: borrower accounts, the portal's own view of an
2application, one-time auth tokens and tokenized upload links (add-borrower-portal-api).
4Borrowers are deliberately NOT ``accounts.User`` rows (design.md D1): staff RBAC, org scope and
5admin lists never see them. A borrower reaches their loans through ``Borrower.email``
6(``apps.accounts.auth.build_borrower_principal``), so there is no FK to ``Borrower``.
7"""
9from __future__ import annotations
11from django.contrib.auth.hashers import check_password, make_password
12from django.db import models
15class BorrowerAccount(models.Model):
16 """A borrower's sign-in for the borrower portal."""
18 email = models.EmailField(unique=True) # stored lower-case
19 password = models.CharField(max_length=128, blank=True) # Django hash; blank = no password
20 name = models.CharField(max_length=100)
21 phone = models.CharField(max_length=30, blank=True)
22 notify_email = models.BooleanField(default=True)
23 notify_sms = models.BooleanField(default=False)
24 is_active = models.BooleanField(default=True)
25 # Shared Cognito pool (Applicants group) identity, set when the borrower signs in via Cognito.
26 cognito_sub = models.CharField(max_length=64, null=True, blank=True, unique=True)
27 invited_at = models.DateTimeField(null=True, blank=True)
28 accepted_at = models.DateTimeField(null=True, blank=True)
29 last_login_at = models.DateTimeField(null=True, blank=True)
30 created_at = models.DateTimeField(auto_now_add=True)
31 updated_at = models.DateTimeField(auto_now=True)
33 class Meta:
34 db_table = "borrower_account"
36 def __str__(self) -> str:
37 return self.email
39 def save(self, *args, **kwargs):
40 self.email = (self.email or "").strip().lower()
41 super().save(*args, **kwargs)
43 def set_password(self, raw: str) -> None:
44 self.password = make_password(raw)
46 def check_password(self, raw: str) -> bool:
47 return bool(self.password) and check_password(raw, self.password)
50class PosApplication(models.Model):
51 """The borrower portal's own state for a loan's application (design.md D6).
53 Keeps the portal's answers verbatim — the 1003 ``Declaration`` booleans default to False, so
54 "not answered" and "No" would be indistinguishable there — and writes them through to the
55 1003 records for staff screens, MISMO and AUS.
56 """
58 loan = models.OneToOneField(
59 "core_los.Loan", on_delete=models.CASCADE, related_name="pos_application"
60 )
61 started_by = models.ForeignKey(
62 BorrowerAccount, on_delete=models.SET_NULL, null=True, blank=True, related_name="+"
63 )
64 declaration_answers = models.JSONField(default=dict, blank=True) # letter -> bool
65 demographics = models.JSONField(default=dict, blank=True) # {ethnicity, sex, race[]}
66 e_consent_at = models.DateTimeField(null=True, blank=True)
67 submitted_at = models.DateTimeField(null=True, blank=True)
68 created_at = models.DateTimeField(auto_now_add=True)
69 updated_at = models.DateTimeField(auto_now=True)
71 class Meta:
72 db_table = "pos_application"
75class AuthToken(models.Model):
76 """A single-use reset link or one-time sign-in code. Only the sha256 hash is stored."""
78 KIND_RESET = "reset"
79 KIND_OTP = "otp"
80 KINDS = [(KIND_RESET, "Password reset"), (KIND_OTP, "One-time code")]
82 # Exactly one owner: a borrower account, or (password reset only) a staff user
83 # (add-sendgrid-email: staff forgot / reset password).
84 account = models.ForeignKey(
85 BorrowerAccount, on_delete=models.CASCADE, related_name="tokens", null=True, blank=True
86 )
87 staff_user = models.ForeignKey(
88 "accounts.User", on_delete=models.CASCADE, related_name="+", null=True, blank=True
89 )
90 kind = models.CharField(max_length=10, choices=KINDS)
91 token_hash = models.CharField(max_length=64, db_index=True)
92 expires_at = models.DateTimeField()
93 used_at = models.DateTimeField(null=True, blank=True)
94 created_at = models.DateTimeField(auto_now_add=True)
96 class Meta:
97 db_table = "borrower_auth_token"
100class UploadInvite(models.Model):
101 """A tokenized link that lets someone upload documents for one loan without signing in."""
103 STATUS_SENT = "Sent"
104 STATUS_OPENED = "Opened"
105 STATUS_COMPLETED = "Completed"
106 STATUS_REVOKED = "Revoked"
107 STATUSES = [
108 (STATUS_SENT, "Sent"),
109 (STATUS_OPENED, "Opened"),
110 (STATUS_COMPLETED, "Completed"),
111 (STATUS_REVOKED, "Revoked"),
112 ]
114 loan = models.ForeignKey(
115 "core_los.Loan", on_delete=models.CASCADE, related_name="upload_invites"
116 )
117 email = models.EmailField()
118 message = models.TextField(blank=True)
119 token_hash = models.CharField(max_length=64, unique=True)
120 status = models.CharField(max_length=12, choices=STATUSES, default=STATUS_SENT)
121 created_by = models.ForeignKey(
122 "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="+"
123 )
124 created_at = models.DateTimeField(auto_now_add=True)
125 updated_at = models.DateTimeField(auto_now=True)
127 class Meta:
128 db_table = "upload_invite"
129 indexes = [models.Index(fields=["loan", "created_at"])]