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
« prev ^ index » next coverage.py v7.13.2, created at 2026-08-19 12:55 +0000
1import datetime
2from dataclasses import asdict, dataclass, field
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
12class ResourceInNumdam(models.Model):
13 pid = models.CharField(max_length=64, db_index=True)
16class CollectionGroup(models.Model):
17 """
18 Overwrites original Django Group.
19 """
21 def __str__(self):
22 return self.group.name
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="")
28 def get_collections(self) -> str:
29 return ", ".join([col.pid for col in self.collections.all()])
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 """
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 )
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.
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"]
67 invite = cls.create(
68 email, inviter=request.user, first_name=first_name, last_name=last_name
69 )
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)
75 return invite
77 def date_expired(self) -> datetime.datetime:
78 return self.sent + datetime.timedelta(
79 days=invitations_app_settings.INVITATION_EXPIRY,
80 )
83@dataclass
84class InviteCommentData:
85 id: int
86 user_id: int
87 pid: str
88 doi: str
91@dataclass
92class InviteCollectionData:
93 pid: list[str]
94 user_id: int
97@dataclass
98class InviteModeratorData:
99 """
100 Interface for storing the moderator data in an invitation.
101 """
103 comments: list[InviteCommentData] = field(default_factory=list)
104 collections: list[InviteCollectionData] = field(default_factory=list)
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)}")
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)}")
130@dataclass
131class InvitationExtraData:
132 """
133 Interface representing an invitation's extra data.
134 """
136 moderator: InviteModeratorData = field(default_factory=InviteModeratorData)
137 user_groups: list[int] = field(default_factory=list)
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).
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)}")
155 def serialize(self) -> dict:
156 return asdict(self)
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"
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}
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"
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"
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)
222 def __str__(self):
223 return self.name
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