Coverage for src / ptf_tools / models.py: 89%

123 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-08-19 12:55 +0000

1import datetime 

2from dataclasses import asdict, dataclass, field 

3 

4from django.conf import settings 

5from django.db import models 

6from django.http import HttpRequest 

7from invitations.app_settings import app_settings as invitations_app_settings 

8from invitations.models import Invitation as BaseInvitation 

9from ptf.models import Collection 

10 

11 

12class ResourceInNumdam(models.Model): 

13 pid = models.CharField(max_length=64, db_index=True) 

14 

15 

16class CollectionGroup(models.Model): 

17 """ 

18 Overwrites original Django Group. 

19 """ 

20 

21 def __str__(self): 

22 return self.group.name 

23 

24 group = models.OneToOneField("auth.Group", unique=True, on_delete=models.CASCADE) 

25 collections = models.ManyToManyField(Collection) 

26 email_alias = models.EmailField(max_length=70, blank=True, default="") 

27 

28 def get_collections(self) -> str: 

29 return ", ".join([col.pid for col in self.collections.all()]) 

30 

31 

32class Invitation(BaseInvitation): 

33 """ 

34 Invitation model. Additionally data can be stored in `extra_data`, to be used 

35 when an user signs up following the invitation link. 

36 Cf. signals.py 

37 """ 

38 

39 first_name = models.CharField("First name", max_length=150, null=False, blank=False) 

40 last_name = models.CharField("Last name", max_length=150, null=False, blank=False) 

41 extra_data = models.JSONField( 

42 default=dict, 

43 blank=True, 

44 help_text="JSON field used to dynamically update the created user object when the invitation is accepted.", 

45 ) 

46 

47 @classmethod 

48 def get_invite(cls, email: str, request: HttpRequest, invite_data: dict) -> "Invitation": 

49 """ 

50 Gets the existing valid invitation or creates a new one and send it. 

51 If there's an existing invitation but it's expired, we delete it and 

52 send a new one. 

53 

54 `invite_data` must contain `first_name` and `last_name` entries. It is passed 

55 as the context of the invite mail renderer. 

56 """ 

57 try: 

58 invite = cls.objects.get(email__iexact=email) 

59 # Delete the invite if it's expired and create a fresh one 

60 if invite.key_expired(): 

61 invite.delete() 

62 raise cls.DoesNotExist 

63 except cls.DoesNotExist: 

64 first_name = invite_data["first_name"] 

65 last_name = invite_data["last_name"] 

66 

67 invite = cls.create( 

68 email, inviter=request.user, first_name=first_name, last_name=last_name 

69 ) 

70 

71 mail_template_context = {**invite_data} 

72 mail_template_context["full_name"] = f"{first_name} {last_name}" 

73 invite.send_invitation(request, **mail_template_context) 

74 

75 return invite 

76 

77 def date_expired(self) -> datetime.datetime: 

78 return self.sent + datetime.timedelta( 

79 days=invitations_app_settings.INVITATION_EXPIRY, 

80 ) 

81 

82 

83@dataclass 

84class InviteCommentData: 

85 id: int 

86 user_id: int 

87 pid: str 

88 doi: str 

89 

90 

91@dataclass 

92class InviteCollectionData: 

93 pid: list[str] 

94 user_id: int 

95 

96 

97@dataclass 

98class InviteModeratorData: 

99 """ 

100 Interface for storing the moderator data in an invitation. 

101 """ 

102 

103 comments: list[InviteCommentData] = field(default_factory=list) 

104 collections: list[InviteCollectionData] = field(default_factory=list) 

105 

106 def __post_init__(self): 

107 try: 

108 comments = self.comments 

109 if not isinstance(comments, list): 109 ↛ 110line 109 didn't jump to line 110 because the condition on line 109 was never true

110 raise ValueError("'comments' must be a list") 

111 self.comments = [ 

112 InviteCommentData(**c) if not isinstance(c, InviteCommentData) else c 

113 for c in comments 

114 ] 

115 except Exception as e: 

116 raise ValueError(f"Error while parsing provided InviteCommentData. {str(e)}") 

117 

118 try: 

119 collections = self.collections 

120 if not isinstance(collections, list): 120 ↛ 121line 120 didn't jump to line 121 because the condition on line 120 was never true

121 raise ValueError("'collections' must be a list") 

122 self.collections = [ 

123 InviteCollectionData(**c) if not isinstance(c, InviteCollectionData) else c 

124 for c in collections 

125 ] 

126 except Exception as e: 

127 raise ValueError(f"Error while parsing provided InviteCollectionData. {str(e)}") 

128 

129 

130@dataclass 

131class InvitationExtraData: 

132 """ 

133 Interface representing an invitation's extra data. 

134 """ 

135 

136 moderator: InviteModeratorData = field(default_factory=InviteModeratorData) 

137 user_groups: list[int] = field(default_factory=list) 

138 

139 def __post_init__(self): 

140 """ 

141 Dataclasses do not provide an effective fromdict method to deserialize 

142 a dataclass (JSON to python dataclass object). 

143 

144 This enables to effectively deserialize a JSON into a InvitationExtraData object, 

145 by replacing the nested dict by their actual dataclass representation. 

146 Beware this might not work well with typing (?) 

147 """ 

148 moderator = self.moderator 

149 if moderator and not isinstance(moderator, InviteModeratorData): 

150 try: 

151 self.moderator = InviteModeratorData(**moderator) 

152 except Exception as e: 

153 raise ValueError(f"Error while parsing provided InviteModeratorData. {str(e)}") 

154 

155 def serialize(self) -> dict: 

156 return asdict(self) 

157 

158 

159class FieldsToUpdateChoices(models.TextChoices): 

160 TITLE = "title" 

161 LANG = "lang" 

162 ATYPE = "atype" 

163 CONTRIBUTORS = "contributors" 

164 ABSTRACT = "abstract" 

165 KEYWORDS = "keywords" 

166 PDF = "pdf" 

167 ILLUSTRATION = "illustration" 

168 DATES = "dates" 

169 PCJ_TOPICS = "pck_topics" 

170 CONFERENCE = "conference" 

171 REFERENCES = "references" 

172 

173 

174FIELDS_TABLE = { 

175 "title": { 

176 "fields_to_update": ["titles", "title_html", "title_xml", "title_tex"], 

177 "additional_fields": [], 

178 }, 

179 "lang": {"fields_to_update": ["lang"], "additional_fields": []}, 

180 "atype": {"fields_to_update": ["atype"], "additional_fields": []}, 

181 "contributors": {"fields_to_update": ["contributors"], "additional_fields": []}, 

182 "abstract": {"fields_to_update": ["abstracts"], "additional_fields": []}, 

183 "keywords": {"fields_to_update": ["kwds"], "additional_fields": []}, 

184 "pdf": {"fields_to_update": ["streams"], "additional_fields": ["pdf"]}, 

185 "illustration": {"fields_to_update": ["ext_links"], "additional_fields": ["illustration"]}, 

186 "dates": { 

187 "fields_to_update": ["date_accepted", "history_dates"], 

188 "additional_fields": ["dates"], 

189 }, 

190 "pcj_topics": {"fields_to_update": ["subjs"], "additional_fields": ["pcj_topics"]}, 

191 "conference": {"fields_to_update": ["subjs"], "additional_fields": ["conference"]}, 

192 "references": {"fields_to_update": ["references", "bibitems"], "additional_fields": []}, 

193} 

194 

195 

196class AdditionalFieldsChoices(models.TextChoices): 

197 # DOI = "doi" 

198 # PID = "pid" 

199 # CONTAINER_PID = "container_pid" 

200 PDF = "pdf" 

201 ILLUSTRATION = "illustration" 

202 DATES = "dates" 

203 MSC_KEYWORDS = "msc_keywords" 

204 

205 

206class EditorialToolsChoices(models.TextChoices): 

207 TRANSLATION = "translation" 

208 SIDEBAR = "sidebar" 

209 LANG_SELECTION = "lang_selection" 

210 BACK_TO_ARTICLE_OPTION = "back_to_article_option" 

211 MSC_KEYWORDS = "msc_keywords" 

212 ONLY_READ_TITLE = "only_read_title" 

213 FULL_MATCHING_FIRST = "full_matching_first" 

214 

215 

216class UserRole(models.Model): 

217 name = models.CharField(max_length=64) 

218 fields_to_update = models.JSONField(default=list, blank=True) 

219 editorial_tools = models.JSONField(default=list, blank=True) 

220 users = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name="roles", blank=True) 

221 

222 def __str__(self): 

223 return self.name 

224 

225 def get_real_fields_to_update(self): 

226 real_fields_to_update = [] 

227 real_additional_fields = [] 

228 for field_to_update in self.fields_to_update: 

229 if field_to_update not in real_fields_to_update: 229 ↛ 228line 229 didn't jump to line 228 because the condition on line 229 was always true

230 real_fields_to_update += FIELDS_TABLE[field_to_update]["fields_to_update"] 

231 real_additional_fields += FIELDS_TABLE[field_to_update]["additional_fields"] 

232 return real_fields_to_update, real_additional_fields