Coverage for portality/forms/article_forms.py: 49%
463 statements
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-20 16:12 +0100
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-20 16:12 +0100
1from copy import deepcopy
2from datetime import datetime
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.ui.messages import Messages
19#########################################
20# Form infrastructure
23class FormContext(object):
24 """
25 ~~FormContext:FormContext->Formulaic:Library~~
26 """
27 def __init__(self, form_data=None, source=None, formulaic_context=None):
28 # initialise our core properties
29 self._source = source
30 self._target = None
31 self._form_data = form_data
32 self._form = None
33 self._renderer = None
34 self._template = None
35 self._alert = []
36 self._info = ''
37 self._formulaic = formulaic_context
39 # initialise the renderer (falling back to a default if necessary)
40 self.make_renderer()
41 if self.renderer is None:
42 self.renderer = Renderer()
44 # specify the jinja template that will wrap the renderer
45 self.set_template()
47 # now create our form instance, with the form_data (if there is any)
48 if form_data is not None:
49 self.data2form()
51 # if there isn't any form data, then we should create the form properties from source instead
52 elif source is not None:
53 self.source2form()
55 # if there is no source, then a blank form object
56 else:
57 self.blank_form()
59 ############################################################
60 # getters and setters on the main FormContext properties
61 ############################################################
63 @property
64 def form(self):
65 return self._form
67 @form.setter
68 def form(self, val):
69 self._form = val
71 @property
72 def source(self):
73 return self._source
75 @property
76 def form_data(self):
77 return self._form_data
79 @property
80 def target(self):
81 return self._target
83 @target.setter
84 def target(self, val):
85 self._target = val
87 @property
88 def renderer(self):
89 return self._renderer
91 @renderer.setter
92 def renderer(self, val):
93 self._renderer = val
95 @property
96 def template(self):
97 return self._template
99 @template.setter
100 def template(self, val):
101 self._template = val
103 @property
104 def alert(self):
105 return self._alert
107 def add_alert(self, val):
108 self._alert.append(val)
110 @property
111 def info(self):
112 return self._info
114 @info.setter
115 def info(self, val):
116 self._info = val
118 ############################################################
119 # Lifecycle functions that subclasses should implement
120 ############################################################
122 def make_renderer(self):
123 """
124 This will be called during init, and must populate the self.render property
125 """
126 pass
128 def set_template(self):
129 """
130 This will be called during init, and must populate the self.template property with the path to the jinja template
131 """
132 pass
134 def pre_validate(self):
135 """
136 This will be run before validation against the form is run.
137 Use it to patch the form with any relevant data, such as fields which were disabled
138 """
139 pass
141 def blank_form(self):
142 """
143 This will be called during init, and must populate the self.form_data property with an instance of the form in this
144 context, based on no originating source or form data
145 """
146 pass
148 def data2form(self):
149 """
150 This will be called during init, and must convert the form_data into an instance of the form in this context,
151 and write to self.form
152 """
153 pass
155 def source2form(self):
156 """
157 This will be called during init, and must convert the source object into an instance of the form in this
158 context, and write to self.form
159 """
160 pass
162 def form2target(self):
163 """
164 Convert the form object into a the target system object, and write to self.target
165 """
166 pass
168 def patch_target(self):
169 """
170 Patch the target with data from the source. This will be run by the finalise method (unless you override it)
171 """
172 pass
174 def finalise(self, *args, **kwargs):
175 """
176 Finish up with the FormContext. Carry out any final workflow tasks, etc.
177 """
178 self.form2target()
179 self.patch_target()
181 ############################################################
182 # Functions which can be called directly, but may be overridden if desired
183 ############################################################
185 def validate(self):
186 self.pre_validate()
187 f = self.form
188 valid = False
189 if f is not None:
190 valid = f.validate()
192 # if this isn't a valid form, record the fields that have errors
193 # with the renderer for use later
194 if not valid:
195 error_fields = []
196 for field in self.form:
197 if field.errors:
198 error_fields.append(field.short_name)
200 return valid
202 @property
203 def errors(self):
204 f = self.form
205 if f is not None:
206 return f.errors
207 return False
209 def render_template(self, **kwargs):
210 return render_template(self.template, form_context=self, **kwargs)
212 def fieldset(self, fieldset_name=None):
213 return self._formulaic.fieldset(fieldset_name)
215 def fieldsets(self):
216 return self._formulaic.fieldsets()
218 def check_field_group_exists(self, field_group_name):
219 return self.renderer.check_field_group_exists(field_group_name)
221 @property
222 def ui_settings(self):
223 return self._formulaic.ui_settings
226class Renderer(object):
227 """
228 ~~FormContextRenderer:FormContext->FormHelper:FormContext~~
229 """
230 def __init__(self):
231 self.FIELD_GROUPS = {}
232 self.fh = FormHelperBS3()
233 self._error_fields = []
234 self._disabled_fields = []
235 self._disable_all_fields = False
236 self._highlight_completable_fields = False
238 def check_field_group_exists(self, field_group_name):
239 """ Return true if the field group exists in this form """
240 group_def = self.FIELD_GROUPS.get(field_group_name)
241 if group_def is None:
242 return False
243 else:
244 return True
246 def render_field_group(self, form_context, field_group_name=None, group_cfg=None):
247 if field_group_name is None:
248 return self._render_all(form_context)
250 # get the group definition
251 group_def = self.FIELD_GROUPS.get(field_group_name)
252 if group_def is None:
253 return ""
255 # build the frag
256 frag = ""
257 for entry in group_def:
258 field_name = list(entry.keys())[0]
259 config = entry.get(field_name)
260 config = deepcopy(config)
262 config = self._rewrite_extra_fields(form_context, config)
263 field = form_context.form[field_name]
265 if field_name in self.disabled_fields or self._disable_all_fields is True:
266 config["disabled"] = "disabled"
268 if self._highlight_completable_fields is True:
269 valid = field.validate(form_context.form)
270 config["complete_me"] = not valid
272 if group_cfg is not None:
273 config.update(group_cfg)
275 frag += self.fh.render_field(field, **config)
277 return frag
279 @property
280 def error_fields(self):
281 return self._error_fields
283 def set_error_fields(self, fields):
284 self._error_fields = fields
286 @property
287 def disabled_fields(self):
288 return self._disabled_fields
290 def set_disabled_fields(self, fields):
291 self._disabled_fields = fields
293 def disable_all_fields(self, disable):
294 self._disable_all_fields = disable
296 def _rewrite_extra_fields(self, form_context, config):
297 if "extra_input_fields" in config:
298 config = deepcopy(config)
299 for opt, field_ref in config.get("extra_input_fields").items():
300 extra_field = form_context.form[field_ref]
301 config["extra_input_fields"][opt] = extra_field
302 return config
304 def _render_all(self, form_context):
305 frag = ""
306 for field in form_context.form:
307 frag += self.fh.render_field(form_context, field.short_name)
308 return frag
310 def find_field(self, field, field_group):
311 for index, item in enumerate(self.FIELD_GROUPS[field_group]):
312 if field in item:
313 return index
315 def insert_field_after(self, field_to_insert, after_this_field, field_group):
316 self.FIELD_GROUPS[field_group].insert(
317 self.find_field(after_this_field, field_group) + 1,
318 field_to_insert
319 )
322class FormHelperBS3(object):
323 """
324 ~~FormHelper:FormContext->Bootstrap3:Technology~~
325 ~~->WTForms:Library~~
326 """
327 def render_field(self, field, **kwargs):
328 # begin the frag
329 frag = ""
331 # deal with the first error if it is relevant
332 first_error = kwargs.pop("first_error", False)
333 if first_error:
334 frag += '<a name="first_problem"></a>'
336 # call the correct render function on the field type
337 if field.type == "FormField":
338 frag += self._form_field(field, **kwargs)
339 elif field.type == "FieldList":
340 frag += self._field_list(field, **kwargs)
341 else:
342 frag += self._wrap_control_group(field, self._render_field(field, **kwargs), **kwargs)
344 return frag
346 def _wrap_control_group(self, field, contents, **kwargs):
347 hidden = kwargs.pop("hidden", False)
348 container_class = kwargs.pop("container_class", None)
349 disabled = kwargs.pop("disabled", False)
350 render_subfields_horizontal = kwargs.pop("render_subfields_horizontal", False)
351 complete_me = kwargs.get("complete_me", False)
353 frag = '<div class="form-group'
354 if field.errors:
355 frag += " error"
356 if render_subfields_horizontal:
357 frag += " row"
358 if container_class is not None:
359 frag += " " + container_class
360 if complete_me:
361 frag += " complete-me"
362 frag += '" id="'
363 frag += field.short_name + '-container"'
364 if hidden:
365 frag += ' style="display:none;"'
366 frag += ">"
367 if contents is not None:
368 frag += contents
369 frag += "</div>"
371 return frag
373 def _form_field(self, field, **kwargs):
374 # get the useful kwargs
375 render_subfields_horizontal = kwargs.pop("render_subfields_horizontal", False)
377 frag = ""
378 # for each subfield, do the render
379 for subfield in field:
380 if render_subfields_horizontal and not (subfield.type == 'CSRFTokenField' and not subfield.value):
381 subfield_width = "3"
382 remove = []
383 for kwarg, val in kwargs.items():
384 if kwarg == 'subfield_display-' + subfield.short_name:
385 subfield_width = val
386 remove.append(kwarg)
387 for rm in remove:
388 del kwargs[rm]
389 frag += '<div class="col-md-' + subfield_width + ' nested-field-container">'
390 frag += self._render_field(subfield, maximise_width=True, **kwargs)
391 frag += "</div>"
392 else:
393 frag += self._render_field(subfield, **kwargs)
395 return self._wrap_control_group(field, frag, **kwargs)
397 def _field_list(self, field, **kwargs):
398 # for each subfield, do the render
399 frag = ""
400 for subfield in field:
401 if subfield.type == "FormField":
402 frag += self.render_field(subfield, **kwargs)
403 else:
404 frag = self._wrap_control_group(field, self._render_field(field, **kwargs), **kwargs)
405 return frag
407 def _render_field(self, field, **kwargs):
408 # interesting arguments from keywords
409 extra_input_fields = kwargs.get("extra_input_fields")
410 q_num = kwargs.pop("q_num", None)
411 maximise_width = kwargs.pop("maximise_width", False)
412 clazz = kwargs.get("class", "")
413 label_width = kwargs.get("label_width", 3)
414 field_width = 12 - label_width
415 field_width = str(kwargs.get("field_width", field_width))
416 if label_width > 0:
417 label_width = str(label_width)
419 if field.type == 'CSRFTokenField' and not field.value:
420 return ""
422 frag = ""
424 # If this is the kind of field that requires a label, give it one
425 if field.type not in ['SubmitField', 'HiddenField', 'CSRFTokenField']:
426 if q_num is not None:
427 frag += '<a class="animated" name="' + q_num + '"></a>'
428 if label_width != 0:
429 frag += '<label class="control-label col-md-' + label_width + '" for="' + field.short_name + '">'
430 if q_num is not None:
431 frag += '<a class="animated orange" href="#' + field.short_name + '-container" title="Link to this question" tabindex="-1">' + q_num + ')</a> '
432 frag += field.label.text
433 if field.flags.required or field.flags.display_required_star:
434 frag += ' <span class="red">*</span>'
435 frag += "</label>"
437 # determine if this is a checkbox
438 is_checkbox = False
439 if (field.type == "SelectMultipleField"
440 and field.option_widget.__class__.__name__ == 'CheckboxInput'
441 and field.widget.__class__.__name__ == 'ListWidget'):
442 is_checkbox = True
444 extra_class = ""
445 if is_checkbox:
446 extra_class += " checkboxes"
448 frag += '<div class="col-md-' + field_width + ' ' + extra_class + '">'
449 if field.type == "RadioField":
450 for subfield in field:
451 frag += self._render_radio(subfield, **kwargs)
452 elif is_checkbox:
453 frag += '<ul id="' + field.short_name + '">'
454 for subfield in field:
455 frag += self._render_checkbox(subfield, **kwargs)
456 frag += "</ul>"
457 else:
458 if maximise_width:
459 clazz += " col-xs-12"
460 kwargs["class"] = clazz
461 render_args = {}
462 # filter anything that shouldn't go in as a field attribute
463 for k, v in kwargs.items():
464 if k in ["class", "style", "disabled"] or k.startswith("data-"):
465 render_args[k] = v
466 frag += field(**render_args) # FIXME: this is probably going to do some weird stuff
468 if field.errors:
469 frag += '<div class="alert alert--danger"><ul>'
470 for error in field.errors:
471 frag += '<li>' + error + '</li>'
472 frag += "</ul></div>"
474 if field.description:
475 frag += '<p class="help-block">' + field.description + '</p>'
477 frag += "</div>"
478 return frag
480 def _render_radio(self, field, **kwargs):
481 extra_input_fields = kwargs.pop("extra_input_fields", {})
482 label_width = "12"
484 frag = '<label class="radio control-label col-md-' + label_width + '" for="' + field.short_name + '">'
485 frag += field(**kwargs)
486 frag += '<span class="label-text">' + field.label.text + '</span>'
488 if field.label.text in list(extra_input_fields.keys()):
489 frag += " " + extra_input_fields[field.label.text](**{"class" : "extra_input_field"})
491 frag += "</label>"
492 return frag
494 def _render_checkbox(self, field, **kwargs):
495 extra_input_fields = kwargs.pop("extra_input_fields", {})
497 frag = "<li>"
498 frag += field(**kwargs)
499 frag += '<label class="control-label" for="' + field.short_name + '">' + field.label.text + '</label>'
501 if field.label.text in list(extra_input_fields.keys()):
502 eif = extra_input_fields[field.label.text]
503 if not isinstance(eif, UnboundField):
504 frag += " " + extra_input_fields[field.label.text](**{"class" : "extra_input_field"})
506 frag += "</li>"
507 return frag
510#########################################
511# Form definition
512# ~~Article:Form~~
514ISSN_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).'
515EMAIL_CONFIRM_ERROR = 'Please double check the email addresses - they do not match.'
516DATE_ERROR = "Date must be supplied in the form YYYY-MM-DD"
517DOI_ERROR = 'Invalid DOI. A DOI can optionally start with a prefix (such as "doi:"), followed by "10." and the remainder of the identifier'
518ORCID_ERROR = "Invalid ORCID iD. Please enter your ORCID iD as a full URL of the form https://orcid.org/0000-0000-0000-0000"
519IDENTICAL_ISSNS_ERROR = "The Print and Online ISSNs supplied are identical. If you supply 2 ISSNs they must be different."
521start_year = app.config.get("METADATA_START_YEAR", datetime.now().year - 15)
522YEAR_CHOICES = [(str(y), str(y)) for y in range(datetime.now().year + 1, start_year - 1, -1)]
523MONTH_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")]
524INITIAL_AUTHOR_FIELDS = 3
527def choices_for_article_issns(user, article_id=None):
528 if "admin" in user.role and article_id is not None:
529 # ~~->Article:Model~~
530 a = models.Article.pull(article_id)
531 # ~~->Journal:Model~~
532 issns = models.Journal.issns_by_owner(a.get_owner())
533 else:
534 issns = models.Journal.issns_by_owner(user.id)
535 ic = [("", "Select an ISSN")] + [(i, i) for i in issns]
536 return ic
539class AuthorForm(Form):
540 """
541 ~~->$ Author:Form~~
542 """
543 name = StringField("Name", [validators.Optional(),NoScriptTag()])
544 affiliation = StringField("Affiliation", [validators.Optional(), NoScriptTag()])
545 orcid_id = StringField("ORCID iD", [validators.Optional(), validators.Regexp(regex=regex.ORCID_COMPILED, message=ORCID_ERROR)])
548class ArticleForm(Form):
549 title = StringField("Article title <em>(required)</em>", [validators.DataRequired(), NoScriptTag()])
550 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)")
551 authors = FieldList(FormField(AuthorForm), min_entries=1) # We have to do the validation for this at a higher level
552 abstract = TextAreaField("Abstract", [validators.Optional(), NoScriptTag()])
553 keywords = TagListField("Keywords", [validators.Optional(), NoScriptTag()], description="Use a , to separate keywords") # enhanced with select2
554 fulltext = StringField("Full-text URL", [OptionalIf("doi", "You must provide the Full-Text URL or the DOI"), validators.URL()])
555 publication_year = DOAJSelectField("Year", [validators.Optional()], choices=YEAR_CHOICES, default=str(datetime.now().year))
556 publication_month = DOAJSelectField("Month", [validators.Optional()], choices=MONTH_CHOICES, default=str(datetime.now().month) )
557 pissn = DOAJSelectField("Print", [ThisOrThat("eissn", "Either this field or Online ISSN is required"), DifferentTo("eissn", message=IDENTICAL_ISSNS_ERROR)], choices=[]) # choices set at construction
558 eissn = DOAJSelectField("Online", [ThisOrThat("pissn", "Either this field or Print ISSN is required"), DifferentTo("pissn", message=IDENTICAL_ISSNS_ERROR)], choices=[]) # choices set at construction
560 volume = StringField("Volume", [validators.Optional(), NoScriptTag()])
561 number = StringField("Issue", [validators.Optional(), NoScriptTag()])
562 start = StringField("Start", [validators.Optional(), NoScriptTag()])
563 end = StringField("End", [validators.Optional(), NoScriptTag()])
565 def __init__(self, *args, **kwargs):
566 super(ArticleForm, self).__init__(*args, **kwargs)
567 try:
568 self.pissn.choices = choices_for_article_issns(current_user)
569 self.eissn.choices = choices_for_article_issns(current_user)
570 except:
571 # not logged in, and current_user is broken
572 # probably you are loading the class from the command line
573 pass
577#########################################
578# Formcontexts and factory
580class ArticleFormFactory(object):
581 """
582 ~~ArticleForm:Factory->AdminArticleMetadata:FormContext~~
583 ~~->PublisherArticleMetadata:FormContext~~
584 """
585 @classmethod
586 def get_from_context(cls, role, source=None, form_data=None, user=None):
587 if role == "admin":
588 return AdminMetadataArticleForm(source=source, form_data=form_data, user=user)
589 if role == "publisher":
590 return PublisherMetadataForm(source=source, form_data=form_data, user=user)
593class MetadataForm(FormContext):
594 """
595 ~~ArticleMetadata:FormContext->Article:Form~~
596 ~~->ArticleForm:Crosswalk~~
597 ~~->Article:Service~~
598 """
600 def __init__(self, source, form_data, user):
601 self.user = user
602 self.author_error = False
603 super(MetadataForm, self).__init__(source=source, form_data=form_data)
605 def _set_choices(self):
606 try:
607 ic = choices_for_article_issns(user=self.user, article_id=self.source.id)
608 self.form.pissn.choices = ic
609 self.form.eissn.choices = ic
610 except Exception as e:
611 print (str(e))
612 # not logged in, and current_user is broken
613 # probably you are loading the class from the command line
614 pass
616 def modify_authors_if_required(self, request_data):
618 more_authors = request_data.get("more_authors")
619 remove_author = None
620 for v in list(request.values.keys()):
621 if v.startswith("remove_authors"):
622 remove_author = v.split("-")[1]
624 # if the user wants more authors, add an extra entry
625 if more_authors:
626 return self.render_template(more_authors=True)
628 # if the user wants to remove an author, do the various back-flips required
629 if remove_author is not None:
630 return self.render_template(remove_authors=remove_author)
632 def _check_for_author_errors(self, **kwargs):
634 if "more_authors" in kwargs and kwargs["more_authors"] == True:
635 self.form.authors.append_entry()
636 if "remove_authors" in kwargs:
637 keep = []
638 while len(self.form.authors.entries) > 0:
639 entry = self.form.authors.pop_entry()
640 if entry.short_name == "authors-" + kwargs["remove_author"]:
641 break
642 else:
643 keep.append(entry)
644 while len(keep) > 0:
645 self.form.authors.append_entry(keep.pop().data)
647 def _validate_authors(self):
648 counted = 0
649 for entry in self.form.authors.entries:
650 name = entry.data.get("name")
651 if name is not None and name != "":
652 counted += 1
653 return counted >= 1
655 def blank_form(self):
656 self.form = ArticleForm()
657 self._set_choices()
659 def source2form(self):
660 self.form = ArticleForm()
661 ArticleFormXWalk.obj2form(self.form, article=self.source)
662 self._set_choices()
664 def data2form(self):
665 self.form = ArticleForm(formdata=self.form_data)
666 self._set_choices()
668 def form2target(self):
669 self.target = ArticleFormXWalk.form2obj(form=self.form)
671 def validate(self):
672 if not self._validate_authors():
673 self.author_error = True
674 if not self.form.validate():
675 return False
676 return True
678 def finalise(self, duplicate_check = True):
679 self.form2target()
680 if not self.author_error:
681 article_service = DOAJ.articleService()
682 article_service.create_article(self.target, self.user, add_journal_info=True,
683 update_article_id=self.source.id if self.source is not None else None,
684 duplicate_check = duplicate_check)
685 article_url = url_for('doaj.article_page', identifier=self.target.id)
686 msg, how = Messages.ARTICLE_METADATA_SUBMITTED_FLASH
687 Messages.flash_with_url(msg.format(url=article_url), how)
688 else:
689 return
692class PublisherMetadataForm(MetadataForm):
693 """
694 ~~PublisherArticleMetadata:FormContext->ArticleMetadata:FormContext~~
695 """
696 def __init__(self, source, form_data, user):
697 super(PublisherMetadataForm, self).__init__(source=source, form_data=form_data, user=user)
699 def set_template(self):
700 self.template = "publisher/metadata.html"
702 def render_template(self, **kwargs):
703 self._check_for_author_errors(**kwargs)
704 if "validated" in kwargs and kwargs["validated"] == True:
705 self.blank_form()
706 return render_template(self.template, form=self.form, form_context=self, author_error=self.author_error)
709class AdminMetadataArticleForm(MetadataForm):
710 """
711 ~~AdminArticleMetadata:FormContext->ArticleMetadata:FormContext~~
712 """
713 def __init__(self, source, form_data, user):
714 super(AdminMetadataArticleForm, self).__init__(source=source, form_data=form_data, user=user)
716 def set_template(self):
717 self.template = "admin/article_metadata.html"
719 def render_template(self, **kwargs):
720 self._check_for_author_errors(**kwargs)
721 return render_template(self.template, form=self.form, form_context=self, author_error=self.author_error)