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

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

3 

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

8 

9from __future__ import annotations 

10 

11from django.contrib.auth.hashers import check_password, make_password 

12from django.db import models 

13 

14 

15class BorrowerAccount(models.Model): 

16 """A borrower's sign-in for the borrower portal.""" 

17 

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) 

32 

33 class Meta: 

34 db_table = "borrower_account" 

35 

36 def __str__(self) -> str: 

37 return self.email 

38 

39 def save(self, *args, **kwargs): 

40 self.email = (self.email or "").strip().lower() 

41 super().save(*args, **kwargs) 

42 

43 def set_password(self, raw: str) -> None: 

44 self.password = make_password(raw) 

45 

46 def check_password(self, raw: str) -> bool: 

47 return bool(self.password) and check_password(raw, self.password) 

48 

49 

50class PosApplication(models.Model): 

51 """The borrower portal's own state for a loan's application (design.md D6). 

52 

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

57 

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) 

70 

71 class Meta: 

72 db_table = "pos_application" 

73 

74 

75class AuthToken(models.Model): 

76 """A single-use reset link or one-time sign-in code. Only the sha256 hash is stored.""" 

77 

78 KIND_RESET = "reset" 

79 KIND_OTP = "otp" 

80 KINDS = [(KIND_RESET, "Password reset"), (KIND_OTP, "One-time code")] 

81 

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) 

95 

96 class Meta: 

97 db_table = "borrower_auth_token" 

98 

99 

100class UploadInvite(models.Model): 

101 """A tokenized link that lets someone upload documents for one loan without signing in.""" 

102 

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 ] 

113 

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) 

126 

127 class Meta: 

128 db_table = "upload_invite" 

129 indexes = [models.Index(fields=["loan", "created_at"])]