Coverage for src / mesh / models / orm / user_models.py: 87%
94 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-05-04 12:41 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-05-04 12:41 +0000
1from __future__ import annotations
3import secrets
4from datetime import datetime
5from typing import TYPE_CHECKING, Self
7from django.contrib.auth.hashers import make_password
8from django.contrib.auth.models import AbstractUser, BaseUserManager
9from django.db import DatabaseError, IntegrityError, models
10from django.utils.translation import gettext_lazy as _
12from mesh.app_settings import app_settings
14from .base_models import BaseChangeTrackingModel
16USER_TOKEN_QUERY_PARAM = "_auth_token"
18if TYPE_CHECKING:
19 from django.db.models.manager import RelatedManager
21 from mesh.models.orm.editorial_models import EditorSectionRight
24class UserManager(BaseUserManager["User"]):
25 """
26 Defines a model manager for our custom User model having no username field.
27 """
29 use_in_migrations = True
31 def _create_user(self, email, password, **extra_fields):
32 """
33 Create and save a User with the given email and password.
34 """
35 if not email:
36 raise ValueError("The given email must be set")
37 email = self.normalize_email(email)
38 user = self.model(email=email, **extra_fields)
39 user.password = make_password(password)
40 user.save(using=self._db)
41 return user
43 def create_user(self, email, password=None, **extra_fields):
44 """
45 Create and save a regular User with the given email and password.
46 """
47 extra_fields.setdefault("is_active", True)
48 extra_fields.setdefault("is_staff", False)
49 extra_fields.setdefault("is_superuser", False)
50 return self._create_user(email, password, **extra_fields)
52 def create_superuser(self, email, password, **extra_fields):
53 """
54 Create and save a SuperUser with the given email and password.
55 """
56 extra_fields.setdefault("is_active", True)
57 extra_fields.setdefault("is_staff", True)
58 extra_fields.setdefault("is_superuser", True)
60 if extra_fields.get("is_staff") is not True:
61 raise ValueError("Superuser must have is_staff=True.")
62 if extra_fields.get("is_superuser") is not True:
63 raise ValueError("Superuser must have is_superuser=True.")
65 return self._create_user(email, password, **extra_fields)
68class User(AbstractUser):
69 """
70 Custom user model without useless username field.
71 Login can only be performed with e-mail address.
72 """
74 username = None
75 first_name = models.CharField(_("first name"), max_length=150, blank=False, null=False)
76 last_name = models.CharField(_("last name"), max_length=150, blank=False, null=False)
77 email = models.EmailField(_("e-mail address"), unique=True)
78 journal_manager = models.BooleanField(_("Journal manager"), default=False)
79 keywords = models.TextField(verbose_name=_("Keywords"), blank=True, null=True, default="")
80 USERNAME_FIELD = "email"
81 REQUIRED_FIELDS = []
83 # TODO : use ENUM
84 current_role = models.CharField(max_length=32, null=True)
85 impersonate_data = None
87 objects: UserManager = UserManager() # type: ignore
88 editor_sections: RelatedManager[EditorSectionRight]
90 def __str__(self) -> str:
91 return f"{self.first_name} {self.last_name} ({self.email})"
93 @property
94 def is_token_authentication_allowed(self) -> bool:
95 """
96 For security purposes, we prevent token authentication for user with elevated
97 rights (staff, admin and journal manager users).
98 """
99 return not (self.is_superuser or self.is_staff or self.journal_manager)
102class UserToken(BaseChangeTrackingModel):
103 """
104 Token used for alternative authentication.
105 """
107 user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="token")
108 key = models.CharField(max_length=256, unique=True)
109 date_refreshed = models.DateField(help_text="Automatically filled when created.")
111 def save(self, *args, **kwargs):
112 if self._state.adding:
113 self.reset_refreshed_date()
114 super().save(*args, **kwargs)
116 def reset_refreshed_date(self):
117 """
118 Reset the user token refreshed date to now.
119 """
120 self.date_refreshed = datetime.utcnow().date()
122 @property
123 def is_expired(self) -> bool:
124 return (
125 datetime.utcnow().date()
126 > self.date_refreshed + app_settings.USER_TOKEN_EXPIRATION_DAYS
127 )
129 @classmethod
130 def get_token(cls, user: User, refresh_token: bool = True) -> Self:
131 """
132 Returns the existing token attached to the given user or creates and returns
133 a fresh one.
134 If the token already exists, its expiration date is reseted.
135 """
136 token = None
137 try:
138 token = cls.objects.get(user=user)
139 except cls.DoesNotExist:
140 pass
142 if token is not None:
143 if refresh_token:
144 token.reset_refreshed_date()
145 token.save()
146 return token
148 tries = 0
149 while token is None and tries < 5:
150 try:
151 token = cls.objects.create(user=user, key=secrets.token_urlsafe(64))
152 except IntegrityError:
153 tries += 1
154 if not token:
155 raise DatabaseError(f"Could not create unique token for user {user}")
157 return token
160class SuggestedReviewer(models.Model):
161 """
162 Table for reviewers added manually that do not have an account.
163 We cannot create a base class for User (with the email address), because if an editor creates a SuggestedReviewer,
164 someone will not be able create an account with the same email address later on.
165 => We need 2 distinct tables. The Journal Manager will have to delete the duplicate SuggestedReviewer
166 """
168 first_name = models.CharField(_("first name"), max_length=150, blank=False, null=False)
169 last_name = models.CharField(_("last name"), max_length=150, blank=False, null=False)
170 email = models.EmailField(_("e-mail address"), unique=True)
171 keywords = models.TextField(verbose_name=_("Keywords"), blank=True, null=True, default="")
173 def __str__(self) -> str:
174 return f"{self.first_name} {self.last_name} ({self.email})"