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

1from __future__ import annotations 

2 

3import secrets 

4from datetime import datetime 

5from typing import TYPE_CHECKING, Self 

6 

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 _ 

11 

12from mesh.app_settings import app_settings 

13 

14from .base_models import BaseChangeTrackingModel 

15 

16USER_TOKEN_QUERY_PARAM = "_auth_token" 

17 

18if TYPE_CHECKING: 

19 from django.db.models.manager import RelatedManager 

20 

21 from mesh.models.orm.editorial_models import EditorSectionRight 

22 

23 

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

25 """ 

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

27 """ 

28 

29 use_in_migrations = True 

30 

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 

42 

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) 

51 

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) 

59 

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

64 

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

66 

67 

68class User(AbstractUser): 

69 """ 

70 Custom user model without useless username field. 

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

72 """ 

73 

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

82 

83 # TODO : use ENUM 

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

85 impersonate_data = None 

86 

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

88 editor_sections: RelatedManager[EditorSectionRight] 

89 

90 def __str__(self) -> str: 

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

92 

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) 

100 

101 

102class UserToken(BaseChangeTrackingModel): 

103 """ 

104 Token used for alternative authentication. 

105 """ 

106 

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

110 

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

112 if self._state.adding: 

113 self.reset_refreshed_date() 

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

115 

116 def reset_refreshed_date(self): 

117 """ 

118 Reset the user token refreshed date to now. 

119 """ 

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

121 

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 ) 

128 

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 

141 

142 if token is not None: 

143 if refresh_token: 

144 token.reset_refreshed_date() 

145 token.save() 

146 return token 

147 

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

156 

157 return token 

158 

159 

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

167 

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

172 

173 def __str__(self) -> str: 

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