Coverage for src / mesh / models / user_models.py: 77%
101 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-10 09:11 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-10 09:11 +0000
1from __future__ import annotations
3import secrets
4from datetime import datetime
5from typing import Self
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
17USER_TOKEN_QUERY_PARAM = "_auth_token"
20class UserManager(BaseUserManager["User"]):
21 """
22 Defines a model manager for our custom User model having no username field.
23 """
25 use_in_migrations = True
27 def _create_user(self, email, password, **extra_fields):
28 """
29 Create and save a User with the given email and password.
30 """
31 if not email:
32 raise ValueError("The given email must be set")
33 email = self.normalize_email(email)
34 user = self.model(email=email, **extra_fields)
35 user.password = make_password(password)
36 user.save(using=self._db)
37 return user
39 def create_user(self, email, password=None, **extra_fields):
40 """
41 Create and save a regular User with the given email and password.
42 """
43 extra_fields.setdefault("is_active", True)
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 """
50 Create and save a SuperUser with the given email and password.
51 """
52 extra_fields.setdefault("is_active", True)
53 extra_fields.setdefault("is_staff", True)
54 extra_fields.setdefault("is_superuser", True)
56 if extra_fields.get("is_staff") is not True:
57 raise ValueError("Superuser must have is_staff=True.")
58 if extra_fields.get("is_superuser") is not True:
59 raise ValueError("Superuser must have is_superuser=True.")
61 return self._create_user(email, password, **extra_fields)
64class User(AbstractUser):
65 """
66 Custom user model without useless username field.
67 Login can only be performed with e-mail address.
68 """
70 username = None
71 first_name = models.CharField(_("first name"), max_length=150, blank=False, null=False)
72 last_name = models.CharField(_("last name"), max_length=150, blank=False, null=False)
73 email = models.EmailField(_("e-mail address"), unique=True)
74 journal_manager = models.BooleanField(_("Journal manager"), default=False)
75 keywords = models.TextField(verbose_name=_("Keywords"), blank=True, null=True, default="")
76 USERNAME_FIELD = "email"
77 REQUIRED_FIELDS = []
79 current_role = models.CharField(max_length=32, null=True)
80 impersonate_data = None
82 objects: UserManager = UserManager() # type: ignore
84 def __str__(self) -> str:
85 return f"{self.first_name} {self.last_name} ({self.email})"
87 @property
88 def is_token_authentication_allowed(self) -> bool:
89 """
90 For security purposes, we prevent token authentication for user with elevated
91 rights (staff, admin and journal manager users).
92 """
93 return not (self.is_superuser or self.is_staff or self.journal_manager)
96class UserToken(BaseChangeTrackingModel):
97 """
98 Token used for alternative authentication.
99 """
101 user = models.OneToOneField(
102 settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="token"
103 )
104 key = models.CharField(max_length=256, unique=True)
105 date_refreshed = models.DateField(help_text="Automatically filled when created.")
107 def save(self, *args, **kwargs):
108 if self._state.adding:
109 self.reset_refreshed_date()
110 super().save(*args, **kwargs)
112 def reset_refreshed_date(self):
113 """
114 Reset the user token refreshed date to now.
115 """
116 self.date_refreshed = datetime.utcnow().date()
118 @property
119 def is_expired(self) -> bool:
120 return (
121 datetime.utcnow().date()
122 > self.date_refreshed + app_settings.USER_TOKEN_EXPIRATION_DAYS
123 )
125 @classmethod
126 def get_token(cls, user: User, refresh_token: bool = True) -> Self:
127 """
128 Returns the existing token attached to the given user or creates and returns
129 a fresh one.
130 If the token already exists, its expiration date is reseted.
131 """
132 token = None
133 try:
134 token = cls.objects.get(user=user)
135 except cls.DoesNotExist:
136 pass
138 if token is not None:
139 if refresh_token:
140 token.reset_refreshed_date()
141 token.save()
142 return token
144 tries = 0
145 while token is None and tries < 5:
146 try:
147 token = cls.objects.create(user=user, key=secrets.token_urlsafe(64))
148 except IntegrityError:
149 tries += 1
150 if not token:
151 raise DatabaseError(f"Could not create unique token for user {user}")
153 return token
156class SuggestedReviewer(models.Model):
157 """
158 Table for reviewers added manually that do not have an account.
159 We cannot create a base class for User (with the email address), because if an editor creates a SuggestedReviewer,
160 someone will not be able create an account with the same email address later on.
161 => We need 2 distinct tables. The Journal Manager will have to delete the duplicate SuggestedReviewer
162 """
164 first_name = models.CharField(_("first name"), max_length=150, blank=False, null=False)
165 last_name = models.CharField(_("last name"), max_length=150, blank=False, null=False)
166 email = models.EmailField(_("e-mail address"), unique=True)
167 keywords = models.TextField(verbose_name=_("Keywords"), blank=True, null=True, default="")
169 def __str__(self) -> str:
170 return f"{self.first_name} {self.last_name} ({self.email})"
173class Suggestion(models.Model):
174 """
175 Through field in the Submission | SuggestedReviewer ManyToMany relation
176 """
178 submission = models.ForeignKey(
179 Submission,
180 null=True,
181 on_delete=models.CASCADE,
182 related_name="suggestions_for_reviewer",
183 )
184 suggested_reviewer = models.ForeignKey(
185 SuggestedReviewer,
186 null=True,
187 on_delete=models.CASCADE,
188 related_name="suggestions_for_submission",
189 )
190 suggested_user = models.ForeignKey(
191 User,
192 null=True,
193 on_delete=models.CASCADE,
194 related_name="suggestions_for_submission",
195 )
197 suggest_to_avoid = models.BooleanField(
198 _("Suggested by an author to not review the submission"), default=False
199 )
201 seq = models.IntegerField()