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

1import glob 

2import os 

3from operator import itemgetter 

4 

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) 

23 

24from .models import Invitation, InvitationExtraData 

25 

26TYPE_CHOICES = ( 

27 ("doi", "doi"), 

28 ("mr-item-id", "mr"), 

29 ("zbl-item-id", "zbl"), 

30 ("numdam-id", "numdam"), 

31 ("pmid", "pubmed"), 

32) 

33 

34RESOURCE_ID_CHOICES = ( 

35 ("issn", "p-issn"), 

36 ("e-issn", "e-issn"), 

37) 

38 

39REL_CHOICES = ( 

40 ("small_icon", "small_icon"), 

41 ("icon", "icon"), 

42 ("test_website", "test_website"), 

43 ("website", "website"), 

44) 

45 

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) 

50 

51 

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 

58 

59 

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 

66 

67 

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 

74 

75 

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" 

81 

82 

83class BibItemIdForm(forms.ModelForm): 

84 class Meta: 

85 model = BibItemId 

86 fields = ["bibitem", "id_type", "id_value"] 

87 

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() 

92 

93 

94class ExtIdForm(forms.ModelForm): 

95 class Meta: 

96 model = ExtId 

97 fields = ["resource", "id_type", "id_value"] 

98 

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() 

103 

104 

105class ExtLinkForm(forms.ModelForm): 

106 class Meta: 

107 model = ExtLink 

108 fields = ["rel", "location"] 

109 widgets = { 

110 "rel": forms.Select(choices=REL_CHOICES), 

111 } 

112 

113 

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 } 

121 

122 

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 } 

140 

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 ) 

149 

150 super().__init__(*args, **kwargs) 

151 

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 

159 

160 

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) 

170 

171 def __init__(self, container, *args, **kwargs): 

172 super().__init__(*args, **kwargs) 

173 self.container = container 

174 

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

193 

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 

199 

200 for extlink in container.extlink_set.all(): 

201 if extlink.rel == "icon": 

202 self.fields["icon"].initial = os.path.basename(extlink.location) 

203 

204 def clean(self): 

205 cleaned_data = super().clean() 

206 return cleaned_data 

207 

208 

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) 

221 

222 def __init__(self, article, *args, **kwargs): 

223 super().__init__(*args, **kwargs) 

224 self.article = article 

225 

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 

257 

258 for count in article.resourcecount_set.all(): 

259 if count.name == "page-count": 

260 self.fields["page_count"].initial = count.value 

261 

262 for extlink in article.extlink_set.all(): 

263 if extlink.rel == "icon": 

264 self.fields["icon"].initial = os.path.basename(extlink.location) 

265 

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 

270 

271 def clean(self): 

272 cleaned_data = super().clean() 

273 return cleaned_data 

274 

275 

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 

289 

290 

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 ) 

308 

309 

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) 

313 

314 if to_appear: 

315 issue_folders = [ 

316 volume for volume in os.listdir(collection_folder) if f"{colid}_0" in volume 

317 ] 

318 

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) 

326 

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

339 

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 

344 

345 

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) 

357 

358 return article_choices 

359 

360 

361class ImportArticleForm(forms.Form): 

362 issue = forms.ChoiceField( 

363 label="Numéro", 

364 ) 

365 article = forms.ChoiceField( 

366 label="Article", 

367 ) 

368 

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 

379 

380 

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 ) 

395 

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) 

402 

403 

404class DiffContainerForm(forms.Form): 

405 import_choice = forms.ChoiceField( 

406 choices=IMPORT_CHOICES, label="Que faire des différences ?", widget=forms.RadioSelect() 

407 ) 

408 

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) 

415 

416 self.fields["import_choice"].initial = IMPORT_CHOICES[0][0] 

417 

418 

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

424 

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 ) 

432 

433 

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 ) 

446 

447 

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 ) 

454 

455 

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 ) 

461 

462 exclusion_list = forms.CharField( 

463 required=False, 

464 widget=forms.Textarea(attrs={"rows": "10"}), 

465 ) 

466 

467 class Meta: 

468 model = RelatedArticles 

469 fields = ["doi_list", "exclusion_list", "automatic_list"] 

470 

471 

472class GraphicalAbstractForm(forms.ModelForm): 

473 """Form for the Graphical Abstract model""" 

474 

475 class Meta: 

476 model = GraphicalAbstract 

477 fields = ("graphical_abstract", "illustration") 

478 

479 

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 ] 

497 

498 def __init__(self, *args, **kwargs): 

499 site_id = kwargs.pop("site_id") 

500 user = kwargs.pop("user") 

501 super().__init__(*args, **kwargs) 

502 

503 self.fields["site_id"].initial = site_id 

504 

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() 

511 

512 colid = get_collection_id(int(site_id)) 

513 

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 

521 

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) 

525 

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 ) 

530 

531 self.fields["menu_title_en"].widget.attrs.update({"class": "menu_title"}) 

532 self.fields["menu_title_fr"].widget.attrs.update({"class": "menu_title"}) 

533 

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

542 

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) 

546 

547 

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 ] 

558 

559 def __init__(self, *args, **kwargs): 

560 site_id = kwargs.pop("site_id") 

561 user = kwargs.pop("user") 

562 super().__init__(*args, **kwargs) 

563 

564 self.fields["site_id"].initial = site_id 

565 

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() 

572 

573 colid = get_collection_id(int(site_id)) 

574 

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 

582 

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

589 

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) 

593 

594 

595class InviteUserForm(forms.Form): 

596 """Base form to invite user.""" 

597 

598 required_css_class = "required" 

599 

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) 

603 

604 

605class InvitationAdminChangeForm(forms.ModelForm): 

606 class Meta: 

607 model = Invitation 

608 fields = "__all__" 

609 

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) 

618 

619 return self.cleaned_data["extra_data"] 

620 

621 

622class InvitationAdminAddForm(InvitationAdminChangeForm, CleanEmailMixin): 

623 class Meta: 

624 fields = ("email", "first_name", "last_name", "extra_data") 

625 

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 

648 

649 

650class SignupForm(BaseSignupForm): 

651 email = forms.EmailField(widget=forms.HiddenInput())