Coverage for apps/accounts/models.py: 85%

125 statements  

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

1"""Organization hierarchy and the custom email-login staff user. 

2 

3RBAC models (Role / Permission / License) are deferred to a later sprint; only the 

4org hierarchy and the custom user land here so AUTH_USER_MODEL is correct before the 

5first migration (design D1 / D2). 

6""" 

7 

8from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin 

9from django.db import models 

10 

11 

12class OrgUnit(models.Model): 

13 """Broker org hierarchy: headquarters and branches.""" 

14 

15 name = models.CharField(max_length=255) 

16 parent = models.ForeignKey( 

17 "self", on_delete=models.CASCADE, null=True, blank=True, related_name="sub_units" 

18 ) 

19 nmls_id = models.CharField(max_length=50, blank=True) 

20 is_headquarters = models.BooleanField(default=False) 

21 created_at = models.DateTimeField(auto_now_add=True) 

22 updated_at = models.DateTimeField(auto_now=True) 

23 

24 class Meta: 

25 db_table = "org_units" 

26 

27 def __str__(self) -> str: 

28 return self.name 

29 

30 

31class UserManager(BaseUserManager): 

32 """Manager for the email-login custom user (no username field).""" 

33 

34 use_in_migrations = True 

35 

36 def _create_user(self, email, password, **extra_fields): 

37 if not email: 37 ↛ 38line 37 didn't jump to line 38 because the condition on line 37 was never true

38 raise ValueError("Users must have an email address") 

39 email = self.normalize_email(email) 

40 user = self.model(email=email, **extra_fields) 

41 user.set_password(password) 

42 user.save(using=self._db) 

43 return user 

44 

45 def create_user(self, email, password=None, **extra_fields): 

46 extra_fields.setdefault("is_staff", False) 

47 extra_fields.setdefault("is_superuser", False) 

48 return self._create_user(email, password, **extra_fields) 

49 

50 def create_superuser(self, email, password=None, **extra_fields): 

51 extra_fields.setdefault("is_staff", True) 

52 extra_fields.setdefault("is_superuser", True) 

53 if extra_fields.get("is_staff") is not True: 

54 raise ValueError("Superuser must have is_staff=True") 

55 if extra_fields.get("is_superuser") is not True: 

56 raise ValueError("Superuser must have is_superuser=True") 

57 return self._create_user(email, password, **extra_fields) 

58 

59 

60class User(AbstractBaseUser, PermissionsMixin): 

61 """Staff user. Login identifier is email. 

62 

63 ``org_unit`` is nullable so a bootstrap superuser can exist before any org is 

64 created; application-created staff users are expected to carry an org unit. 

65 """ 

66 

67 org_unit = models.ForeignKey( 

68 OrgUnit, on_delete=models.PROTECT, related_name="users", null=True, blank=True 

69 ) 

70 email = models.EmailField(unique=True) 

71 # Immutable AWS Cognito subject — the cross-DB link to Tireless (`users.cognito_id`) 

72 # and the JIT provisioning / lookup key (nullable: pre-Cognito users backfill on login). 

73 cognito_sub = models.CharField(max_length=255, unique=True, null=True, blank=True) 

74 first_name = models.CharField(max_length=100) 

75 last_name = models.CharField(max_length=100) 

76 title = models.CharField(max_length=150, blank=True) 

77 phone = models.CharField(max_length=30, blank=True) 

78 nmls_id = models.CharField(max_length=50, blank=True) 

79 signature = models.CharField(max_length=255, blank=True) 

80 notify_email = models.BooleanField(default=True) 

81 notify_sms = models.BooleanField(default=False) 

82 is_active = models.BooleanField(default=True) 

83 is_staff = models.BooleanField(default=False) 

84 created_at = models.DateTimeField(auto_now_add=True) 

85 

86 objects = UserManager() 

87 

88 USERNAME_FIELD = "email" 

89 REQUIRED_FIELDS = ["first_name", "last_name"] 

90 

91 class Meta: 

92 db_table = "users" 

93 

94 def __str__(self) -> str: 

95 return self.email 

96 

97 

98class Permission(models.Model): 

99 """A unit of access, referenced by stable code (e.g. "LOAN_EDIT").""" 

100 

101 code = models.CharField(max_length=64, unique=True) 

102 label = models.CharField(max_length=128, blank=True) 

103 branch_override = models.BooleanField(default=True) 

104 admin_role_check_reqd = models.BooleanField(default=False) 

105 

106 class Meta: 

107 db_table = "permissions" 

108 

109 def __str__(self) -> str: 

110 return self.code 

111 

112 

113class Role(models.Model): 

114 """A named bundle of permissions (e.g. "Loan Officer").""" 

115 

116 name = models.CharField(max_length=64, unique=True) 

117 

118 class Meta: 

119 db_table = "roles" 

120 

121 def __str__(self) -> str: 

122 return self.name 

123 

124 

125class RolePermission(models.Model): 

126 """Role → permission matrix entry.""" 

127 

128 role = models.ForeignKey(Role, on_delete=models.CASCADE, related_name="role_permissions") 

129 permission = models.ForeignKey( 

130 Permission, on_delete=models.CASCADE, related_name="role_permissions" 

131 ) 

132 

133 class Meta: 

134 db_table = "role_permissions" 

135 unique_together = ("role", "permission") 

136 

137 

138class UserRole(models.Model): 

139 """A user's role within an org unit (null org unit = org-wide / HQ).""" 

140 

141 user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="user_roles") 

142 role = models.ForeignKey(Role, on_delete=models.PROTECT, related_name="user_roles") 

143 org_unit = models.ForeignKey( 

144 OrgUnit, on_delete=models.CASCADE, null=True, blank=True, related_name="user_roles" 

145 ) 

146 

147 class Meta: 

148 db_table = "user_roles" 

149 unique_together = ("user", "role", "org_unit") 

150 

151 

152class License(models.Model): 

153 """NMLS + per-state licensing for a user.""" 

154 

155 user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="licenses") 

156 state_code = models.CharField(max_length=2) 

157 nmls_id = models.CharField(max_length=50, blank=True) 

158 active = models.BooleanField(default=True) 

159 sponsored = models.BooleanField(default=False) 

160 

161 class Meta: 

162 db_table = "licenses" 

163 unique_together = ("user", "state_code") 

164 

165 def __str__(self) -> str: 

166 return f"{self.user_id}:{self.state_code}" 

167 

168 

169class Company(models.Model): 

170 """The one company this deployment serves (add-admin-organization-api) — a true 

171 singleton, same lazy-create-on-first-read pattern as ``core_los.LoanNumberSequence``.""" 

172 

173 name = models.CharField(max_length=255, blank=True) 

174 nmls_id = models.CharField(max_length=50, blank=True) 

175 type = models.CharField(max_length=50, blank=True) 

176 dba = models.CharField(max_length=255, blank=True) 

177 phone = models.CharField(max_length=30, blank=True) 

178 mers_id = models.CharField(max_length=50, blank=True) 

179 address_line = models.CharField(max_length=255, blank=True) 

180 city = models.CharField(max_length=100, blank=True) 

181 state_code = models.CharField(max_length=2, blank=True) 

182 postal_code = models.CharField(max_length=10, blank=True) 

183 business_hours = models.CharField(max_length=255, blank=True) 

184 pos_config = models.JSONField(default=dict, blank=True) 

185 lead_form_config = models.JSONField(default=dict, blank=True) 

186 agent_config = models.JSONField(default=dict, blank=True) 

187 non_delegated_config = models.JSONField(default=dict, blank=True) 

188 

189 class Meta: 

190 db_table = "company" 

191 

192 def __str__(self) -> str: 

193 return self.name or "Company" 

194 

195 

196class ApiClient(models.Model): 

197 """Partner OAuth client / API key for external automation (e.g. Zapier).""" 

198 

199 client_id = models.CharField(max_length=100, unique=True) 

200 org_unit = models.ForeignKey(OrgUnit, on_delete=models.CASCADE, null=True, blank=True) 

201 label = models.CharField(max_length=255, blank=True) 

202 secret_enc = models.TextField(blank=True) 

203 api_key_enc = models.TextField(blank=True) 

204 created_at = models.DateTimeField(auto_now_add=True) 

205 

206 class Meta: 

207 db_table = "api_clients"