Coverage for src / ptf_tools / forms.py: 38%
363 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-04-20 09:25 +0000
« prev ^ index » next coverage.py v7.13.2, created at 2026-04-20 09:25 +0000
1import glob
2import os
3from operator import itemgetter
5from allauth.account.forms import SignupForm as BaseSignupForm
6from ckeditor_uploader.fields import RichTextUploadingFormField
7from crispy_forms.helper import FormHelper
8from django import forms
9from django.conf import settings
10from django.contrib.auth import get_user_model
11from invitations.forms import CleanEmailMixin
12from mersenne_cms.models import News, Page
13from ptf.model_helpers import get_collection_id, is_site_en_only, is_site_fr_only
14from ptf.models import (
15 BibItemId,
16 Collection,
17 ExtId,
18 ExtLink,
19 GraphicalAbstract,
20 RelatedArticles,
21 ResourceId,
22)
24from .models import Invitation, InvitationExtraData
26TYPE_CHOICES = (
27 ("doi", "doi"),
28 ("mr-item-id", "mr"),
29 ("zbl-item-id", "zbl"),
30 ("numdam-id", "numdam"),
31 ("pmid", "pubmed"),
32)
34RESOURCE_ID_CHOICES = (
35 ("issn", "p-issn"),
36 ("e-issn", "e-issn"),
37)
39REL_CHOICES = (
40 ("small_icon", "small_icon"),
41 ("icon", "icon"),
42 ("test_website", "test_website"),
43 ("website", "website"),
44)
46IMPORT_CHOICES = (
47 ("1", "Préserver des métadonnées existantes dans ptf-tools (equal-contrib, coi_statement)"),
48 ("2", "Remplacer tout par le fichier XML"),
49)
52class PtfFormHelper(FormHelper):
53 def __init__(self, *args, **kwargs):
54 super().__init__(*args, **kwargs)
55 self.label_class = "col-xs-4 col-sm-2"
56 self.field_class = "col-xs-8 col-sm-6"
57 self.form_tag = False
60class PtfModalFormHelper(FormHelper):
61 def __init__(self, *args, **kwargs):
62 super().__init__(*args, **kwargs)
63 self.label_class = "col-xs-3"
64 self.field_class = "col-xs-8"
65 self.form_tag = False
68class PtfLargeModalFormHelper(FormHelper):
69 def __init__(self, *args, **kwargs):
70 super().__init__(*args, **kwargs)
71 self.label_class = "col-xs-6"
72 self.field_class = "col-xs-6"
73 self.form_tag = False
76class FormSetHelper(FormHelper):
77 def __init__(self, *args, **kwargs):
78 super().__init__(*args, **kwargs)
79 self.form_tag = False
80 self.template = "bootstrap3/whole_uni_formset.html"
83class BibItemIdForm(forms.ModelForm):
84 class Meta:
85 model = BibItemId
86 fields = ["bibitem", "id_type", "id_value"]
88 def __init__(self, *args, **kwargs):
89 super().__init__(*args, **kwargs)
90 self.fields["id_type"].widget = forms.Select(choices=TYPE_CHOICES)
91 self.fields["bibitem"].widget = forms.HiddenInput()
94class ExtIdForm(forms.ModelForm):
95 class Meta:
96 model = ExtId
97 fields = ["resource", "id_type", "id_value"]
99 def __init__(self, *args, **kwargs):
100 super().__init__(*args, **kwargs)
101 self.fields["id_type"].widget = forms.Select(choices=TYPE_CHOICES)
102 self.fields["resource"].widget = forms.HiddenInput()
105class ExtLinkForm(forms.ModelForm):
106 class Meta:
107 model = ExtLink
108 fields = ["rel", "location"]
109 widgets = {
110 "rel": forms.Select(choices=REL_CHOICES),
111 }
114class ResourceIdForm(forms.ModelForm):
115 class Meta:
116 model = ResourceId
117 fields = ["id_type", "id_value"]
118 widgets = {
119 "id_type": forms.Select(choices=RESOURCE_ID_CHOICES),
120 }
123class CollectionForm(forms.ModelForm):
124 class Meta:
125 model = Collection
126 fields = [
127 "pid",
128 "provider",
129 "coltype",
130 "title_tex",
131 "abbrev",
132 "doi",
133 "wall",
134 "alive",
135 "sites",
136 ]
137 widgets = {
138 "title_tex": forms.TextInput(),
139 }
141 def __init__(self, *args, **kwargs):
142 # Add extra fields before the base class __init__
143 self.base_fields["description_en"] = RichTextUploadingFormField(
144 required=False, label="Description (EN)"
145 )
146 self.base_fields["description_fr"] = RichTextUploadingFormField(
147 required=False, label="Description (FR)"
148 )
150 super().__init__(*args, **kwargs)
152 # self.instance is now set, specify initial values
153 qs = self.instance.abstract_set.filter(tag="description")
154 for abstract in qs:
155 if abstract.lang == "fr":
156 self.initial["description_fr"] = abstract.value_html
157 elif abstract.lang == "en":
158 self.initial["description_en"] = abstract.value_html
161class ContainerForm(forms.Form):
162 pid = forms.CharField(required=True, initial="")
163 title = forms.CharField(required=False, initial="")
164 trans_title = forms.CharField(required=False, initial="")
165 publisher = forms.CharField(required=True, initial="")
166 year = forms.CharField(required=False, initial="")
167 volume = forms.CharField(required=False, initial="")
168 number = forms.CharField(required=False, initial="")
169 icon = forms.FileField(required=False)
171 def __init__(self, container, *args, **kwargs):
172 super().__init__(*args, **kwargs)
173 self.container = container
175 if "data" in kwargs:
176 # form_invalid: preserve input values
177 self.fields["pid"].initial = kwargs["data"]["pid"]
178 self.fields["publisher"].initial = kwargs["data"]["publisher"]
179 self.fields["year"].initial = kwargs["data"]["year"]
180 self.fields["volume"].initial = kwargs["data"]["volume"]
181 self.fields["number"].initial = kwargs["data"]["number"]
182 self.fields["title"].initial = kwargs["data"]["title"]
183 self.fields["trans_title"].initial = kwargs["data"]["trans_title"]
184 elif container:
185 self.fields["pid"].initial = container.pid
186 self.fields["title"].initial = container.title_tex
187 # We arbitrarily take the first translated title
188 trans_title = self.container.title_set.filter(type="main").first()
189 if trans_title:
190 self.fields["trans_title"].initial = trans_title.title_html
191 else:
192 self.fields["trans_title"].initial = ""
194 if container.my_publisher:
195 self.fields["publisher"].initial = container.my_publisher.pub_name
196 self.fields["year"].initial = container.year
197 self.fields["volume"].initial = container.volume
198 self.fields["number"].initial = container.number
200 for extlink in container.extlink_set.all():
201 if extlink.rel == "icon":
202 self.fields["icon"].initial = os.path.basename(extlink.location)
204 def clean(self):
205 cleaned_data = super().clean()
206 return cleaned_data
209class ArticleForm(forms.Form):
210 pid = forms.CharField(required=True, initial="")
211 title = forms.CharField(required=False, initial="")
212 fpage = forms.CharField(required=False, initial="")
213 lpage = forms.CharField(required=False, initial="")
214 page_count = forms.CharField(required=False, initial="")
215 page_range = forms.CharField(required=False, initial="")
216 icon = forms.FileField(required=False)
217 pdf = forms.FileField(required=False)
218 coi_statement = forms.CharField(required=False, initial="")
219 show_body = forms.BooleanField(required=False, initial=True)
220 do_not_publish = forms.BooleanField(required=False, initial=True)
222 def __init__(self, article, *args, **kwargs):
223 super().__init__(*args, **kwargs)
224 self.article = article
226 if "data" in kwargs:
227 data = kwargs["data"]
228 # form_invalid: preserve input values
229 self.fields["pid"].initial = data["pid"]
230 if "title" in data:
231 self.fields["title"].initial = data["title"]
232 if "fpage" in data:
233 self.fields["fpage"].initial = data["fpage"]
234 if "lpage" in data:
235 self.fields["lpage"].initial = data["lpage"]
236 if "page_range" in data:
237 self.fields["page_range"].initial = data["page_range"]
238 if "page_count" in data:
239 self.fields["page_count"].initial = data["page_count"]
240 if "coi_statement" in data:
241 self.fields["coi_statement"].initial = data["coi_statement"]
242 if "show_body" in data:
243 self.fields["show_body"].initial = data["show_body"]
244 if "do_not_publish" in data:
245 self.fields["do_not_publish"].initial = data["do_not_publish"]
246 elif article:
247 # self.fields['pid'].initial = article.pid
248 self.fields["title"].initial = article.title_tex
249 self.fields["fpage"].initial = article.fpage
250 self.fields["lpage"].initial = article.lpage
251 self.fields["page_range"].initial = article.page_range
252 self.fields["coi_statement"].initial = (
253 article.coi_statement if article.coi_statement else ""
254 )
255 self.fields["show_body"].initial = article.show_body
256 self.fields["do_not_publish"].initial = article.do_not_publish
258 for count in article.resourcecount_set.all():
259 if count.name == "page-count":
260 self.fields["page_count"].initial = count.value
262 for extlink in article.extlink_set.all():
263 if extlink.rel == "icon":
264 self.fields["icon"].initial = os.path.basename(extlink.location)
266 qs = article.datastream_set.filter(rel="full-text", mimetype="application/pdf")
267 if qs.exists():
268 datastream = qs.first()
269 self.fields["pdf"].initial = datastream.location
271 def clean(self):
272 cleaned_data = super().clean()
273 return cleaned_data
276def cast_volume(element):
277 # Permet le classement des volumes dans le cas où :
278 # - un numero de volume est de la forme "11-12" (cf crchim)
279 # - un volume est de la forme "S5" (cf smai)
280 if not element:
281 return "", ""
282 try:
283 casted = int(element.split("-")[0])
284 extra = ""
285 except ValueError as _:
286 casted = int(element.split("-")[0][1:])
287 extra = element
288 return extra, casted
291def unpack_pid(filename):
292 # retourne un tableau pour chaque filename de la forme :
293 # [filename, collection, year, vseries, volume_extra, volume, issue_extra, issue]
294 # Permet un tri efficace par la suite
295 collection, year, vseries, volume, issue = filename.split("/")[-1].split(".")[0].split("_")
296 extra_volume, casted_volume = cast_volume(volume)
297 extra_issue, casted_issue = cast_volume(issue)
298 return (
299 filename,
300 collection,
301 year,
302 vseries,
303 extra_volume,
304 casted_volume,
305 extra_issue,
306 casted_issue,
307 )
310def get_volume_choices(colid, to_appear=False):
311 if settings.IMPORT_CEDRICS_DIRECTLY:
312 collection_folder = os.path.join(settings.CEDRAM_TEX_FOLDER, colid)
314 if to_appear:
315 issue_folders = [
316 volume for volume in os.listdir(collection_folder) if f"{colid}_0" in volume
317 ]
319 else:
320 issue_folders = [
321 d
322 for d in os.listdir(collection_folder)
323 if os.path.isdir(os.path.join(collection_folder, d))
324 ]
325 issue_folders = sorted(issue_folders, reverse=True)
327 files = [
328 (os.path.join(collection_folder, d, d + "-cdrxml.xml"), d)
329 for d in issue_folders
330 if os.path.isfile(os.path.join(collection_folder, d, d + "-cdrxml.xml"))
331 ]
332 else:
333 if to_appear:
334 volumes_path = os.path.join(
335 settings.CEDRAM_XML_FOLDER, colid, "metadata", f"{colid}_0*.xml"
336 )
337 else:
338 volumes_path = os.path.join(settings.CEDRAM_XML_FOLDER, colid, "metadata", "*.xml")
340 files = [unpack_pid(filename) for filename in glob.glob(volumes_path)]
341 sort = sorted(files, key=itemgetter(1, 2, 3, 4, 5, 6, 7), reverse=True)
342 files = [(item[0], item[0].split("/")[-1]) for item in sort]
343 return files
346def get_article_choices(colid, issue_name):
347 issue_folder = os.path.join(settings.CEDRAM_TEX_FOLDER, colid, issue_name)
348 article_choices = [
349 (d, os.path.basename(d))
350 for d in os.listdir(issue_folder)
351 if (
352 os.path.isdir(os.path.join(issue_folder, d))
353 and os.path.isfile(os.path.join(issue_folder, d, d + "-cdrxml.xml"))
354 )
355 ]
356 article_choices = sorted(article_choices, reverse=True)
358 return article_choices
361class ImportArticleForm(forms.Form):
362 issue = forms.ChoiceField(
363 label="Numéro",
364 )
365 article = forms.ChoiceField(
366 label="Article",
367 )
369 def __init__(self, *args, **kwargs):
370 # we need to pop this extra colid kwarg if not, the call to super.__init__ won't work
371 colid = kwargs.pop("colid")
372 super().__init__(*args, **kwargs)
373 volumes = get_volume_choices(colid)
374 self.fields["issue"].choices = volumes
375 articles = []
376 if volumes:
377 articles = get_article_choices(colid, volumes[0][1])
378 self.fields["article"].choices = articles
381class ImportContainerForm(forms.Form):
382 filename = forms.ChoiceField(
383 label="Numéro",
384 )
385 remove_email = forms.BooleanField(
386 label="Supprimer les mails des contribs issus de CEDRAM ?",
387 initial=True,
388 required=False,
389 )
390 remove_date_prod = forms.BooleanField(
391 label="Supprimer les dates de mise en prod issues de CEDRAM ?",
392 initial=True,
393 required=False,
394 )
396 def __init__(self, *args, **kwargs):
397 # we need to pop this extra colid kwarg if not, the call to super.__init__ won't work
398 colid = kwargs.pop("colid")
399 to_appear = kwargs.pop("to_appear")
400 super().__init__(*args, **kwargs)
401 self.fields["filename"].choices = get_volume_choices(colid, to_appear)
404class DiffContainerForm(forms.Form):
405 import_choice = forms.ChoiceField(
406 choices=IMPORT_CHOICES, label="Que faire des différences ?", widget=forms.RadioSelect()
407 )
409 def __init__(self, *args, **kwargs):
410 # we need to pop this extra full_path kwarg if not, the call to super.__init__ won't work
411 kwargs.pop("colid")
412 # filename = kwargs.pop('filename')
413 # to_appear = kwargs.pop('to_appear')
414 super().__init__(*args, **kwargs)
416 self.fields["import_choice"].initial = IMPORT_CHOICES[0][0]
419class ImportEditflowArticleForm(forms.Form):
420 @staticmethod
421 def validate_xml_file(value):
422 if not value.name.lower().endswith(".xml"):
423 raise forms.ValidationError("Only .xml files are allowed.")
425 editflow_xml_file = forms.FileField(
426 label="Import an article from an XML file provided by Editflow.",
427 required=True,
428 validators=[validate_xml_file.__func__],
429 widget=forms.ClearableFileInput(attrs={"accept": ".xml"}),
430 help_text="Only .xml files are accepted.",
431 )
434class RegisterPubmedForm(forms.Form):
435 CHOICES = [
436 ("off", "Yes"),
437 ("on", "No, update the article in PubMed"),
438 ]
439 update_article = forms.ChoiceField(
440 label="Are you registering the article for the first time ? Note: If you are updating the article, consider that only AuthorList (Author, Affiliation, Identifier), InvestigatorList (Investigator, Affiliation, Identifier), Pagination, ELocationID, OtherAbstract, PII and DOI fields can be updated. All other edits must be made using the PubMed Data Management system: https://www.ncbi.nlm.nih.gov/pubmed/management/",
441 widget=forms.RadioSelect,
442 choices=CHOICES,
443 required=False,
444 initial="on",
445 )
448class CreateFrontpageForm(forms.Form):
449 create_frontpage = forms.BooleanField(
450 label="Update des frontpages des articles avec date de mise en ligne ?",
451 initial=False,
452 required=False,
453 )
456class RelatedForm(forms.ModelForm):
457 doi_list = forms.CharField(
458 required=False,
459 widget=forms.Textarea(attrs={"rows": "10", "placeholder": "doi_1\ndoi_2\ndoi_3\n"}),
460 )
462 exclusion_list = forms.CharField(
463 required=False,
464 widget=forms.Textarea(attrs={"rows": "10"}),
465 )
467 class Meta:
468 model = RelatedArticles
469 fields = ["doi_list", "exclusion_list", "automatic_list"]
472class GraphicalAbstractForm(forms.ModelForm):
473 """Form for the Graphical Abstract model"""
475 class Meta:
476 model = GraphicalAbstract
477 fields = ("graphical_abstract", "illustration")
480class PageForm(forms.ModelForm):
481 class Meta:
482 model = Page
483 fields = [
484 "menu_title_en",
485 "menu_title_fr",
486 "parent_page",
487 "content_en",
488 "content_fr",
489 "state",
490 "slug_en",
491 "slug_fr",
492 "menu_order",
493 "position",
494 "mersenne_id",
495 "site_id",
496 ]
498 def __init__(self, *args, **kwargs):
499 site_id = kwargs.pop("site_id")
500 user = kwargs.pop("user")
501 super().__init__(*args, **kwargs)
503 self.fields["site_id"].initial = site_id
505 if not user.is_staff: 505 ↛ 512line 505 didn't jump to line 512 because the condition on line 505 was always true
506 for field_name in ["mersenne_id", "site_id"]:
507 field = self.fields[field_name]
508 # Hide the field is not enough, otherwise BaseForm._clean_fields will not get the value
509 field.disabled = True
510 field.widget = field.hidden_widget()
512 colid = get_collection_id(int(site_id))
514 # By default, CKEditor stores files in 1 folder
515 # We want to store the files in a @colid folder
516 for field_name in ["content_en", "content_fr"]:
517 field = self.fields[field_name]
518 widget = field.widget
519 widget.config["filebrowserUploadUrl"] = "/ckeditor/upload/" + colid
520 widget.config["filebrowserBrowseUrl"] = "/ckeditor/browse/" + colid
522 pages = Page.objects.filter(site_id=site_id, parent_page=None)
523 if self.instance: 523 ↛ 526line 523 didn't jump to line 526 because the condition on line 523 was always true
524 pages = pages.exclude(id=self.instance.id)
526 choices = [(p.id, p.menu_title_en) for p in pages if p.menu_title_en]
527 self.fields["parent_page"].choices = sorted(
528 choices + [(None, "---------")], key=lambda x: x[1]
529 )
531 self.fields["menu_title_en"].widget.attrs.update({"class": "menu_title"})
532 self.fields["menu_title_fr"].widget.attrs.update({"class": "menu_title"})
534 if is_site_en_only(site_id): 534 ↛ 535line 534 didn't jump to line 535 because the condition on line 534 was never true
535 self.fields.pop("content_fr")
536 self.fields.pop("menu_title_fr")
537 self.fields.pop("slug_fr")
538 elif is_site_fr_only(site_id): 538 ↛ 539line 538 didn't jump to line 539 because the condition on line 538 was never true
539 self.fields.pop("content_en")
540 self.fields.pop("menu_title_en")
541 self.fields.pop("slug_en")
543 def save_model(self, request, obj, form, change):
544 obj.site_id = form.cleaned_data["site_id"]
545 super().save_model(request, obj, form, change)
548class NewsForm(forms.ModelForm):
549 class Meta:
550 model = News
551 fields = [
552 "title_en",
553 "title_fr",
554 "content_en",
555 "content_fr",
556 "site_id",
557 ]
559 def __init__(self, *args, **kwargs):
560 site_id = kwargs.pop("site_id")
561 user = kwargs.pop("user")
562 super().__init__(*args, **kwargs)
564 self.fields["site_id"].initial = site_id
566 if not user.is_staff:
567 for field_name in ["site_id"]:
568 field = self.fields[field_name]
569 # Hide the field is not enough, otherwise BaseForm._clean_fields will not get the value
570 field.disabled = True
571 field.widget = field.hidden_widget()
573 colid = get_collection_id(int(site_id))
575 # By default, CKEditor stores files in 1 folder
576 # We want to store the files in a @colid folder
577 for field_name in ["content_en", "content_fr"]:
578 field = self.fields[field_name]
579 widget = field.widget
580 widget.config["filebrowserUploadUrl"] = "/ckeditor/upload/" + colid
581 widget.config["filebrowserBrowseUrl"] = "/ckeditor/browse/" + colid
583 if is_site_en_only(site_id):
584 self.fields.pop("content_fr")
585 self.fields.pop("title_fr")
586 elif is_site_fr_only(site_id):
587 self.fields.pop("content_en")
588 self.fields.pop("title_en")
590 def save_model(self, request, obj, form, change):
591 obj.site_id = form.cleaned_data["site_id"]
592 super().save_model(request, obj, form, change)
595class InviteUserForm(forms.Form):
596 """Base form to invite user."""
598 required_css_class = "required"
600 first_name = forms.CharField(label="First name", max_length=150, required=True)
601 last_name = forms.CharField(label="Last name", max_length=150, required=True)
602 email = forms.EmailField(label="E-mail address", required=True)
605class InvitationAdminChangeForm(forms.ModelForm):
606 class Meta:
607 model = Invitation
608 fields = "__all__"
610 def clean_extra_data(self):
611 """
612 Enforce the JSON structure with the InvitationExtraData dataclass interface.
613 """
614 try:
615 InvitationExtraData(**self.cleaned_data["extra_data"])
616 except Exception as e:
617 raise forms.ValidationError(e)
619 return self.cleaned_data["extra_data"]
622class InvitationAdminAddForm(InvitationAdminChangeForm, CleanEmailMixin):
623 class Meta:
624 fields = ("email", "first_name", "last_name", "extra_data")
626 def save(self, *args, **kwargs):
627 """
628 Populate the invitation data, save in DB and send the invitation e-mail.
629 """
630 cleaned_data = self.clean()
631 email = cleaned_data["email"]
632 params = {"email": email}
633 if cleaned_data.get("inviter"):
634 params["inviter"] = cleaned_data["inviter"]
635 else:
636 user = getattr(self, "user", None)
637 if isinstance(user, get_user_model()):
638 params["inviter"] = user
639 instance = Invitation.create(**params)
640 instance.first_name = cleaned_data["first_name"]
641 instance.last_name = cleaned_data["last_name"]
642 instance.extra_data = cleaned_data.get("extra_data", {})
643 instance.save()
644 full_name = f"{instance.first_name} {instance.last_name}"
645 instance.send_invitation(self.request, **{"full_name": full_name})
646 super().save(*args, **kwargs)
647 return instance
650class SignupForm(BaseSignupForm):
651 email = forms.EmailField(widget=forms.HiddenInput())