Coverage for portality / forms / validate.py: 78%
393 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
1import re
2from flask_login import current_user
3from wtforms import validators
4from wtforms.compat import string_types
5from typing import List
7from portality.core import app
8from portality.lib import dates
9from portality.lib.dates import FMT_DATE_STD
10from portality.models import Journal, EditorGroup, Account
12from datetime import datetime
13from portality import regex
14from portality.datasets import get_currency_code
15from portality.lib import isolang
18class MultiFieldValidator(object):
19 """ A validator that accesses the value of an additional field """
21 def __init__(self, other_field, *args, **kwargs):
22 self.other_field_name = other_field
23 super(MultiFieldValidator, self).__init__(*args, **kwargs)
25 @staticmethod
26 def get_other_field(field_name, form):
27 other_field = form._fields.get(field_name)
28 if not other_field:
29 if hasattr(form.meta, "parent_form"):
30 other_field = MultiFieldValidator.get_other_field(field_name, form.meta.parent_form)
31 else:
32 raise Exception('No field named "{0}" in form (or its parent containers)'.format(field_name))
33 return other_field
36class DataOptional(object):
37 """
38 Allows empty input and stops the validation chain from continuing.
40 If input is empty, also removes prior errors (such as processing errors)
41 from the field.
43 This is a near-clone of the WTForms standard Optional class, except that
44 it checks the .data parameter not the .raw_data parameter, which allows us
45 to check coerced fields correctly.
47 ~~DataOptional:FormValidator~~
49 :param strip_whitespace:
50 If True (the default) also stop the validation chain on input which
51 consists of only whitespace.
52 """
53 field_flags = ('optional', )
55 def __init__(self, strip_whitespace=True):
56 if strip_whitespace:
57 self.string_check = lambda s: s.strip()
58 else:
59 self.string_check = lambda s: s
61 def __call__(self, form, field):
62 if not field.data or isinstance(field.data, string_types) and not self.string_check(field.data):
63 field.errors[:] = []
64 raise validators.StopValidation()
67class OptionalIf(DataOptional, MultiFieldValidator):
68 # A validator which makes a field optional if another field is set
69 # and has a truthy value.
70 # ~~OptionalIf:FormValidator~~
72 def __init__(self, other_field_name, message=None, optvals=None, *args, **kwargs):
73 self.other_field_name = other_field_name
74 if not message:
75 message = "This field is required in the current circumstances"
76 self.message = message
77 self.optvals = optvals if optvals is not None else []
78 super(OptionalIf, self).__init__(*args, **kwargs)
80 def __call__(self, form, field):
81 other_field = self.get_other_field(self.other_field_name, form)
83 # if no values (for other_field) which make this field optional
84 # are specified...
85 if not self.optvals:
86 # ... just make this field optional if the other is truthy
87 if bool(other_field.data):
88 super(OptionalIf, self).__call__(form, field)
89 else:
90 # otherwise it is required
91 dr = validators.DataRequired(self.message)
92 dr(form, field)
93 else:
94 # if such values are specified, check for them
95 no_optval_matched = True
96 for v in self.optvals:
97 if isinstance(other_field.data, list):
98 if v in other_field.data and len(other_field.data) == 1:
99 # must be the only option submitted - OK for
100 # radios and for checkboxes where a single
101 # checkbox, but no more, is required to make the
102 # field optional
103 no_optval_matched = False
104 self.__make_optional(form, field)
105 break
106 if other_field.data == v:
107 no_optval_matched = False
108 self.__make_optional(form, field)
109 break
111 if no_optval_matched:
112 if not field.data:
113 raise validators.StopValidation('This field is required')
115 def __make_optional(self, form, field):
116 super(OptionalIf, self).__call__(form, field)
117 raise validators.StopValidation()
120class HTTPURL(validators.Regexp):
121 """
122 Simple regexp based url validation. Much like the email validator, you
123 probably want to validate the url later by other means if the url must
124 resolve.
126 ~~HTTPURL:FormValidator~~
128 :param require_tld:
129 If true, then the domain-name portion of the URL must contain a .tld
130 suffix. Set this to false if you want to allow domains like
131 `localhost`.
132 :param message:
133 Error message to raise in case of a validation error.
134 """
135 def __init__(self, message=None):
136 super(HTTPURL, self).__init__(regex.HTTP_URL, re.IGNORECASE, message)
138 def __call__(self, form, field, message=None):
139 message = self.message
140 if message is None:
141 message = field.gettext('Invalid URL.')
143 if field.data:
144 super(HTTPURL, self).__call__(form, field, message)
147class MaxLen(object):
148 """
149 Maximum length validator. Works on anything which supports len(thing).
151 Use {max_len} in your custom message to insert the maximum length you've
152 specified into the message.
154 ~~MaxLen:FormValidator~~
155 """
157 def __init__(self, max_len, message='Maximum {max_len}.', *args, **kwargs):
158 self.max_len = max_len
159 self.message = message
161 def __call__(self, form, field):
162 if len(field.data) > self.max_len:
163 raise validators.ValidationError(self.message.format(max_len=self.max_len))
166class RequiredIfRole(validators.DataRequired):
167 """
168 Makes a field required, if the user has the specified role
170 ~~RequiredIfRole:FormValidator~~
171 """
173 def __init__(self, role, *args, **kwargs):
174 self.role = role
175 super(RequiredIfRole, self).__init__(*args, **kwargs)
177 def __call__(self, form, field):
178 if current_user.has_role(self.role):
179 super(RequiredIfRole, self).__call__(form, field)
182class RegexpOnTagList(object):
183 """
184 Validates the field against a user provided regexp.
186 ~~RegexpOnTagList:FormValidator~~
188 :param regex:
189 The regular expression string to use. Can also be a compiled regular
190 expression pattern.
191 :param flags:
192 The regexp flags to use, for example re.IGNORECASE. Ignored if
193 `regex` is not a string.
194 :param message:
195 Error message to raise in case of a validation error.
196 """
197 def __init__(self, regex, flags=0, message=None):
198 if isinstance(regex, string_types):
199 regex = re.compile(regex, flags)
200 self.regex = regex
201 self.message = message
203 def __call__(self, form, field, message=None):
204 for entry in field.data:
205 match = self.regex.match(entry or '')
206 if not match:
207 if message is None:
208 if self.message is None:
209 message = field.gettext('Invalid input.')
210 else:
211 message = self.message
213 raise validators.ValidationError(message)
216class ThisOrThat(MultiFieldValidator):
217 """
218 ~~ThisOrThat:FormValidator~~
219 """
220 def __init__(self, other_field_name, message=None, *args, **kwargs):
221 self.message = message
222 super(ThisOrThat, self).__init__(other_field_name, *args, **kwargs)
224 def __call__(self, form, field):
225 other_field = self.get_other_field(self.other_field_name, form)
226 this = bool(field.data)
227 that = bool(other_field.data)
228 if not this and not that:
229 if not self.message:
230 self.message = "Either this field or " + other_field.label.text + " is required"
231 raise validators.ValidationError(self.message)
234class ReservedUsernames(object):
235 """
236 A username validator. When applied to fields containing usernames it prevents
237 their use if they are reserved.
239 ~~ReservedUsernames:FormValidator~~
240 """
241 def __init__(self, message='The "{reserved}" user is reserved. Please choose a different username.', *args, **kwargs):
242 self.message = message
244 def __call__(self, form, field):
245 return self.__validate(field.data)
247 def __validate(self, username):
248 if not isinstance(username, str):
249 raise validators.ValidationError('Invalid username (not a string) passed to ReservedUsernames validator.')
251 if username.lower() in [u.lower() for u in app.config.get('RESERVED_USERNAMES', [])]:
252 raise validators.ValidationError(self.message.format(reserved=username))
254 @classmethod
255 def validate(cls, username):
256 return cls().__validate(username)
259class OwnerExists(object):
260 """
261 A username validator. When applied to fields containing usernames it ensures that the username
262 exists
264 ~~OwnerExists:FormValidator~~
265 """
266 def __init__(self, message='The "{reserved}" user does not exist. Please choose an existing username, or create a new account first.', *args, **kwargs):
267 self.message = message
269 def __call__(self, form, field):
270 return self.__validate(field.data)
272 def __validate(self, username):
273 if not isinstance(username, str):
274 raise validators.ValidationError('Invalid username (not a string) passed to OwnerExists validator.')
276 if username == "":
277 return
279 acc = Account.pull(username)
280 if not acc:
281 raise validators.ValidationError(self.message.format(reserved=username))
283 @classmethod
284 def validate(cls, username):
285 return cls().__validate(username)
288class ISSNInPublicDOAJ(object):
289 """
290 ~~ISSNInPublicDOAJ:FormValidator~~
291 """
292 def __init__(self, message=None):
293 if not message:
294 message = "This ISSN already appears in the public DOAJ database"
295 self.message = message
297 def __call__(self, form, field):
298 if field.data is not None:
299 existing = Journal.find_by_issn(field.data, in_doaj=True, max=1)
300 if len(existing) > 0:
301 raise validators.ValidationError(self.message)
304class JournalURLInPublicDOAJ(object):
305 """
306 ~~JournalURLInPublicDOAJ:FormValidator~~
307 """
308 def __init__(self, message=None):
309 if not message:
310 message = "This Journal URL already appears in the public DOAJ database"
311 self.message = message
313 def __call__(self, form, field):
314 if field.data is not None:
315 existing = Journal.find_by_journal_url(field.data, in_doaj=True, max=1)
316 if len(existing) > 0:
317 raise validators.ValidationError(self.message)
320class StopWords(object):
321 """
322 ~~StopWords:FormValidator~~
323 """
324 def __init__(self, stopwords, message=None):
325 self.stopwords = stopwords
326 if not message:
327 message = "You may not enter '{stop_word}' in this field"
328 self.message = message
330 def __call__(self, form, field):
331 for v in field.data:
332 if v.strip() in self.stopwords:
333 raise validators.StopValidation(self.message.format(stop_word=v))
336class DifferentTo(MultiFieldValidator):
337 """
338 ~~DifferentTo:FormValidator~~
339 """
340 def __init__(self, other_field_name, ignore_empty=True, message=None):
341 super(DifferentTo, self).__init__(other_field_name)
342 self.ignore_empty = ignore_empty
343 if not message:
344 message = "This field must contain a different value to the field '{x}'".format(x=self.other_field_name)
345 self.message = message
347 def __call__(self, form, field):
348 other_field = self.get_other_field(self.other_field_name, form)
350 if other_field.data == field.data:
351 if self.ignore_empty and (not other_field.data or not field.data):
352 return
353 raise validators.ValidationError(self.message)
356class RequiredIfOtherValue(MultiFieldValidator):
357 """
358 Makes a field required, if the user has selected a specific value in another field
360 ~~RequiredIfOtherValue:FormValidator~~
361 """
363 def __init__(self, other_field_name, other_value, message=None, *args, **kwargs):
364 self.other_value = other_value
365 self.message = message if message is not None else "This field is required when {x} is {y}".format(x=other_field_name, y=other_value)
366 super(RequiredIfOtherValue, self).__init__(other_field_name, *args, **kwargs)
368 def __call__(self, form, field):
369 # attempt to get the other field - if it doesn't exist, just take this as valid
370 try:
371 other_field = self.get_other_field(self.other_field_name, form)
372 except:
373 return
375 if isinstance(self.other_value, list):
376 self._match_list(form, field, other_field)
377 else:
378 self._match_single(form, field, other_field)
380 def _match_single(self, form, field, other_field):
381 if isinstance(other_field.data, list):
382 match = self.other_value in other_field.data
383 else:
384 match = other_field.data == self.other_value
385 if match:
386 dr = validators.DataRequired(self.message)
387 dr(form, field)
388 else:
389 if not field.data or (isinstance(field.data, str) and not field.data.strip()):
390 raise validators.StopValidation()
392 def _match_list(self, form, field, other_field):
393 if isinstance(other_field.data, list):
394 match = len(list(set(self.other_value) & set(other_field.data))) > 0
395 else:
396 match = other_field.data in self.other_value
397 if match:
398 dr = validators.DataRequired(self.message)
399 dr(form, field)
400 else:
401 if not field.data or len(field.data) == 0:
402 raise validators.StopValidation()
405class OnlyIf(MultiFieldValidator):
406 """
407 Field only validates if other fields have specific values (or are truthy)
408 ~~OnlyIf:FormValidator~~
409 """
410 def __init__(self, other_fields: List[dict], ignore_empty=True, message=None, *args, **kwargs):
411 self.other_fields = other_fields
412 self.ignore_empty = ignore_empty
413 if not message:
414 fieldnames = [n['field'] for n in self.other_fields]
415 message = "This field can only be selected with valid values in other fields: '{x}'".format(x=fieldnames)
416 self.message = message
417 super(OnlyIf, self).__init__(None, *args, **kwargs)
419 def __call__(self, form, field):
420 if field.data is None or field.data is False or isinstance(field.data, str) and not field.data.strip():
421 return
423 others = self.get_other_fields(form)
425 for o_f in self.other_fields:
426 other = others[o_f["field"]]
427 if self.ignore_empty and (not other.data or not field.data):
428 continue
429 if o_f.get("or") is not None:
430 # succeed if the value is in the list
431 if other.data in o_f["or"]:
432 continue
433 if o_f.get('not') is not None:
434 # Succeed if the value doesn't equal the one specified
435 if other.data != o_f['not']:
436 continue
437 if o_f.get('value') is not None:
438 if other.data == o_f['value']:
439 # Succeed if the other field has the specified value
440 continue
441 if o_f.get('value') is None:
442 # No target value supplied - succeed if the other field is truthy
443 if other.data:
444 continue
445 raise validators.ValidationError(self.message)
447 def get_other_fields(self, form):
448 # return the actual fields matching the names in self.other_fields
449 others = {f["field"]: self.get_other_field(f["field"], form) for f in self.other_fields}
450 return others
453class NotIf(OnlyIf):
454 """
455 Field only validates if other fields DO NOT have specific values (or are truthy)
456 ~~NotIf:FormValidator~~
457 """
459 def __call__(self, form, field):
460 others = self.get_other_fields(form)
462 for o_f in self.other_fields:
463 other = others[o_f["field"]]
464 if self.ignore_empty and (not other.data or not field.data):
465 continue
466 if o_f.get('value') is None:
467 # Fail if the other field is truthy
468 if other.data:
469 validators.ValidationError(self.message)
470 elif other.data == o_f['value']:
471 # Fail if the other field has the specified value
472 validators.ValidationError(self.message)
475class OnlyIfExists(OnlyIf):
476 """
477 Field only validates if other fields DOES have ANY values (or are truthy)
478 ~~NotIf:FormValidator~~
479 """
481 def __call__(self, form, field):
482 others = self.get_other_fields(form)
484 for o_f in self.other_fields:
485 other = others[o_f["field"]]
486 if not other.data or not field.data:
487 validators.ValidationError(self.message)
489class NoScriptTag(object):
490 """
491 Checks that a field does not contain a script html tag
492 ~~NoScriptTag:FormValidator~~
493 """
495 def __init__(self, message=None):
496 if not message:
497 message = "Value cannot contain script tag"
498 self.message = message
500 def __call__(self, form, field):
501 if field.data is not None and "<script>" in field.data:
502 raise validators.ValidationError(self.message)
505class GroupMember(MultiFieldValidator):
506 """
507 Validation passes when a field's value is a member of the specified group
508 ~~GroupMember:FormValidator~~
509 """
510 # Fixme: should / can this be generalised to any group vs specific to editor groups?
512 def __init__(self, group_field_name, *args, **kwargs):
513 super(GroupMember, self).__init__(group_field_name, *args, **kwargs)
515 def __call__(self, form, field):
516 group_field = self.get_other_field(self.other_field_name, form)
518 """ Validate the choice of editor, which could be out of sync with the group in exceptional circumstances """
519 # lifted from from formcontext
520 editor = field.data
521 if editor is not None and editor != "":
522 editor_group_name = group_field.data
523 if editor_group_name is not None and editor_group_name != "":
524 eg = EditorGroup.pull_by_key("name", editor_group_name)
525 if eg is not None:
526 if eg.is_member(editor):
527 return # success - an editor group was found and our editor was in it
528 raise validators.ValidationError("Editor '{0}' not found in editor group '{1}'".format(editor, editor_group_name))
529 else:
530 raise validators.ValidationError("An editor has been assigned without an editor group")
533class RequiredValue(object):
534 """
535 Checks that a field contains a required value
536 ~~RequiredValue:FormValidator~~
537 """
538 def __init__(self, value, message=None):
539 self.value = value
540 if not message:
541 message = "You must enter {value}"
542 self.message = message
544 def __call__(self, form, field):
545 if field.data != self.value:
546 raise validators.ValidationError(self.message.format(value=self.value))
549class BigEndDate(object):
550 """
551 ~~BigEndDate:FormValidator~~
552 """
553 def __init__(self, value, message=None):
554 self.value = value
555 self.message = message or "Date must be a big-end formatted date (e.g. 2020-11-23)"
557 def __call__(self, form, field):
558 if not field.data:
559 return
560 try:
561 datetime.strptime(field.data, FMT_DATE_STD)
562 except Exception:
563 raise validators.ValidationError(self.message)
566class DateInThePast(object):
567 def __init__(self, message=None):
568 self.message = message or "Date must be in the past"
570 def __call__(self, form, field):
571 if not field.data:
572 return
573 try:
574 d = datetime.strptime(field.data, FMT_DATE_STD)
575 except Exception:
576 raise validators.ValidationError(self.message)
578 if d > dates.now():
579 raise validators.ValidationError(self.message)
581class Year(object):
582 def __init__(self, value, message=None):
583 self.value = value
584 self.message = message or "Date must be a year in 4 digit format (eg. 1987)"
586 def __call__(self, form, field):
587 if not field.data:
588 return
589 return app.config.get('MINIMAL_OA_START_DATE', 1900) <= field.data <= dates.now().year
592class CustomRequired(object):
593 """
594 ~~CustomRequired:FormValidator~~
595 """
596 field_flags = ('required', )
598 def __init__(self, message=None):
599 self.message = message
601 def __call__(self, form, field):
602 if field.data is None or isinstance(field.data, str) and not field.data.strip() or isinstance(field.data, list) and len(field.data) == 0:
603 if self.message is None:
604 message = field.gettext('This field is required.')
605 else:
606 message = self.message
608 field.errors[:] = []
609 raise validators.StopValidation(message)
612class EmailAvailable(object):
613 """
614 ~~EmailAvailable:FormValidator~~
615 """
616 def __init__(self, message=None):
617 if not message:
618 message = "Email address is already in use"
619 self.message = message
621 def __call__(self, form, field):
622 if field.data is not None:
623 existing = Account.email_in_use(field.data)
624 if existing is True:
625 raise validators.ValidationError(self.message)
628class IdAvailable(object):
629 """
630 ~~IdAvailable:FormValidator~~
631 """
632 def __init__(self, message=None):
633 if not message:
634 message = "Account ID already in use"
635 self.message = message
637 def __call__(self, form, field):
638 if field.data is not None:
639 existing = Account.pull(field.data)
640 if existing is not None:
641 raise validators.ValidationError(self.message)
644class IgnoreUnchanged(object):
645 """
646 Disables validation when the input is unchanged and stops the validation chain from continuing.
648 If input is the same as before, also removes prior errors (such as processing errors)
649 from the field.
651 ~~IgnoreUnchanged:FormValidator~~
653 """
654 field_flags = ('optional', )
656 def __call__(self, form, field):
657 if field.data and field.object_data and field.data == field.object_data:
658 field.errors[:] = []
659 raise validators.StopValidation()
662class CurrentISOCurrency(object):
663 """
664 ~~EmailAvailable:FormValidator~~
665 """
666 def __init__(self, message=None):
667 if not message:
668 message = "Currency is not in the currently supported ISO list"
669 self.message = message
671 def __call__(self, form, field):
672 if field.data is not None and field.data != '':
673 check = get_currency_code(field.data, fail_if_not_found=True)
674 if check is None:
675 raise validators.ValidationError(self.message)
678class CurrentISOLanguage(object):
679 def __init__(self, message=None):
680 if not message:
681 message = "Language is not in the currently supported ISO list"
682 self.message = message
684 def __call__(self, form, field):
685 if field.data is not None and field.data != '':
686 check = isolang.find(field.data)
687 if check is None:
688 raise validators.ValidationError(self.message)