""" Custom user model. Two things are worth pointing out: * The username field is the e-mail address - the dataset users are internal analysts, and an e-mail is the identifier they already have. * Authorisation is expressed as a single ``role`` field instead of a pile of Django groups. With three well defined roles this is easier to reason about, easier to test, and it keeps the JWT payload small. """ from __future__ import annotations from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin from django.db import models from django.utils import timezone from django.utils.translation import gettext_lazy as _ class Role(models.TextChoices): ADMIN = "admin", _("Administrator") ANALYST = "analyst", _("Analyst") VIEWER = "viewer", _("Viewer") class UserManager(BaseUserManager): """Manager that normalises e-mails and refuses to create users without one.""" use_in_migrations = True def create_user(self, email: str, password: str | None = None, **extra) -> User: if not email: raise ValueError("Users must have an email address.") email = self.normalize_email(email).lower() user = self.model(email=email, **extra) # ``set_password`` hashes with Argon2 (see PASSWORD_HASHERS). user.set_password(password) user.full_clean(exclude=["password"]) user.save(using=self._db) return user def create_superuser(self, email: str, password: str | None = None, **extra) -> User: extra.setdefault("is_staff", True) extra.setdefault("is_superuser", True) extra.setdefault("role", Role.ADMIN) if extra.get("is_staff") is not True or extra.get("is_superuser") is not True: raise ValueError("Superuser must have is_staff=True and is_superuser=True.") return self.create_user(email, password, **extra) class User(AbstractBaseUser, PermissionsMixin): email = models.EmailField(_("email address"), unique=True, db_index=True) full_name = models.CharField(_("full name"), max_length=150, blank=True) role = models.CharField( _("role"), max_length=16, choices=Role.choices, default=Role.VIEWER, help_text=_("Determines which fields and actions the user has access to."), ) is_active = models.BooleanField(_("active"), default=True) is_staff = models.BooleanField(_("staff status"), default=False) date_joined = models.DateTimeField(_("date joined"), default=timezone.now) objects = UserManager() USERNAME_FIELD = "email" REQUIRED_FIELDS: list[str] = [] class Meta: verbose_name = _("user") verbose_name_plural = _("users") ordering = ("-date_joined",) def __str__(self) -> str: return self.email # -- Authorisation helpers --------------------------------------------- @property def is_admin_role(self) -> bool: return self.role == Role.ADMIN or self.is_superuser @property def can_view_pii(self) -> bool: """ Whether raw contact details may be returned to this user. Viewers only ever see masked e-mails and phone numbers; unmasking is a deliberate privilege granted to analysts and administrators. """ return self.role in (Role.ADMIN, Role.ANALYST) or self.is_superuser