Coverage for portality/forms/validate.py: 77%
350 statements
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-22 15:59 +0100
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-22 15:59 +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.models import Journal, EditorGroup, Account
10from datetime import datetime
11from portality import regex
14class MultiFieldValidator(object):
15 """ A validator that accesses the value of an additional field """
17 def __init__(self, other_field, *args, **kwargs):
18 self.other_field_name = other_field
19 super(MultiFieldValidator, self).__init__(*args, **kwargs)
21 @staticmethod
22 def get_other_field(field_name, form):
23 other_field = form._fields.get(field_name)
24 if not other_field:
25 if hasattr(form.meta, "parent_form"):
26 other_field = MultiFieldValidator.get_other_field(field_name, form.meta.parent_form)
27 else:
28 raise Exception('No field named "{0}" in form (or its parent containers)'.format(field_name))
29 return other_field
32class DataOptional(object):
33 """
34 Allows empty input and stops the validation chain from continuing.
36 If input is empty, also removes prior errors (such as processing errors)
37 from the field.
39 This is a near-clone of the WTForms standard Optional class, except that
40 it checks the .data parameter not the .raw_data parameter, which allows us
41 to check coerced fields correctly.
43 ~~DataOptional:FormValidator~~
45 :param strip_whitespace:
46 If True (the default) also stop the validation chain on input which
47 consists of only whitespace.
48 """
49 field_flags = ('optional', )
51 def __init__(self, strip_whitespace=True):
52 if strip_whitespace:
53 self.string_check = lambda s: s.strip()
54 else:
55 self.string_check = lambda s: s
57 def __call__(self, form, field):
58 if not field.data or isinstance(field.data, string_types) and not self.string_check(field.data):
59 field.errors[:] = []
60 raise validators.StopValidation()
63class OptionalIf(DataOptional, MultiFieldValidator):
64 # A validator which makes a field optional if another field is set
65 # and has a truthy value.
66 # ~~OptionalIf:FormValidator~~
68 def __init__(self, other_field_name, message=None, optvals=None, *args, **kwargs):
69 self.other_field_name = other_field_name
70 if not message:
71 message = "This field is required in the current circumstances"
72 self.message = message
73 self.optvals = optvals if optvals is not None else []
74 super(OptionalIf, self).__init__(*args, **kwargs)
76 def __call__(self, form, field):
77 other_field = self.get_other_field(self.other_field_name, form)
79 # if no values (for other_field) which make this field optional
80 # are specified...
81 if not self.optvals:
82 # ... just make this field optional if the other is truthy
83 if bool(other_field.data):
84 super(OptionalIf, self).__call__(form, field)
85 else:
86 # otherwise it is required
87 dr = validators.DataRequired(self.message)
88 dr(form, field)
89 else:
90 # if such values are specified, check for them
91 no_optval_matched = True
92 for v in self.optvals:
93 if isinstance(other_field.data, list):
94 if v in other_field.data and len(other_field.data) == 1:
95 # must be the only option submitted - OK for
96 # radios and for checkboxes where a single
97 # checkbox, but no more, is required to make the
98 # field optional
99 no_optval_matched = False
100 self.__make_optional(form, field)
101 break
102 if other_field.data == v:
103 no_optval_matched = False
104 self.__make_optional(form, field)
105 break
107 if no_optval_matched:
108 if not field.data:
109 raise validators.StopValidation('This field is required')
111 def __make_optional(self, form, field):
112 super(OptionalIf, self).__call__(form, field)
113 raise validators.StopValidation()
116class HTTPURL(validators.Regexp):
117 """
118 Simple regexp based url validation. Much like the email validator, you
119 probably want to validate the url later by other means if the url must
120 resolve.
122 ~~HTTPURL:FormValidator~~
124 :param require_tld:
125 If true, then the domain-name portion of the URL must contain a .tld
126 suffix. Set this to false if you want to allow domains like
127 `localhost`.
128 :param message:
129 Error message to raise in case of a validation error.
130 """
131 def __init__(self, message=None):
132 super(HTTPURL, self).__init__(regex.HTTP_URL, re.IGNORECASE, message)
134 def __call__(self, form, field, message=None):
135 message = self.message
136 if message is None:
137 message = field.gettext('Invalid URL.')
139 if field.data:
140 super(HTTPURL, self).__call__(form, field, message)
143class MaxLen(object):
144 """
145 Maximum length validator. Works on anything which supports len(thing).
147 Use {max_len} in your custom message to insert the maximum length you've
148 specified into the message.
150 ~~MaxLen:FormValidator~~
151 """
153 def __init__(self, max_len, message='Maximum {max_len}.', *args, **kwargs):
154 self.max_len = max_len
155 self.message = message
157 def __call__(self, form, field):
158 if len(field.data) > self.max_len:
159 raise validators.ValidationError(self.message.format(max_len=self.max_len))
162class RequiredIfRole(validators.DataRequired):
163 """
164 Makes a field required, if the user has the specified role
166 ~~RequiredIfRole:FormValidator~~
167 """
169 def __init__(self, role, *args, **kwargs):
170 self.role = role
171 super(RequiredIfRole, self).__init__(*args, **kwargs)
173 def __call__(self, form, field):
174 if current_user.has_role(self.role):
175 super(RequiredIfRole, self).__call__(form, field)
178class RegexpOnTagList(object):
179 """
180 Validates the field against a user provided regexp.
182 ~~RegexpOnTagList:FormValidator~~
184 :param regex:
185 The regular expression string to use. Can also be a compiled regular
186 expression pattern.
187 :param flags:
188 The regexp flags to use, for example re.IGNORECASE. Ignored if
189 `regex` is not a string.
190 :param message:
191 Error message to raise in case of a validation error.
192 """
193 def __init__(self, regex, flags=0, message=None):
194 if isinstance(regex, string_types):
195 regex = re.compile(regex, flags)
196 self.regex = regex
197 self.message = message
199 def __call__(self, form, field, message=None):
200 for entry in field.data:
201 match = self.regex.match(entry or '')
202 if not match:
203 if message is None:
204 if self.message is None:
205 message = field.gettext('Invalid input.')
206 else:
207 message = self.message
209 raise validators.ValidationError(message)
212class ThisOrThat(MultiFieldValidator):
213 """
214 ~~ThisOrThat:FormValidator~~
215 """
216 def __init__(self, other_field_name, message=None, *args, **kwargs):
217 self.message = message
218 super(ThisOrThat, self).__init__(other_field_name, *args, **kwargs)
220 def __call__(self, form, field):
221 other_field = self.get_other_field(self.other_field_name, form)
222 this = bool(field.data)
223 that = bool(other_field.data)
224 if not this and not that:
225 if not self.message:
226 self.message = "Either this field or " + other_field.label.text + " is required"
227 raise validators.ValidationError(self.message)
230class ReservedUsernames(object):
231 """
232 A username validator. When applied to fields containing usernames it prevents
233 their use if they are reserved.
235 ~~ReservedUsernames:FormValidator~~
236 """
237 def __init__(self, message='The "{reserved}" user is reserved. Please choose a different username.', *args, **kwargs):
238 self.message = message
240 def __call__(self, form, field):
241 return self.__validate(field.data)
243 def __validate(self, username):
244 if not isinstance(username, str):
245 raise validators.ValidationError('Invalid username (not a string) passed to ReservedUsernames validator.')
247 if username.lower() in [u.lower() for u in app.config.get('RESERVED_USERNAMES', [])]:
248 raise validators.ValidationError(self.message.format(reserved=username))
250 @classmethod
251 def validate(cls, username):
252 return cls().__validate(username)
255class OwnerExists(object):
256 """
257 A username validator. When applied to fields containing usernames it ensures that the username
258 exists
260 ~~OwnerExists:FormValidator~~
261 """
262 def __init__(self, message='The "{reserved}" user does not exist. Please choose an existing username, or create a new account first.', *args, **kwargs):
263 self.message = message
265 def __call__(self, form, field):
266 return self.__validate(field.data)
268 def __validate(self, username):
269 if not isinstance(username, str):
270 raise validators.ValidationError('Invalid username (not a string) passed to OwnerExists validator.')
272 if username == "":
273 return
275 acc = Account.pull(username)
276 if not acc:
277 raise validators.ValidationError(self.message.format(reserved=username))
279 @classmethod
280 def validate(cls, username):
281 return cls().__validate(username)
284class ISSNInPublicDOAJ(object):
285 """
286 ~~ISSNInPublicDOAJ:FormValidator~~
287 """
288 def __init__(self, message=None):
289 if not message:
290 message = "This ISSN already appears in the public DOAJ database"
291 self.message = message
293 def __call__(self, form, field):
294 if field.data is not None:
295 existing = Journal.find_by_issn(field.data, in_doaj=True, max=1)
296 if len(existing) > 0:
297 raise validators.ValidationError(self.message)
300class JournalURLInPublicDOAJ(object):
301 """
302 ~~JournalURLInPublicDOAJ:FormValidator~~
303 """
304 def __init__(self, message=None):
305 if not message:
306 message = "This Journal URL already appears in the public DOAJ database"
307 self.message = message
309 def __call__(self, form, field):
310 if field.data is not None:
311 existing = Journal.find_by_journal_url(field.data, in_doaj=True, max=1)
312 if len(existing) > 0:
313 raise validators.ValidationError(self.message)
316class StopWords(object):
317 """
318 ~~StopWords:FormValidator~~
319 """
320 def __init__(self, stopwords, message=None):
321 self.stopwords = stopwords
322 if not message:
323 message = "You may not enter '{stop_word}' in this field"
324 self.message = message
326 def __call__(self, form, field):
327 for v in field.data:
328 if v.strip() in self.stopwords:
329 raise validators.StopValidation(self.message.format(stop_word=v))
332class DifferentTo(MultiFieldValidator):
333 """
334 ~~DifferentTo:FormValidator~~
335 """
336 def __init__(self, other_field_name, ignore_empty=True, message=None):
337 super(DifferentTo, self).__init__(other_field_name)
338 self.ignore_empty = ignore_empty
339 if not message:
340 message = "This field must contain a different value to the field '{x}'".format(x=self.other_field_name)
341 self.message = message
343 def __call__(self, form, field):
344 other_field = self.get_other_field(self.other_field_name, form)
346 if other_field.data == field.data:
347 if self.ignore_empty and (not other_field.data or not field.data):
348 return
349 raise validators.ValidationError(self.message)
352class RequiredIfOtherValue(MultiFieldValidator):
353 """
354 Makes a field required, if the user has selected a specific value in another field
356 ~~RequiredIfOtherValue:FormValidator~~
357 """
359 def __init__(self, other_field_name, other_value, message=None, *args, **kwargs):
360 self.other_value = other_value
361 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)
362 super(RequiredIfOtherValue, self).__init__(other_field_name, *args, **kwargs)
364 def __call__(self, form, field):
365 # attempt to get the other field - if it doesn't exist, just take this as valid
366 try:
367 other_field = self.get_other_field(self.other_field_name, form)
368 except:
369 return
371 if isinstance(self.other_value, list):
372 self._match_list(form, field, other_field)
373 else:
374 self._match_single(form, field, other_field)
376 def _match_single(self, form, field, other_field):
377 if isinstance(other_field.data, list):
378 match = self.other_value in other_field.data
379 else:
380 match = other_field.data == self.other_value
381 if match:
382 dr = validators.DataRequired(self.message)
383 dr(form, field)
384 else:
385 if not field.data or (isinstance(field.data, str) and not field.data.strip()):
386 raise validators.StopValidation()
388 def _match_list(self, form, field, other_field):
389 if isinstance(other_field.data, list):
390 match = len(list(set(self.other_value) & set(other_field.data))) > 0
391 else:
392 match = other_field.data in self.other_value
393 if match:
394 dr = validators.DataRequired(self.message)
395 dr(form, field)
396 else:
397 if not field.data or len(field.data) == 0:
398 raise validators.StopValidation()
401class OnlyIf(MultiFieldValidator):
402 """
403 Field only validates if other fields have specific values (or are truthy)
404 ~~OnlyIf:FormValidator~~
405 """
406 def __init__(self, other_fields: List[dict], ignore_empty=True, message=None, *args, **kwargs):
407 self.other_fields = other_fields
408 self.ignore_empty = ignore_empty
409 if not message:
410 fieldnames = [n['field'] for n in self.other_fields]
411 message = "This field can only be selected with valid values in other fields: '{x}'".format(x=fieldnames)
412 self.message = message
413 super(OnlyIf, self).__init__(None, *args, **kwargs)
415 def __call__(self, form, field):
416 if field.data is None or field.data is False or isinstance(field.data, str) and not field.data.strip():
417 return
419 others = self.get_other_fields(form)
421 for o_f in self.other_fields:
422 other = others[o_f["field"]]
423 if self.ignore_empty and (not other.data or not field.data):
424 continue
425 if o_f.get("or") is not None:
426 # succeed if the value is in the list
427 if other.data in o_f["or"]:
428 continue
429 if o_f.get('not') is not None:
430 # Succeed if the value doesn't equal the one specified
431 if other.data != o_f['not']:
432 continue
433 if o_f.get('value') is not None:
434 if other.data == o_f['value']:
435 # Succeed if the other field has the specified value
436 continue
437 if o_f.get('value') is None:
438 # No target value supplied - succeed if the other field is truthy
439 if other.data:
440 continue
441 raise validators.ValidationError(self.message)
443 def get_other_fields(self, form):
444 # return the actual fields matching the names in self.other_fields
445 others = {f["field"]: self.get_other_field(f["field"], form) for f in self.other_fields}
446 return others
449class NotIf(OnlyIf):
450 """
451 Field only validates if other fields DO NOT have specific values (or are truthy)
452 ~~NotIf:FormValidator~~
453 """
455 def __call__(self, form, field):
456 others = self.get_other_fields(form)
458 for o_f in self.other_fields:
459 other = others[o_f["field"]]
460 if self.ignore_empty and (not other.data or not field.data):
461 continue
462 if o_f.get('value') is None:
463 # Fail if the other field is truthy
464 if other.data:
465 validators.ValidationError(self.message)
466 elif other.data == o_f['value']:
467 # Fail if the other field has the specified value
468 validators.ValidationError(self.message)
471class NoScriptTag(object):
472 """
473 Checks that a field does not contain a script html tag
474 ~~NoScriptTag:FormValidator~~
475 """
477 def __init__(self, message=None):
478 if not message:
479 message = "Value cannot contain script tag"
480 self.message = message
482 def __call__(self, form, field):
483 if field.data is not None and "<script>" in field.data:
484 raise validators.ValidationError(self.message)
487class GroupMember(MultiFieldValidator):
488 """
489 Validation passes when a field's value is a member of the specified group
490 ~~GroupMember:FormValidator~~
491 """
492 # Fixme: should / can this be generalised to any group vs specific to editor groups?
494 def __init__(self, group_field_name, *args, **kwargs):
495 super(GroupMember, self).__init__(group_field_name, *args, **kwargs)
497 def __call__(self, form, field):
498 group_field = self.get_other_field(self.other_field_name, form)
500 """ Validate the choice of editor, which could be out of sync with the group in exceptional circumstances """
501 # lifted from from formcontext
502 editor = field.data
503 if editor is not None and editor != "":
504 editor_group_name = group_field.data
505 if editor_group_name is not None and editor_group_name != "":
506 eg = EditorGroup.pull_by_key("name", editor_group_name)
507 if eg is not None:
508 if eg.is_member(editor):
509 return # success - an editor group was found and our editor was in it
510 raise validators.ValidationError("Editor '{0}' not found in editor group '{1}'".format(editor, editor_group_name))
511 else:
512 raise validators.ValidationError("An editor has been assigned without an editor group")
515class RequiredValue(object):
516 """
517 Checks that a field contains a required value
518 ~~RequiredValue:FormValidator~~
519 """
520 def __init__(self, value, message=None):
521 self.value = value
522 if not message:
523 message = "You must enter {value}"
524 self.message = message
526 def __call__(self, form, field):
527 if field.data != self.value:
528 raise validators.ValidationError(self.message.format(value=self.value))
531class BigEndDate(object):
532 """
533 ~~BigEndDate:FormValidator~~
534 """
535 def __init__(self, value, message=None):
536 self.value = value
537 self.message = message or "Date must be a big-end formatted date (e.g. 2020-11-23)"
539 def __call__(self, form, field):
540 if not field.data:
541 return
542 try:
543 datetime.strptime(field.data, '%Y-%m-%d')
544 except Exception:
545 raise validators.ValidationError(self.message)
547class Year(object):
548 def __init__(self, value, message=None):
549 self.value = value
550 self.message = message or "Date must be a year in 4 digit format (eg. 1987)"
552 def __call__(self, form, field):
553 if not field.data:
554 return
555 return app.config.get('MINIMAL_OA_START_DATE', 1900) <= field.data <= datetime.now().year
558class CustomRequired(object):
559 """
560 ~~CustomRequired:FormValidator~~
561 """
562 field_flags = ('required', )
564 def __init__(self, message=None):
565 self.message = message
567 def __call__(self, form, field):
568 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:
569 if self.message is None:
570 message = field.gettext('This field is required.')
571 else:
572 message = self.message
574 field.errors[:] = []
575 raise validators.StopValidation(message)
578class EmailAvailable(object):
579 """
580 ~~EmailAvailable:FormValidator~~
581 """
582 def __init__(self, message=None):
583 if not message:
584 message = "Email address is already in use"
585 self.message = message
587 def __call__(self, form, field):
588 if field.data is not None:
589 existing = Account.email_in_use(field.data)
590 if existing is True:
591 raise validators.ValidationError(self.message)
594class IdAvailable(object):
595 """
596 ~~IdAvailable:FormValidator~~
597 """
598 def __init__(self, message=None):
599 if not message:
600 message = "Account ID already in use"
601 self.message = message
603 def __call__(self, form, field):
604 if field.data is not None:
605 existing = Account.pull(field.data)
606 if existing is not None:
607 raise validators.ValidationError(self.message)
610class IgnoreUnchanged(object):
611 """
612 Disables validation when the input is unchanged and stops the validation chain from continuing.
614 If input is the same as before, also removes prior errors (such as processing errors)
615 from the field.
617 ~~IgnoreUnchanged:FormValidator~~
619 """
620 field_flags = ('optional', )
622 def __call__(self, form, field):
623 if field.data and field.object_data and field.data == field.object_data:
624 field.errors[:] = []
625 raise validators.StopValidation()