Coverage for portality / forms / article_forms.py: 75%
472 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 00:09 +0100
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 00:09 +0100
1from copy import deepcopy
2from typing import Literal, Optional
4from flask import render_template, url_for, request
5from flask_login import current_user
6from wtforms import Form, validators
7from wtforms import StringField, TextAreaField, FormField, FieldList
8from wtforms.fields.core import UnboundField
10from portality import regex, models
11from portality.bll import DOAJ
12from portality.core import app
13from portality.crosswalks.article_form import ArticleFormXWalk
14from portality.forms.fields import DOAJSelectField, TagListField
15from portality.forms.validate import OptionalIf, ThisOrThat, NoScriptTag, DifferentTo
16from portality.lib import dates
17from portality.ui.messages import Messages
18from portality.ui import templates
21#########################################
22# Form infrastructure
25class FormContext(object):
26 """
27 ~~FormContext:FormContext->Formulaic:Library~~
28 """
29 def __init__(self, form_data=None, source=None, formulaic_context=None):
30 # initialise our core properties
31 self._source = source
32 self._target = None
33 self._form_data = form_data
34 self._form = None
35 self._renderer = None
36 self._template = None
37 self._alert = []
38 self._info = ''
39 self._formulaic = formulaic_context
41 # initialise the renderer (falling back to a default if necessary)
42 self.make_renderer()
43 if self.renderer is None:
44 self.renderer = Renderer()
46 # specify the jinja template that will wrap the renderer
47 self.set_template()
49 # now create our form instance, with the form_data (if there is any)
50 if form_data is not None:
51 self.data2form()
53 # if there isn't any form data, then we should create the form properties from source instead
54 elif source is not None:
55 self.source2form()
57 # if there is no source, then a blank form object
58 else:
59 self.blank_form()
61 ############################################################
62 # getters and setters on the main FormContext properties
63 ############################################################
65 @property
66 def form(self):
67 return self._form
69 @form.setter
70 def form(self, val):
71 self._form = val
73 @property
74 def source(self) -> Optional:
75 return self._source
77 @property
78 def form_data(self):
79 return self._form_data
81 @property
82 def target(self):
83 return self._target
85 @target.setter
86 def target(self, val):
87 self._target = val
89 @property
90 def renderer(self):
91 return self._renderer
93 @renderer.setter
94 def renderer(self, val):
95 self._renderer = val
97 @property
98 def template(self):
99 return self._template
101 @template.setter
102 def template(self, val):
103 self._template = val
105 @property
106 def alert(self):
107 return self._alert
109 def add_alert(self, val):
110 self._alert.append(val)
112 @property
113 def info(self):
114 return self._info
116 @info.setter
117 def info(self, val):
118 self._info = val
120 ############################################################
121 # Lifecycle functions that subclasses should implement
122 ############################################################
124 def make_renderer(self):
125 """
126 This will be called during init, and must populate the self.render property
127 """
128 pass
130 def set_template(self):
131 """
132 This will be called during init, and must populate the self.template property with the path to the jinja template
133 """
134 pass
136 def pre_validate(self):
137 """
138 This will be run before validation against the form is run.
139 Use it to patch the form with any relevant data, such as fields which were disabled
140 """
141 pass
143 def blank_form(self):
144 """
145 This will be called during init, and must populate the self.form_data property with an instance of the form in this
146 context, based on no originating source or form data
147 """
148 pass
150 def data2form(self):
151 """
152 This will be called during init, and must convert the form_data into an instance of the form in this context,
153 and write to self.form
154 """
155 pass
157 def source2form(self):
158 """
159 This will be called during init, and must convert the source object into an instance of the form in this
160 context, and write to self.form
161 """
162 pass
164 def form2target(self):
165 """
166 Convert the form object into a the target system object, and write to self.target
167 """
168 pass
170 def patch_target(self):
171 """
172 Patch the target with data from the source. This will be run by the finalise method (unless you override it)
173 """
174 pass
176 def finalise(self, *args, **kwargs):
177 """
178 Finish up with the FormContext. Carry out any final workflow tasks, etc.
179 """
180 self.form2target()
181 self.patch_target()
183 ############################################################
184 # Functions which can be called directly, but may be overridden if desired
185 ############################################################
187 def validate(self):
188 self.pre_validate()
189 f = self.form
190 valid = False
191 if f is not None:
192 valid = f.validate()
194 # if this isn't a valid form, record the fields that have errors
195 # with the renderer for use later
196 if not valid:
197 error_fields = []
198 for field in self.form:
199 if field.errors:
200 error_fields.append(field.short_name)
202 return valid
204 @property
205 def errors(self):
206 f = self.form
207 if f is not None:
208 return f.errors
209 return False
211 def render_template(self, **kwargs):
212 return render_template(self.template, form_context=self, **kwargs)
214 def fieldset(self, fieldset_name=None):
215 return self._formulaic.fieldset(fieldset_name)
217 def fieldsets(self):
218 return self._formulaic.fieldsets()
220 def check_field_group_exists(self, field_group_name):
221 return self.renderer.check_field_group_exists(field_group_name)
223 @property
224 def ui_settings(self):
225 return self._formulaic.ui_settings
228class Renderer(object):
229 """
230 ~~FormContextRenderer:FormContext->FormHelper:FormContext~~
231 """
232 def __init__(self):
233 self.FIELD_GROUPS = {}
234 self.fh = FormHelperBS3()
235 self._error_fields = []
236 self._disabled_fields = []
237 self._disable_all_fields = False
238 self._highlight_completable_fields = False
240 def check_field_group_exists(self, field_group_name):
241 """ Return true if the field group exists in this form """
242 group_def = self.FIELD_GROUPS.get(field_group_name)
243 if group_def is None:
244 return False
245 else:
246 return True
248 def render_field_group(self, form_context, field_group_name=None, group_cfg=None):
249 if field_group_name is None:
250 return self._render_all(form_context)
252 # get the group definition
253 group_def = self.FIELD_GROUPS.get(field_group_name)
254 if group_def is None:
255 return ""
257 # build the frag
258 frag = ""
259 for entry in group_def:
260 field_name = list(entry.keys())[0]
261 config = entry.get(field_name)
262 config = deepcopy(config)
264 config = self._rewrite_extra_fields(form_context, config)
265 field = form_context.form[field_name]
267 if field_name in self.disabled_fields or self._disable_all_fields is True:
268 config["disabled"] = "disabled"
270 if self._highlight_completable_fields is True:
271 valid = field.validate(form_context.form)
272 config["complete_me"] = not valid
274 if group_cfg is not None:
275 config.update(group_cfg)
277 frag += self.fh.render_field(field, **config)
279 return frag
281 @property
282 def error_fields(self):
283 return self._error_fields
285 def set_error_fields(self, fields):
286 self._error_fields = fields
288 @property
289 def disabled_fields(self):
290 return self._disabled_fields
292 def set_disabled_fields(self, fields):
293 self._disabled_fields = fields
295 def disable_all_fields(self, disable):
296 self._disable_all_fields = disable
298 def _rewrite_extra_fields(self, form_context, config):
299 if "extra_input_fields" in config:
300 config = deepcopy(config)
301 for opt, field_ref in config.get("extra_input_fields").items():
302 extra_field = form_context.form[field_ref]
303 config["extra_input_fields"][opt] = extra_field
304 return config
306 def _render_all(self, form_context):
307 frag = ""
308 for field in form_context.form:
309 frag += self.fh.render_field(form_context, field.short_name)
310 return frag
312 def find_field(self, field, field_group):
313 for index, item in enumerate(self.FIELD_GROUPS[field_group]):
314 if field in item:
315 return index
317 def insert_field_after(self, field_to_insert, after_this_field, field_group):
318 self.FIELD_GROUPS[field_group].insert(
319 self.find_field(after_this_field, field_group) + 1,
320 field_to_insert
321 )
324class FormHelperBS3(object):
325 """
326 ~~FormHelper:FormContext->Bootstrap3:Technology~~
327 ~~->WTForms:Library~~
328 """
329 def render_field(self, field, **kwargs):
330 # begin the frag
331 frag = ""
333 # deal with the first error if it is relevant
334 first_error = kwargs.pop("first_error", False)
335 if first_error:
336 frag += '<a name="first_problem"></a>'
338 # call the correct render function on the field type
339 if field.type == "FormField":
340 frag += self._form_field(field, **kwargs)
341 elif field.type == "FieldList":
342 frag += self._field_list(field, **kwargs)
343 else:
344 frag += self._wrap_control_group(field, self._render_field(field, **kwargs), **kwargs)
346 return frag
348 def _wrap_control_group(self, field, contents, **kwargs):
349 hidden = kwargs.pop("hidden", False)
350 container_class = kwargs.pop("container_class", None)
351 disabled = kwargs.pop("disabled", False)
352 render_subfields_horizontal = kwargs.pop("render_subfields_horizontal", False)
353 complete_me = kwargs.get("complete_me", False)
355 frag = '<div class="form-group'
356 if field.errors:
357 frag += " error"
358 if render_subfields_horizontal:
359 frag += " row"
360 if container_class is not None:
361 frag += " " + container_class
362 if complete_me:
363 frag += " complete-me"
364 frag += '" id="'
365 frag += field.short_name + '-container"'
366 if hidden:
367 frag += ' style="display:none;"'
368 frag += ">"
369 if contents is not None:
370 frag += contents
371 frag += "</div>"
373 return frag
375 def _form_field(self, field, **kwargs):
376 # get the useful kwargs
377 render_subfields_horizontal = kwargs.pop("render_subfields_horizontal", False)
379 frag = ""
380 # for each subfield, do the render
381 for subfield in field:
382 if render_subfields_horizontal and not (subfield.type == 'CSRFTokenField' and not subfield.value):
383 subfield_width = "3"
384 remove = []
385 for kwarg, val in kwargs.items():
386 if kwarg == 'subfield_display-' + subfield.short_name:
387 subfield_width = val
388 remove.append(kwarg)
389 for rm in remove:
390 del kwargs[rm]
391 frag += '<div class="col-md-' + subfield_width + ' nested-field-container">'
392 frag += self._render_field(subfield, maximise_width=True, **kwargs)
393 frag += "</div>"
394 else:
395 frag += self._render_field(subfield, **kwargs)
397 return self._wrap_control_group(field, frag, **kwargs)
399 def _field_list(self, field, **kwargs):
400 # for each subfield, do the render
401 frag = ""
402 for subfield in field:
403 if subfield.type == "FormField":
404 frag += self.render_field(subfield, **kwargs)
405 else:
406 frag = self._wrap_control_group(field, self._render_field(field, **kwargs), **kwargs)
407 return frag
409 def _render_field(self, field, **kwargs):
410 # interesting arguments from keywords
411 extra_input_fields = kwargs.get("extra_input_fields")
412 q_num = kwargs.pop("q_num", None)
413 maximise_width = kwargs.pop("maximise_width", False)
414 clazz = kwargs.get("class", "")
415 label_width = kwargs.get("label_width", 3)
416 field_width = 12 - label_width
417 field_width = str(kwargs.get("field_width", field_width))
418 if label_width > 0:
419 label_width = str(label_width)
421 if field.type == 'CSRFTokenField' and not field.value:
422 return ""
424 frag = ""
426 # If this is the kind of field that requires a label, give it one
427 if field.type not in ['SubmitField', 'HiddenField', 'CSRFTokenField']:
428 if q_num is not None:
429 frag += '<a class="animated" name="' + q_num + '"></a>'
430 if label_width != 0:
431 frag += '<label class="control-label col-md-' + label_width + '" for="' + field.short_name + '">'
432 if q_num is not None:
433 frag += '<a class="animated orange" href="#' + field.short_name + '-container" title="Link to this question" tabindex="-1">' + q_num + ')</a> '
434 frag += field.label.text
435 if field.flags.required or field.flags.display_required_star:
436 frag += ' <span class="red">*</span>'
437 frag += "</label>"
439 # determine if this is a checkbox
440 is_checkbox = False
441 if (field.type == "SelectMultipleField"
442 and field.option_widget.__class__.__name__ == 'CheckboxInput'
443 and field.widget.__class__.__name__ == 'ListWidget'):
444 is_checkbox = True
446 extra_class = ""
447 if is_checkbox:
448 extra_class += " checkboxes"
450 frag += '<div class="col-md-' + field_width + ' ' + extra_class + '">'
451 if field.type == "RadioField":
452 for subfield in field:
453 frag += self._render_radio(subfield, **kwargs)
454 elif is_checkbox:
455 frag += '<ul id="' + field.short_name + '">'
456 for subfield in field:
457 frag += self._render_checkbox(subfield, **kwargs)
458 frag += "</ul>"
459 else:
460 if maximise_width:
461 clazz += " col-xs-12"
462 kwargs["class"] = clazz
463 render_args = {}
464 # filter anything that shouldn't go in as a field attribute
465 for k, v in kwargs.items():
466 if k in ["class", "style", "disabled"] or k.startswith("data-"):
467 render_args[k] = v
468 frag += field(**render_args) # FIXME: this is probably going to do some weird stuff
470 if field.errors:
471 frag += '<div class="alert alert--danger"><ul>'
472 for error in field.errors:
473 frag += '<li>' + error + '</li>'
474 frag += "</ul></div>"
476 if field.description:
477 frag += '<p class="help-block">' + field.description + '</p>'
479 frag += "</div>"
480 return frag
482 def _render_radio(self, field, **kwargs):
483 extra_input_fields = kwargs.pop("extra_input_fields", {})
484 label_width = "12"
486 frag = '<label class="radio control-label col-md-' + label_width + '" for="' + field.short_name + '">'
487 frag += field(**kwargs)
488 frag += '<span class="label-text">' + field.label.text + '</span>'
490 if field.label.text in list(extra_input_fields.keys()):
491 frag += " " + extra_input_fields[field.label.text](**{"class" : "extra_input_field"})
493 frag += "</label>"
494 return frag
496 def _render_checkbox(self, field, **kwargs):
497 extra_input_fields = kwargs.pop("extra_input_fields", {})
499 frag = "<li>"
500 frag += field(**kwargs)
501 frag += '<label class="control-label" for="' + field.short_name + '">' + field.label.text + '</label>'
503 if field.label.text in list(extra_input_fields.keys()):
504 eif = extra_input_fields[field.label.text]
505 if not isinstance(eif, UnboundField):
506 frag += " " + extra_input_fields[field.label.text](**{"class" : "extra_input_field"})
508 frag += "</li>"
509 return frag
512#########################################
513# Form definition
514# ~~Article:Form~~
516ISSN_ERROR = 'An ISSN or EISSN should be 7 or 8 digits long, separated by a dash, e.g. 1234-5678. If it is 7 digits long, it must end with the letter X (e.g. 1234-567X).'
517EMAIL_CONFIRM_ERROR = 'Please double check the email addresses - they do not match.'
518DATE_ERROR = "Date must be supplied in the form YYYY-MM-DD"
519DOI_ERROR = 'Invalid DOI. A DOI can optionally start with a prefix (such as "doi:"), followed by "10." and the remainder of the identifier'
520ORCID_ERROR = "Invalid ORCID iD. Please enter your ORCID iD structured as: https://orcid.org/0000-0000-0000-0000. URLs must start with https."
521IDENTICAL_ISSNS_ERROR = "The Print and Online ISSNs supplied are identical. If you supply 2 ISSNs they must be different."
523start_year = app.config.get("METADATA_START_YEAR", dates.now().year - 15)
524YEAR_CHOICES = [(str(y), str(y)) for y in range(dates.now().year + 1, start_year - 1, -1)]
525MONTH_CHOICES = [("","---"), ("1", "01"), ("2", "02"), ("3", "03"), ("4", "04"), ("5", "05"), ("6", "06"), ("7", "07"), ("8", "08"), ("9", "09"), ("10", "10"), ("11", "11"), ("12", "12")]
526INITIAL_AUTHOR_FIELDS = 3
529def choices_for_article_issns(user, article_id=None,
530 issn_type: Literal['eissn', 'pissn', 'all'] = 'all'):
532 owner = None
533 if "admin" in user.role and article_id is not None:
534 # ~~->Article:Model~~
535 a = models.Article.pull(article_id)
536 if a:
537 owner = a.get_owner()
539 if not owner:
540 owner = user.id
542 if issn_type == 'eissn':
543 issn_field = 'bibjson.eissn.exact'
544 elif issn_type == 'pissn':
545 issn_field = 'bibjson.pissn.exact'
546 else:
547 issn_field = 'index.issn.exact'
549 # ~~->Journal:Model~~
550 issns = models.Journal.issns_by_owner(owner, in_doaj=True, issn_field=issn_field)
551 ic = [("", "Select an ISSN")] + [(i, i) for i in issns]
552 return ic
555class AuthorForm(Form):
556 """
557 ~~->$ Author:Form~~
558 """
559 name = StringField("Name", [validators.Optional(),NoScriptTag()])
560 affiliation = StringField("Affiliation", [validators.Optional(), NoScriptTag()])
561 orcid_id = StringField("ORCID iD", [validators.Optional(), validators.Regexp(regex=regex.ORCID_COMPILED, message=ORCID_ERROR)])
564class ArticleForm(Form):
565 title = StringField("Article title <em>(required)</em>", [validators.DataRequired(), NoScriptTag()])
566 doi = StringField("DOI", [OptionalIf("fulltext", "You must provide the DOI or the Full-Text URL"), validators.Regexp(regex=regex.DOI_COMPILED, message=DOI_ERROR)], description="(You must provide a DOI and/or a Full-Text URL)")
567 authors = FieldList(FormField(AuthorForm), min_entries=1) # We have to do the validation for this at a higher level
568 abstract = TextAreaField("Abstract", [validators.Optional(), NoScriptTag()])
569 keywords = TagListField("Keywords", [validators.Optional(), NoScriptTag()], description="Use a , to separate keywords") # enhanced with select2
570 fulltext = StringField("Full-text URL", [OptionalIf("doi", "You must provide the Full-Text URL or the DOI"), validators.URL()])
571 publication_year = DOAJSelectField("Year", [validators.Optional()], choices=YEAR_CHOICES, default=str(dates.now().year))
572 publication_month = DOAJSelectField("Month", [validators.Optional()], choices=MONTH_CHOICES, default="" )
573 pissn = DOAJSelectField("Print", [
574 ThisOrThat("eissn", "Either this field or Online ISSN is required"),
575 DifferentTo("eissn", message=IDENTICAL_ISSNS_ERROR)
576 ], choices=[]) # choices set at construction
577 eissn = DOAJSelectField("Online", [
578 ThisOrThat("pissn", "Either this field or Print ISSN is required"),
579 DifferentTo("pissn", message=IDENTICAL_ISSNS_ERROR)
580 ], choices=[]) # choices set at construction
582 volume = StringField("Volume", [validators.Optional(), NoScriptTag()])
583 number = StringField("Issue", [validators.Optional(), NoScriptTag()])
584 start = StringField("Start", [validators.Optional(), NoScriptTag()])
585 end = StringField("End", [validators.Optional(), NoScriptTag()])
587 def __init__(self, *args, **kwargs):
588 super(ArticleForm, self).__init__(*args, **kwargs)
589 self.set_choices()
591 def set_choices(self, user=None, article_id=None):
592 user = user or current_user
593 try:
594 self.pissn.choices = choices_for_article_issns(user, issn_type='pissn', article_id=article_id)
595 self.eissn.choices = choices_for_article_issns(user, issn_type='eissn', article_id=article_id)
596 except Exception as e:
597 # not logged in, and current_user is broken
598 # probably you are loading the class from the command line
599 app.logger.exception(str(e))
603#########################################
604# Formcontexts and factory
606class ArticleFormFactory(object):
607 """
608 ~~ArticleForm:Factory->AdminArticleMetadata:FormContext~~
609 ~~->PublisherArticleMetadata:FormContext~~
610 """
611 @classmethod
612 def get_from_context(cls, role, source=None, form_data=None, user=None):
613 if role == "admin":
614 return AdminMetadataArticleForm(source=source, form_data=form_data, user=user)
615 if role == "publisher":
616 return PublisherMetadataForm(source=source, form_data=form_data, user=user)
619class MetadataForm(FormContext):
620 """
621 ~~ArticleMetadata:FormContext->Article:Form~~
622 ~~->ArticleForm:Crosswalk~~
623 ~~->Article:Service~~
624 """
626 def __init__(self, source, form_data, user):
627 self.user = user
628 self.author_error = False
629 super(MetadataForm, self).__init__(source=source, form_data=form_data)
631 def _set_choices(self):
632 if self.source is not None:
633 self.form.set_choices(user=self.user, article_id=self.source.id)
635 def modify_authors_if_required(self, request_data):
637 more_authors = request_data.get("more_authors")
638 remove_author = None
639 for v in list(request.values.keys()):
640 if v.startswith("remove_authors"):
641 remove_author = v.split("-")[1]
643 # if the user wants more authors, add an extra entry
644 if more_authors:
645 return self.render_template(more_authors=True)
647 # if the user wants to remove an author, do the various back-flips required
648 if remove_author is not None:
649 return self.render_template(remove_authors=remove_author)
651 def _check_for_author_errors(self, **kwargs):
653 if "more_authors" in kwargs and kwargs["more_authors"] == True:
654 self.form.authors.append_entry()
655 if "remove_authors" in kwargs:
656 keep = []
657 while len(self.form.authors.entries) > 0:
658 entry = self.form.authors.pop_entry()
659 if entry.short_name == "authors-" + kwargs["remove_author"]:
660 break
661 else:
662 keep.append(entry)
663 while len(keep) > 0:
664 self.form.authors.append_entry(keep.pop().data)
666 def _validate_authors(self):
667 counted = 0
668 for entry in self.form.authors.entries:
669 name = entry.data.get("name")
670 if name is not None and name != "":
671 counted += 1
672 return counted >= 1
674 def blank_form(self):
675 self.form = ArticleForm()
676 self._set_choices()
678 def source2form(self):
679 self.form = ArticleForm()
680 ArticleFormXWalk.obj2form(self.form, article=self.source)
681 self._set_choices()
683 def data2form(self):
684 self.form = ArticleForm(formdata=self.form_data)
685 self._set_choices()
687 def form2target(self):
688 self.target = ArticleFormXWalk.form2obj(form=self.form)
690 def validate(self):
691 if not self._validate_authors():
692 self.author_error = True
693 if not self.form.validate():
694 return False
695 return True
697 def finalise(self, duplicate_check = True):
698 self.form2target()
699 if not self.author_error:
700 article_service = DOAJ.articleService()
701 article_service.create_article(self.target, self.user, add_journal_info=True,
702 update_article_id=self.source.id if self.source is not None else None,
703 duplicate_check = duplicate_check)
704 article_url = url_for('doaj.article_page', identifier=self.target.id)
705 msg, how = Messages.ARTICLE_METADATA_SUBMITTED_FLASH
706 Messages.flash_with_url(msg.format(url=article_url), how)
707 else:
708 return
711class PublisherMetadataForm(MetadataForm):
712 """
713 ~~PublisherArticleMetadata:FormContext->ArticleMetadata:FormContext~~
714 """
715 def __init__(self, source, form_data, user):
716 super(PublisherMetadataForm, self).__init__(source=source, form_data=form_data, user=user)
718 def set_template(self):
719 self.template = templates.PUBLISHER_ARTICLE_METADATA
721 def render_template(self, **kwargs):
722 self._check_for_author_errors(**kwargs)
723 if "validated" in kwargs and kwargs["validated"] == True:
724 self.blank_form()
725 return render_template(self.template, form=self.form, form_context=self, author_error=self.author_error)
728class AdminMetadataArticleForm(MetadataForm):
729 """
730 ~~AdminArticleMetadata:FormContext->ArticleMetadata:FormContext~~
731 """
732 def __init__(self, source, form_data, user):
733 super(AdminMetadataArticleForm, self).__init__(source=source, form_data=form_data, user=user)
735 def set_template(self):
736 self.template = templates.ADMIN_ARTICLE_FORM
738 def render_template(self, **kwargs):
739 self._check_for_author_errors(**kwargs)
740 return render_template(self.template, form=self.form, form_context=self, author_error=self.author_error)