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
« prev ^ index » next coverage.py v7.10.7, created at 2026-10-06 13:07 +0000
1import uuid
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 _
11from .phone import normalize_phone
12from .phone import validate_e164
15class ImmutableRecordError(Exception):
16 """Raised when code tries to change a record that must never change."""
19class UserManager(BaseUserManager):
20 """Manager for a user model that signs in by email and has no username."""
22 use_in_migrations = True
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)
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
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)
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)
60class User(AbstractUser):
61 """A person using the platform: contributor, staff member or both."""
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)
84 USERNAME_FIELD = "email"
85 REQUIRED_FIELDS: list[str] = []
87 objects = UserManager()
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 ]
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)
112 def get_absolute_url(self) -> str:
113 return reverse("users:account")
115 def display_identifier(self) -> str:
116 return self.name or self.email or self.phone or str(self.pk)
118 def __str__(self) -> str:
119 return self.display_identifier()
122class ConsentRecord(models.Model):
123 """One consent decision by a user. Rows are never edited or deleted.
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 """
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")
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)
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")
154 def __str__(self) -> str:
155 return f"{self.user.display_identifier()} ({self.scope}: {self.tier})"
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)
163 def delete(self, *args, **kwargs):
164 msg = "Consent records are append-only and cannot be deleted one by one."
165 raise ImmutableRecordError(msg)
168class AccountDeletionRequest(models.Model):
169 """A request by a user to delete their account and data.
171 Capturing a request deactivates the account straight away; staff then
172 work the request through the statuses below.
173 """
175 class Source(models.TextChoices):
176 IN_APP = "in_app", _("In app")
177 WEB = "web", _("Web")
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")
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 }
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)
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")
234 def __str__(self) -> str:
235 return f"{self.id} ({self.status})"
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]
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 = ""