Coverage for src/mesh/models/user_models.py: 74%
102 statements
« prev ^ index » next coverage.py v7.7.0, created at 2025-04-28 07:45 +0000
« prev ^ index » next coverage.py v7.7.0, created at 2025-04-28 07:45 +0000
1from __future__ import annotations
3import secrets
4from datetime import datetime
5from typing import Self, TypeVar
7from django.conf import settings
8from django.contrib.auth.hashers import make_password
9from django.contrib.auth.models import AbstractUser, BaseUserManager
10from django.db import DatabaseError, IntegrityError, models
11from django.utils.translation import gettext_lazy as _
13from ..app_settings import app_settings
14from .base_models import BaseChangeTrackingModel
15from .submission_models import Submission
17_T = TypeVar("_T", bound=AbstractUser)
19USER_TOKEN_QUERY_PARAM = "_auth_token"
22class UserManager(BaseUserManager[_T]):
23 """
24 Defines a model manager for our custom User model having no username field.
25 """
27 use_in_migrations = True
29 def _create_user(self, email, password, **extra_fields) -> _T:
30 """
31 Create and save a User with the given email and password.
32 """
33 if not email:
34 raise ValueError("The given email must be set")
35 email = self.normalize_email(email)
36 user = self.model(email=email, **extra_fields)
37 user.password = make_password(password)
38 user.save(using=self._db)
39 return user
41 def create_user(self, email, password=None, **extra_fields) -> _T:
42 """
43 Create and save a regular User with the given email and password.
44 """
45 extra_fields.setdefault("is_active", True)
46 extra_fields.setdefault("is_staff", False)
47 extra_fields.setdefault("is_superuser", False)
48 return self._create_user(email, password, **extra_fields)
50 def create_superuser(self, email, password, **extra_fields) -> _T:
51 """
52 Create and save a SuperUser with the given email and password.
53 """
54 extra_fields.setdefault("is_active", True)
55 extra_fields.setdefault("is_staff", True)
56 extra_fields.setdefault("is_superuser", True)
58 if extra_fields.get("is_staff") is not True:
59 raise ValueError("Superuser must have is_staff=True.")
60 if extra_fields.get("is_superuser") is not True:
61 raise ValueError("Superuser must have is_superuser=True.")
63 return self._create_user(email, password, **extra_fields)
66class User(AbstractUser):
67 """
68 Custom user model without useless username field.
69 Login can only be performed with e-mail address.
70 """
72 username = None
73 first_name = models.CharField(_("first name"), max_length=150, blank=False, null=False)
74 last_name = models.CharField(_("last name"), max_length=150, blank=False, null=False)
75 email = models.EmailField(_("e-mail address"), unique=True)
76 journal_manager = models.BooleanField(_("Journal manager"), default=False)
77 keywords = models.TextField(verbose_name=_("Keywords"), blank=True, null=True, default="")
78 USERNAME_FIELD = "email"
79 REQUIRED_FIELDS = []
81 current_role = models.CharField(max_length=32, blank=True, null=True)
82 impersonate_data = None
84 objects: UserManager[Self] = UserManager()
86 def __str__(self) -> str:
87 return f"{self.first_name} {self.last_name} ({self.email})"
89 @property
90 def is_token_authentication_allowed(self) -> bool:
91 """
92 For security purposes, we prevent token authentication for user with elevated
93 rights (staff, admin and journal manager users).
94 """
95 return not (self.is_superuser or self.is_staff or self.journal_manager)
98class UserToken(BaseChangeTrackingModel):
99 """
100 Token used for alternative authentication.
101 """
103 user = models.OneToOneField(
104 settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="token"
105 )
106 key = models.CharField(max_length=256, unique=True)
107 date_refreshed = models.DateField(blank=True, help_text="Automatically filled when created.")
109 def save(self, *args, **kwargs):
110 if self._state.adding:
111 self.reset_refreshed_date()
112 super().save(*args, **kwargs)
114 def reset_refreshed_date(self):
115 """
116 Reset the user token refreshed date to now.
117 """
118 self.date_refreshed = datetime.utcnow().date()
120 @property
121 def is_expired(self) -> bool:
122 return (
123 datetime.utcnow().date()
124 > self.date_refreshed + app_settings.USER_TOKEN_EXPIRATION_DAYS
125 )
127 @classmethod
128 def get_token(cls, user: User, refresh_token: bool = True) -> Self:
129 """
130 Returns the existing token attached to the given user or creates and returns
131 a fresh one.
132 If the token already exists, its expiration date is reseted.
133 """
134 token = None
135 try:
136 token = cls.objects.get(user=user)
137 except cls.DoesNotExist:
138 pass
140 if token is not None:
141 if refresh_token: 141 ↛ 144line 141 didn't jump to line 144 because the condition on line 141 was always true
142 token.reset_refreshed_date()
143 token.save()
144 return token
146 tries = 0
147 while token is None and tries < 5:
148 try:
149 token = cls.objects.create(user=user, key=secrets.token_urlsafe(64))
150 except IntegrityError:
151 tries += 1
152 if not token: 152 ↛ 153line 152 didn't jump to line 153 because the condition on line 152 was never true
153 raise DatabaseError(f"Could not create unique token for user {user}")
155 return token
158class SuggestedReviewer(models.Model):
159 """
160 Table for reviewers added manually that do not have an account.
161 We cannot create a base class for User (with the email address), because if an editor creates a SuggestedReviewer,
162 someone will not be able create an account with the same email address later on.
163 => We need 2 distinct tables. The Journal Manager will have to delete the duplicate SuggestedReviewer
164 """
166 first_name = models.CharField(_("first name"), max_length=150, blank=False, null=False)
167 last_name = models.CharField(_("last name"), max_length=150, blank=False, null=False)
168 email = models.EmailField(_("e-mail address"), unique=True)
169 keywords = models.TextField(verbose_name=_("Keywords"), blank=True, null=True, default="")
171 def __str__(self) -> str:
172 return f"{self.first_name} {self.last_name} ({self.email})"
175class Suggestion(models.Model):
176 """
177 Through field in the Submission | SuggestedReviewer ManyToMany relation
178 """
180 submission = models.ForeignKey(
181 Submission,
182 blank=True,
183 null=True,
184 on_delete=models.CASCADE,
185 related_name="suggestions_for_reviewer",
186 )
187 suggested_reviewer = models.ForeignKey(
188 SuggestedReviewer,
189 blank=True,
190 null=True,
191 on_delete=models.CASCADE,
192 related_name="suggestions_for_submission",
193 )
194 suggested_user = models.ForeignKey(
195 User,
196 blank=True,
197 null=True,
198 on_delete=models.CASCADE,
199 related_name="suggestions_for_submission",
200 )
202 suggest_to_avoid = models.BooleanField(
203 _("Suggested by an author to not review the submission"), default=False
204 )
206 seq = models.IntegerField()