Coverage for src / ptf_tools / forms.py: 38%

357 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-09-18 08:07 +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 Collection, 

16 ExtId, 

17 ExtLink, 

18 GraphicalAbstract, 

19 RelatedArticles, 

20 ResourceId, 

21) 

22 

23from .models import Invitation, InvitationExtraData 

24 

25TYPE_CHOICES = ( 

26 ("doi", "doi"), 

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

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

29 ("numdam-id", "numdam"), 

30 ("pmid", "pubmed"), 

31) 

32 

33RESOURCE_ID_CHOICES = ( 

34 ("issn", "p-issn"), 

35 ("e-issn", "e-issn"), 

36) 

37 

38REL_CHOICES = ( 

39 ("small_icon", "small_icon"), 

40 ("icon", "icon"), 

41 ("test_website", "test_website"), 

42 ("website", "website"), 

43) 

44 

45IMPORT_CHOICES = ( 

46 ("1", "Préserver des métadonnées existantes dans ptf-tools (equal-contrib, coi_statement)"), 

47 ("2", "Remplacer tout par le fichier XML"), 

48) 

49 

50 

51class PtfFormHelper(FormHelper): 

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

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

54 self.label_class = "col-4 col-sm-2" 

55 self.field_class = "col-8 col-sm-6" 

56 self.form_tag = False 

57 

58 

59class PtfModalFormHelper(FormHelper): 

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

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

62 self.label_class = "col-3" 

63 self.field_class = "col-8" 

64 self.form_tag = False 

65 

66 

67class PtfLargeModalFormHelper(FormHelper): 

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

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

70 self.label_class = "col-6" 

71 self.field_class = "col-6" 

72 self.form_tag = False 

73 

74 

75class FormSetHelper(FormHelper): 

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

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

78 self.form_tag = False 

79 self.template = "bootstrap5/whole_uni_formset.html" 

80 

81 

82class ExtIdForm(forms.ModelForm): 

83 class Meta: 

84 model = ExtId 

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

86 

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

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

89 self.fields["id_type"].widget = forms.Select(choices=TYPE_CHOICES) 

90 self.fields["resource"].widget = forms.HiddenInput() 

91 

92 

93class ExtLinkForm(forms.ModelForm): 

94 class Meta: 

95 model = ExtLink 

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

97 widgets = { 

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

99 } 

100 

101 

102class ResourceIdForm(forms.ModelForm): 

103 class Meta: 

104 model = ResourceId 

105 fields = ["id_type", "id_value"] 

106 widgets = { 

107 "id_type": forms.Select(choices=RESOURCE_ID_CHOICES), 

108 } 

109 

110 

111class CollectionForm(forms.ModelForm): 

112 class Meta: 

113 model = Collection 

114 fields = [ 

115 "pid", 

116 "provider", 

117 "coltype", 

118 "title_tex", 

119 "abbrev", 

120 "doi", 

121 "wall", 

122 "alive", 

123 "sites", 

124 ] 

125 widgets = { 

126 "title_tex": forms.TextInput(), 

127 } 

128 

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

130 # Add extra fields before the base class __init__ 

131 self.base_fields["description_en"] = RichTextUploadingFormField( 

132 required=False, label="Description (EN)" 

133 ) 

134 self.base_fields["description_fr"] = RichTextUploadingFormField( 

135 required=False, label="Description (FR)" 

136 ) 

137 

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

139 

140 # self.instance is now set, specify initial values 

141 qs = self.instance.abstract_set.filter(tag="description") 

142 for abstract in qs: 

143 if abstract.lang == "fr": 

144 self.initial["description_fr"] = abstract.value_html 

145 elif abstract.lang == "en": 

146 self.initial["description_en"] = abstract.value_html 

147 

148 

149class ContainerForm(forms.Form): 

150 pid = forms.CharField(required=True, initial="") 

151 title = forms.CharField(required=False, initial="") 

152 trans_title = forms.CharField(required=False, initial="") 

153 publisher = forms.CharField(required=True, initial="") 

154 year = forms.CharField(required=False, initial="") 

155 volume = forms.CharField(required=False, initial="") 

156 number = forms.CharField(required=False, initial="") 

157 icon = forms.FileField(required=False) 

158 

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

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

161 self.container = container 

162 

163 if "data" in kwargs: 

164 # form_invalid: preserve input values 

165 self.fields["pid"].initial = kwargs["data"]["pid"] 

166 self.fields["publisher"].initial = kwargs["data"]["publisher"] 

167 self.fields["year"].initial = kwargs["data"]["year"] 

168 self.fields["volume"].initial = kwargs["data"]["volume"] 

169 self.fields["number"].initial = kwargs["data"]["number"] 

170 self.fields["title"].initial = kwargs["data"]["title"] 

171 self.fields["trans_title"].initial = kwargs["data"]["trans_title"] 

172 elif container: 

173 self.fields["pid"].initial = container.pid 

174 self.fields["title"].initial = container.title_tex 

175 # We arbitrarily take the first translated title 

176 trans_title = self.container.title_set.filter(type="main").first() 

177 if trans_title: 

178 self.fields["trans_title"].initial = trans_title.title_html 

179 else: 

180 self.fields["trans_title"].initial = "" 

181 

182 if container.my_publisher: 

183 self.fields["publisher"].initial = container.my_publisher.pub_name 

184 self.fields["year"].initial = container.fyear 

185 self.fields["volume"].initial = container.volume 

186 self.fields["number"].initial = container.number 

187 

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

189 if extlink.rel == "icon": 

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

191 

192 def clean(self): 

193 cleaned_data = super().clean() 

194 return cleaned_data 

195 

196 

197class ArticleForm(forms.Form): 

198 pid = forms.CharField(required=True, initial="") 

199 title = forms.CharField(required=False, initial="") 

200 fpage = forms.CharField(required=False, initial="") 

201 lpage = forms.CharField(required=False, initial="") 

202 page_count = forms.CharField(required=False, initial="") 

203 page_range = forms.CharField(required=False, initial="") 

204 icon = forms.FileField(required=False) 

205 pdf = forms.FileField(required=False) 

206 coi_statement = forms.CharField(required=False, initial="") 

207 show_body = forms.BooleanField(required=False, initial=True) 

208 do_not_publish = forms.BooleanField(required=False, initial=True) 

209 

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

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

212 self.article = article 

213 

214 if "data" in kwargs: 

215 data = kwargs["data"] 

216 # form_invalid: preserve input values 

217 self.fields["pid"].initial = data["pid"] 

218 if "title" in data: 

219 self.fields["title"].initial = data["title"] 

220 if "fpage" in data: 

221 self.fields["fpage"].initial = data["fpage"] 

222 if "lpage" in data: 

223 self.fields["lpage"].initial = data["lpage"] 

224 if "page_range" in data: 

225 self.fields["page_range"].initial = data["page_range"] 

226 if "page_count" in data: 

227 self.fields["page_count"].initial = data["page_count"] 

228 if "coi_statement" in data: 

229 self.fields["coi_statement"].initial = data["coi_statement"] 

230 if "show_body" in data: 

231 self.fields["show_body"].initial = data["show_body"] 

232 if "do_not_publish" in data: 

233 self.fields["do_not_publish"].initial = data["do_not_publish"] 

234 elif article: 

235 # self.fields['pid'].initial = article.pid 

236 self.fields["title"].initial = article.title_tex 

237 self.fields["fpage"].initial = article.fpage 

238 self.fields["lpage"].initial = article.lpage 

239 self.fields["page_range"].initial = article.page_range 

240 self.fields["coi_statement"].initial = ( 

241 article.coi_statement if article.coi_statement else "" 

242 ) 

243 self.fields["show_body"].initial = article.show_body 

244 self.fields["do_not_publish"].initial = article.do_not_publish 

245 

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

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

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

249 

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

251 if extlink.rel == "icon": 

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

253 

254 qs = article.datastream_set.filter(rel="full-text", mimetype="application/pdf") 

255 if qs.exists(): 

256 datastream = qs.first() 

257 self.fields["pdf"].initial = datastream.location 

258 

259 def clean(self): 

260 cleaned_data = super().clean() 

261 return cleaned_data 

262 

263 

264def cast_volume(element): 

265 # Permet le classement des volumes dans le cas où : 

266 # - un numero de volume est de la forme "11-12" (cf crchim) 

267 # - un volume est de la forme "S5" (cf smai) 

268 if not element: 

269 return "", "" 

270 try: 

271 casted = int(element.split("-")[0]) 

272 extra = "" 

273 except ValueError as _: 

274 casted = int(element.split("-")[0][1:]) 

275 extra = element 

276 return extra, casted 

277 

278 

279def unpack_pid(filename): 

280 # retourne un tableau pour chaque filename de la forme : 

281 # [filename, collection, year, vseries, volume_extra, volume, issue_extra, issue] 

282 # Permet un tri efficace par la suite 

283 collection, year, vseries, volume, issue = filename.split("/")[-1].split(".")[0].split("_") 

284 extra_volume, casted_volume = cast_volume(volume) 

285 extra_issue, casted_issue = cast_volume(issue) 

286 return ( 

287 filename, 

288 collection, 

289 year, 

290 vseries, 

291 extra_volume, 

292 casted_volume, 

293 extra_issue, 

294 casted_issue, 

295 ) 

296 

297 

298def get_volume_choices(colid, to_appear=False): 

299 if settings.IMPORT_CEDRICS_DIRECTLY: 

300 collection_folder = os.path.join(settings.CEDRAM_TEX_FOLDER, colid) 

301 

302 if to_appear: 

303 issue_folders = [ 

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

305 ] 

306 

307 else: 

308 issue_folders = [ 

309 d 

310 for d in os.listdir(collection_folder) 

311 if os.path.isdir(os.path.join(collection_folder, d)) 

312 ] 

313 issue_folders = sorted(issue_folders, reverse=True) 

314 

315 files = [ 

316 (os.path.join(collection_folder, d, d + "-cdrxml.xml"), d) 

317 for d in issue_folders 

318 if os.path.isfile(os.path.join(collection_folder, d, d + "-cdrxml.xml")) 

319 ] 

320 else: 

321 if to_appear: 

322 volumes_path = os.path.join( 

323 settings.CEDRAM_XML_FOLDER, colid, "metadata", f"{colid}_0*.xml" 

324 ) 

325 else: 

326 volumes_path = os.path.join(settings.CEDRAM_XML_FOLDER, colid, "metadata", "*.xml") 

327 

328 files = [unpack_pid(filename) for filename in glob.glob(volumes_path)] 

329 sort = sorted(files, key=itemgetter(1, 2, 3, 4, 5, 6, 7), reverse=True) 

330 files = [(item[0], item[0].split("/")[-1]) for item in sort] 

331 return files 

332 

333 

334def get_article_choices(colid, issue_name): 

335 issue_folder = os.path.join(settings.CEDRAM_TEX_FOLDER, colid, issue_name) 

336 article_choices = [ 

337 (d, os.path.basename(d)) 

338 for d in os.listdir(issue_folder) 

339 if ( 

340 os.path.isdir(os.path.join(issue_folder, d)) 

341 and os.path.isfile(os.path.join(issue_folder, d, d + "-cdrxml.xml")) 

342 ) 

343 ] 

344 article_choices = sorted(article_choices, reverse=True) 

345 

346 return article_choices 

347 

348 

349class ImportArticleForm(forms.Form): 

350 issue = forms.ChoiceField( 

351 label="Numéro", 

352 ) 

353 article = forms.ChoiceField( 

354 label="Article", 

355 ) 

356 

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

358 # we need to pop this extra colid kwarg if not, the call to super.__init__ won't work 

359 colid = kwargs.pop("colid") 

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

361 volumes = get_volume_choices(colid) 

362 self.fields["issue"].choices = volumes 

363 articles = [] 

364 if volumes: 

365 articles = get_article_choices(colid, volumes[0][1]) 

366 self.fields["article"].choices = articles 

367 

368 

369class ImportContainerForm(forms.Form): 

370 filename = forms.ChoiceField( 

371 label="Numéro", 

372 ) 

373 remove_email = forms.BooleanField( 

374 label="Delete emails from CEDRAM contribs?", 

375 initial=True, 

376 required=False, 

377 ) 

378 remove_date_prod = forms.BooleanField( 

379 label="Delete published dates from CEDRAM?", 

380 initial=True, 

381 required=False, 

382 ) 

383 

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

385 # we need to pop this extra colid kwarg if not, the call to super.__init__ won't work 

386 colid = kwargs.pop("colid") 

387 to_appear = kwargs.pop("to_appear") 

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

389 self.fields["filename"].choices = get_volume_choices(colid, to_appear) 

390 

391 

392class DiffContainerForm(forms.Form): 

393 import_choice = forms.ChoiceField( 

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

395 ) 

396 

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

398 # we need to pop this extra full_path kwarg if not, the call to super.__init__ won't work 

399 kwargs.pop("colid") 

400 # filename = kwargs.pop('filename') 

401 # to_appear = kwargs.pop('to_appear') 

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

403 

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

405 

406 

407class ImportEditflowArticleForm(forms.Form): 

408 def validate_xml_file(value): 

409 if not value.name.lower().endswith(".xml"): 

410 raise forms.ValidationError("Only .xml files are allowed.") 

411 

412 editflow_xml_file = forms.FileField( 

413 label="Import an article from an XML file provided by Editflow.", 

414 required=True, 

415 validators=[validate_xml_file], 

416 widget=forms.ClearableFileInput(attrs={"accept": ".xml"}), 

417 help_text="Only .xml files are accepted.", 

418 ) 

419 

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

421 # we need to pop this extra full_path kwarg if not, the call to super.__init__ won't work 

422 kwargs.pop("colid", None) 

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

424 

425 

426class RegisterPubmedForm(forms.Form): 

427 CHOICES = [ 

428 ("off", "Yes"), 

429 ("on", "No, update the article in PubMed"), 

430 ] 

431 update_article = forms.ChoiceField( 

432 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/", 

433 widget=forms.RadioSelect, 

434 choices=CHOICES, 

435 required=False, 

436 initial="on", 

437 ) 

438 

439 

440class CreateFrontpageForm(forms.Form): 

441 create_frontpage = forms.BooleanField( 

442 label="Update des frontpages des articles avec date de mise en ligne ?", 

443 initial=False, 

444 required=False, 

445 ) 

446 

447 

448class RelatedForm(forms.ModelForm): 

449 doi_list = forms.CharField( 

450 required=False, 

451 widget=forms.Textarea(attrs={"rows": "10", "placeholder": "doi_1\ndoi_2\ndoi_3\n"}), 

452 ) 

453 

454 exclusion_list = forms.CharField( 

455 required=False, 

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

457 ) 

458 

459 class Meta: 

460 model = RelatedArticles 

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

462 

463 

464class GraphicalAbstractForm(forms.ModelForm): 

465 """Form for the Graphical Abstract model""" 

466 

467 class Meta: 

468 model = GraphicalAbstract 

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

470 

471 

472class PageForm(forms.ModelForm): 

473 class Meta: 

474 model = Page 

475 fields = [ 

476 "menu_title_en", 

477 "menu_title_fr", 

478 "parent_page", 

479 "content_en", 

480 "content_fr", 

481 "state", 

482 "slug_en", 

483 "slug_fr", 

484 "menu_order", 

485 "position", 

486 "mersenne_id", 

487 "site_id", 

488 ] 

489 

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

491 site_id = kwargs.pop("site_id") 

492 user = kwargs.pop("user") 

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

494 

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

496 

497 if not user.is_staff: 497 ↛ 504line 497 didn't jump to line 504 because the condition on line 497 was always true

498 for field_name in ["mersenne_id", "site_id"]: 

499 field = self.fields[field_name] 

500 # Hide the field is not enough, otherwise BaseForm._clean_fields will not get the value 

501 field.disabled = True 

502 field.widget = field.hidden_widget() 

503 

504 colid = get_collection_id(int(site_id)) 

505 

506 # By default, CKEditor stores files in 1 folder 

507 # We want to store the files in a @colid folder 

508 for field_name in ["content_en", "content_fr"]: 

509 field = self.fields[field_name] 

510 widget = field.widget 

511 widget.config["filebrowserUploadUrl"] = "/ckeditor/upload/" + colid 

512 widget.config["filebrowserBrowseUrl"] = "/ckeditor/browse/" + colid 

513 

514 pages = Page.objects.filter(site_id=site_id, parent_page=None) 

515 if self.instance: 515 ↛ 518line 515 didn't jump to line 518 because the condition on line 515 was always true

516 pages = pages.exclude(id=self.instance.id) 

517 

518 choices = [(p.id, p.menu_title_en) for p in pages if p.menu_title_en] 

519 self.fields["parent_page"].choices = sorted( 

520 choices + [(None, "---------")], key=lambda x: x[1] 

521 ) 

522 

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

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

525 

526 if is_site_en_only(site_id): 526 ↛ 527line 526 didn't jump to line 527 because the condition on line 526 was never true

527 self.fields.pop("content_fr") 

528 self.fields.pop("menu_title_fr") 

529 self.fields.pop("slug_fr") 

530 elif is_site_fr_only(site_id): 530 ↛ 531line 530 didn't jump to line 531 because the condition on line 530 was never true

531 self.fields.pop("content_en") 

532 self.fields.pop("menu_title_en") 

533 self.fields.pop("slug_en") 

534 

535 def save_model(self, request, obj, form, change): 

536 obj.site_id = form.cleaned_data["site_id"] 

537 super().save_model(request, obj, form, change) 

538 

539 

540class NewsForm(forms.ModelForm): 

541 class Meta: 

542 model = News 

543 fields = [ 

544 "title_en", 

545 "title_fr", 

546 "content_en", 

547 "content_fr", 

548 "site_id", 

549 ] 

550 

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

552 site_id = kwargs.pop("site_id") 

553 user = kwargs.pop("user") 

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

555 

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

557 

558 if not user.is_staff: 

559 for field_name in ["site_id"]: 

560 field = self.fields[field_name] 

561 # Hide the field is not enough, otherwise BaseForm._clean_fields will not get the value 

562 field.disabled = True 

563 field.widget = field.hidden_widget() 

564 

565 colid = get_collection_id(int(site_id)) 

566 

567 # By default, CKEditor stores files in 1 folder 

568 # We want to store the files in a @colid folder 

569 for field_name in ["content_en", "content_fr"]: 

570 field = self.fields[field_name] 

571 widget = field.widget 

572 widget.config["filebrowserUploadUrl"] = "/ckeditor/upload/" + colid 

573 widget.config["filebrowserBrowseUrl"] = "/ckeditor/browse/" + colid 

574 

575 if is_site_en_only(site_id): 

576 self.fields.pop("content_fr") 

577 self.fields.pop("title_fr") 

578 elif is_site_fr_only(site_id): 

579 self.fields.pop("content_en") 

580 self.fields.pop("title_en") 

581 

582 def save_model(self, request, obj, form, change): 

583 obj.site_id = form.cleaned_data["site_id"] 

584 super().save_model(request, obj, form, change) 

585 

586 

587class InviteUserForm(forms.Form): 

588 """Base form to invite user.""" 

589 

590 required_css_class = "required" 

591 

592 first_name = forms.CharField(label="First name", max_length=150, required=True) 

593 last_name = forms.CharField(label="Last name", max_length=150, required=True) 

594 email = forms.EmailField(label="E-mail address", required=True) 

595 

596 

597class InvitationAdminChangeForm(forms.ModelForm): 

598 class Meta: 

599 model = Invitation 

600 fields = "__all__" 

601 

602 def clean_extra_data(self): 

603 """ 

604 Enforce the JSON structure with the InvitationExtraData dataclass interface. 

605 """ 

606 try: 

607 InvitationExtraData(**self.cleaned_data["extra_data"]) 

608 except Exception as e: 

609 raise forms.ValidationError(e) 

610 

611 return self.cleaned_data["extra_data"] 

612 

613 

614class InvitationAdminAddForm(InvitationAdminChangeForm, CleanEmailMixin): 

615 class Meta: 

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

617 

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

619 """ 

620 Populate the invitation data, save in DB and send the invitation e-mail. 

621 """ 

622 cleaned_data = self.clean() 

623 email = cleaned_data["email"] 

624 params = {"email": email} 

625 if cleaned_data.get("inviter"): 

626 params["inviter"] = cleaned_data["inviter"] 

627 else: 

628 user = getattr(self, "user", None) 

629 if isinstance(user, get_user_model()): 

630 params["inviter"] = user 

631 instance = Invitation.create(**params) 

632 instance.first_name = cleaned_data["first_name"] 

633 instance.last_name = cleaned_data["last_name"] 

634 instance.extra_data = cleaned_data.get("extra_data", {}) 

635 instance.save() 

636 full_name = f"{instance.first_name} {instance.last_name}" 

637 instance.send_invitation(self.request, **{"full_name": full_name}) 

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

639 return instance 

640 

641 

642class SignupForm(BaseSignupForm): 

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