Coverage for baibu/users/models.py: 99%

142 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-10-06 13:07 +0000

1import uuid 

2 

3from django.conf import settings 

4from django.contrib.auth.models import AbstractUser 

5from django.contrib.auth.models import BaseUserManager 

6from django.db import models 

7from django.urls import reverse 

8from django.utils import timezone 

9from django.utils.translation import gettext_lazy as _ 

10 

11from .phone import normalize_phone 

12from .phone import validate_e164 

13 

14 

15class ImmutableRecordError(Exception): 

16 """Raised when code tries to change a record that must never change.""" 

17 

18 

19class UserManager(BaseUserManager): 

20 """Manager for a user model that signs in by email and has no username.""" 

21 

22 use_in_migrations = True 

23 

24 def normalize_email_or_none(self, email: str | None) -> str | None: 

25 email = (email or "").strip() 

26 if not email: 

27 return None 

28 return self.normalize_email(email) 

29 

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

31 email = self.normalize_email_or_none(email) 

32 if not email: 

33 msg = "The email address must be set." 

34 raise ValueError(msg) 

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

36 if password: 

37 user.set_password(password) 

38 else: 

39 user.set_unusable_password() 

40 user.save(using=self._db) 

41 return user 

42 

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

44 extra_fields.setdefault("is_staff", False) 

45 extra_fields.setdefault("is_superuser", False) 

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

47 

48 def create_superuser(self, email, password, **extra_fields): 

49 extra_fields.setdefault("is_staff", True) 

50 extra_fields.setdefault("is_superuser", True) 

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

52 msg = "Superuser must have is_staff=True." 

53 raise ValueError(msg) 

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

55 msg = "Superuser must have is_superuser=True." 

56 raise ValueError(msg) 

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

58 

59 

60class User(AbstractUser): 

61 """A person using the platform: contributor, staff member or both.""" 

62 

63 id = models.UUIDField(primary_key=True, default=uuid.uuid7, editable=False) 

64 username = None # type: ignore[assignment] 

65 email = models.EmailField(_("Email address"), unique=True, null=True, blank=True) 

66 # One name field: first and last names do not fit naming patterns everywhere. 

67 name = models.CharField(_("Name"), blank=True, max_length=255) 

68 first_name = None # type: ignore[assignment] 

69 last_name = None # type: ignore[assignment] 

70 city = models.CharField(_("City"), max_length=128, blank=True) 

71 country = models.CharField(_("Country"), max_length=128, blank=True) 

72 # For phone sign-in (PHONE_SIGN_IN_ENABLED). International format, e.g. +15550100. 

73 # Null, like email, means "none". 

74 phone = models.CharField( # noqa: DJ001 

75 _("Phone number"), 

76 max_length=16, 

77 null=True, 

78 blank=True, 

79 db_index=True, 

80 validators=[validate_e164], 

81 ) 

82 phone_verified = models.BooleanField(_("Phone number verified"), default=False) 

83 

84 USERNAME_FIELD = "email" 

85 REQUIRED_FIELDS: list[str] = [] 

86 

87 objects = UserManager() 

88 

89 class Meta(AbstractUser.Meta): 

90 constraints = [ 

91 # Only a verified number belongs to someone. Unverified numbers may 

92 # repeat, so nobody can block a number by typing it in first. 

93 models.UniqueConstraint( 

94 fields=["phone"], 

95 condition=models.Q(phone_verified=True), 

96 name="users_unique_verified_phone", 

97 ), 

98 ] 

99 

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

101 original = (self.email, self.phone, self.phone_verified) 

102 self.email = User.objects.normalize_email_or_none(self.email) 

103 self.phone = normalize_phone(self.phone) or None 

104 if not self.phone: 

105 self.phone_verified = False 

106 if original != (self.email, self.phone, self.phone_verified) and ( 

107 update_fields := kwargs.get("update_fields") 

108 ): 

109 kwargs["update_fields"] = list({*update_fields, "email", "phone", "phone_verified"}) 

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

111 

112 def get_absolute_url(self) -> str: 

113 return reverse("users:account") 

114 

115 def display_identifier(self) -> str: 

116 return self.name or self.email or self.phone or str(self.pk) 

117 

118 def __str__(self) -> str: 

119 return self.display_identifier() 

120 

121 

122class ConsentRecord(models.Model): 

123 """One consent decision by a user. Rows are never edited or deleted. 

124 

125 A new decision adds a row and marks the previous one revoked, so the full 

126 history of what a person agreed to, and when, is kept. 

127 """ 

128 

129 class Tier(models.TextChoices): 

130 NONE = "none", _("No data use") 

131 EVAL_ONLY = "eval_only", _("Evaluation only") 

132 TRAINING_ELIGIBLE = "training_eligible", _("Training eligible") 

133 

134 id = models.UUIDField(primary_key=True, default=uuid.uuid7, editable=False) 

135 user = models.ForeignKey( 

136 settings.AUTH_USER_MODEL, 

137 on_delete=models.CASCADE, 

138 related_name="consent_records", 

139 ) 

140 tier = models.CharField(max_length=32, choices=Tier.choices) 

141 source = models.CharField(max_length=64, help_text=_("Where the decision was made, e.g. account_settings.")) 

142 scope = models.CharField(max_length=64, help_text=_("What the decision covers, e.g. platform or chat.")) 

143 granted_at = models.DateTimeField(default=timezone.now) 

144 revoked_at = models.DateTimeField(null=True, blank=True) 

145 metadata = models.JSONField(default=dict, blank=True) 

146 created_at = models.DateTimeField(auto_now_add=True) 

147 

148 class Meta: 

149 ordering = ["-granted_at", "-created_at"] 

150 indexes = [models.Index(fields=["user", "scope", "-granted_at"])] 

151 verbose_name = _("Consent record") 

152 verbose_name_plural = _("Consent records") 

153 

154 def __str__(self) -> str: 

155 return f"{self.user.display_identifier()} ({self.scope}: {self.tier})" 

156 

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

158 if not self._state.adding: 

159 msg = "Consent records are append-only; record a new decision with record_consent()." 

160 raise ImmutableRecordError(msg) 

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

162 

163 def delete(self, *args, **kwargs): 

164 msg = "Consent records are append-only and cannot be deleted one by one." 

165 raise ImmutableRecordError(msg) 

166 

167 

168class AccountDeletionRequest(models.Model): 

169 """A request by a user to delete their account and data. 

170 

171 Capturing a request deactivates the account straight away; staff then 

172 work the request through the statuses below. 

173 """ 

174 

175 class Source(models.TextChoices): 

176 IN_APP = "in_app", _("In app") 

177 WEB = "web", _("Web") 

178 

179 class Status(models.TextChoices): 

180 SUBMITTED = "submitted", _("Submitted") 

181 APPROVED = "approved", _("Approved") 

182 IN_PROGRESS = "in_progress", _("In progress") 

183 COMPLETED = "completed", _("Completed") 

184 REJECTED = "rejected", _("Rejected") 

185 FAILED = "failed", _("Failed") 

186 

187 OPEN_STATUSES = (Status.SUBMITTED, Status.APPROVED, Status.IN_PROGRESS, Status.FAILED) 

188 # Which status may follow which. Completed and rejected are final. 

189 ALLOWED_TRANSITIONS = { 

190 Status.SUBMITTED: {Status.APPROVED, Status.REJECTED}, 

191 Status.APPROVED: {Status.IN_PROGRESS, Status.REJECTED}, 

192 Status.IN_PROGRESS: {Status.COMPLETED, Status.FAILED}, 

193 Status.FAILED: {Status.IN_PROGRESS, Status.REJECTED}, 

194 Status.COMPLETED: set(), 

195 Status.REJECTED: set(), 

196 } 

197 

198 id = models.UUIDField(primary_key=True, default=uuid.uuid7, editable=False) 

199 source = models.CharField(max_length=32, choices=Source.choices) 

200 status = models.CharField(max_length=32, choices=Status.choices, default=Status.SUBMITTED) 

201 # Kept as a receipt after the account itself is deleted. 

202 user = models.ForeignKey( 

203 settings.AUTH_USER_MODEL, 

204 on_delete=models.SET_NULL, 

205 null=True, 

206 blank=True, 

207 related_name="account_deletion_requests", 

208 ) 

209 requester_name = models.CharField(max_length=255, blank=True) 

210 requester_email = models.EmailField(blank=True) 

211 request_details = models.TextField(blank=True) 

212 resolution_notes = models.TextField(blank=True) 

213 metadata = models.JSONField(default=dict, blank=True) 

214 requested_at = models.DateTimeField(auto_now_add=True) 

215 updated_at = models.DateTimeField(auto_now=True) 

216 completed_at = models.DateTimeField(null=True, blank=True) 

217 

218 class Meta: 

219 ordering = ["-requested_at"] 

220 constraints = [ 

221 models.UniqueConstraint( 

222 fields=["user"], 

223 condition=models.Q(status__in=["submitted", "approved", "in_progress", "failed"]), 

224 name="users_one_open_account_deletion_request_per_user", 

225 ), 

226 ] 

227 indexes = [ 

228 models.Index(fields=["status", "requested_at"]), 

229 models.Index(fields=["source", "requested_at"]), 

230 ] 

231 verbose_name = _("Account deletion request") 

232 verbose_name_plural = _("Account deletion requests") 

233 

234 def __str__(self) -> str: 

235 return f"{self.id} ({self.status})" 

236 

237 def can_transition_to(self, new_status: str) -> bool: 

238 return new_status == self.status or new_status in self.ALLOWED_TRANSITIONS[self.status] 

239 

240 def clear_personal_details(self) -> None: 

241 """Blank the requester's details once the request is finished; the receipt stays.""" 

242 self.requester_name = "" 

243 self.requester_email = "" 

244 self.request_details = ""