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

1from __future__ import annotations 

2 

3import secrets 

4from datetime import datetime 

5from typing import Self 

6 

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 _ 

12 

13from ..app_settings import app_settings 

14from .base_models import BaseChangeTrackingModel 

15from .submission_models import Submission 

16 

17USER_TOKEN_QUERY_PARAM = "_auth_token" 

18 

19 

20class UserManager(BaseUserManager["User"]): 

21 """ 

22 Defines a model manager for our custom User model having no username field. 

23 """ 

24 

25 use_in_migrations = True 

26 

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 

38 

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) 

47 

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) 

55 

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.") 

60 

61 return self._create_user(email, password, **extra_fields) 

62 

63 

64class User(AbstractUser): 

65 """ 

66 Custom user model without useless username field. 

67 Login can only be performed with e-mail address. 

68 """ 

69 

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 = [] 

78 

79 current_role = models.CharField(max_length=32, null=True) 

80 impersonate_data = None 

81 

82 objects: UserManager = UserManager() # type: ignore 

83 

84 def __str__(self) -> str: 

85 return f"{self.first_name} {self.last_name} ({self.email})" 

86 

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) 

94 

95 

96class UserToken(BaseChangeTrackingModel): 

97 """ 

98 Token used for alternative authentication. 

99 """ 

100 

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.") 

106 

107 def save(self, *args, **kwargs): 

108 if self._state.adding: 

109 self.reset_refreshed_date() 

110 super().save(*args, **kwargs) 

111 

112 def reset_refreshed_date(self): 

113 """ 

114 Reset the user token refreshed date to now. 

115 """ 

116 self.date_refreshed = datetime.utcnow().date() 

117 

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 ) 

124 

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 

137 

138 if token is not None: 

139 if refresh_token: 

140 token.reset_refreshed_date() 

141 token.save() 

142 return token 

143 

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}") 

152 

153 return token 

154 

155 

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 """ 

163 

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="") 

168 

169 def __str__(self) -> str: 

170 return f"{self.first_name} {self.last_name} ({self.email})" 

171 

172 

173class Suggestion(models.Model): 

174 """ 

175 Through field in the Submission | SuggestedReviewer ManyToMany relation 

176 """ 

177 

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 ) 

196 

197 suggest_to_avoid = models.BooleanField( 

198 _("Suggested by an author to not review the submission"), default=False 

199 ) 

200 

201 seq = models.IntegerField()