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

1from __future__ import annotations 

2 

3import secrets 

4from datetime import datetime 

5from typing import Self, TypeVar 

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 

17_T = TypeVar("_T", bound=AbstractUser) 

18 

19USER_TOKEN_QUERY_PARAM = "_auth_token" 

20 

21 

22class UserManager(BaseUserManager[_T]): 

23 """ 

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

25 """ 

26 

27 use_in_migrations = True 

28 

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 

40 

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) 

49 

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) 

57 

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

62 

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

64 

65 

66class User(AbstractUser): 

67 """ 

68 Custom user model without useless username field. 

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

70 """ 

71 

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

80 

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

82 impersonate_data = None 

83 

84 objects: UserManager[Self] = UserManager() 

85 

86 def __str__(self) -> str: 

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

88 

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) 

96 

97 

98class UserToken(BaseChangeTrackingModel): 

99 """ 

100 Token used for alternative authentication. 

101 """ 

102 

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

108 

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

110 if self._state.adding: 

111 self.reset_refreshed_date() 

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

113 

114 def reset_refreshed_date(self): 

115 """ 

116 Reset the user token refreshed date to now. 

117 """ 

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

119 

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 ) 

126 

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 

139 

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 

145 

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

154 

155 return token 

156 

157 

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

165 

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

170 

171 def __str__(self) -> str: 

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

173 

174 

175class Suggestion(models.Model): 

176 """ 

177 Through field in the Submission | SuggestedReviewer ManyToMany relation 

178 """ 

179 

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 ) 

201 

202 suggest_to_avoid = models.BooleanField( 

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

204 ) 

205 

206 seq = models.IntegerField()