From 85403a0784f752cde26400353e44121807a93624 Mon Sep 17 00:00:00 2001 From: Taverne Sylvain Date: Fri, 25 Nov 2016 11:28:30 +0100 Subject: [PATCH] WIP: Works on validators (WIP) --- itools/database/fields.py | 11 ++ itools/validators/__init__.py | 28 +++++ itools/validators/base.py | 205 ++++++++++++++++++++++++++++++++ itools/validators/database.py | 39 ++++++ itools/validators/exceptions.py | 44 +++++++ itools/validators/files.py | 117 ++++++++++++++++++ itools/validators/password.py | 50 ++++++++ itools/validators/registry.py | 27 +++++ itools/web/context.py | 23 +++- itools/web/views.py | 11 +- setup.conf | 4 +- test/test.py | 4 +- test/test_validators.py | 67 +++++++++++ 13 files changed, 620 insertions(+), 10 deletions(-) create mode 100644 itools/validators/__init__.py create mode 100644 itools/validators/base.py create mode 100644 itools/validators/database.py create mode 100644 itools/validators/exceptions.py create mode 100644 itools/validators/files.py create mode 100644 itools/validators/password.py create mode 100644 itools/validators/registry.py create mode 100644 test/test_validators.py diff --git a/itools/database/fields.py b/itools/database/fields.py index 54d66a26d..7850f30cd 100644 --- a/itools/database/fields.py +++ b/itools/database/fields.py @@ -17,6 +17,7 @@ # Import from itools from itools.core import is_prototype, prototype from itools.gettext import MSG +from itools.validators import validator class Field(prototype): @@ -31,6 +32,8 @@ class Field(prototype): 'invalid': MSG(u'Invalid value.'), 'required': MSG(u'This field is required.'), } + validators = [] + def get_datatype(self): return self.datatype @@ -41,6 +44,14 @@ def access(self, mode, resource): return True + def get_validators(self): + validators = [] + for v in self.validators: + if type(v) is str: + v = validator(v)() + validators.append(v) + return validators + def get_field_and_datatype(elt): """ Now schema can be Datatype or Field. diff --git a/itools/validators/__init__.py b/itools/validators/__init__.py new file mode 100644 index 000000000..556b6e5f8 --- /dev/null +++ b/itools/validators/__init__.py @@ -0,0 +1,28 @@ +# -*- coding: UTF-8 -*- +# Copyright (C) 2016 Sylvain Taverne +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# Import from itools +from base import BaseValidator +from exceptions import ValidationError +from registry import register_validator, validator +import files +import database + +__all__ = [ + 'BaseValidator', + 'ValidationError', + 'register_validator', + 'validator'] diff --git a/itools/validators/base.py b/itools/validators/base.py new file mode 100644 index 000000000..ac5021b2a --- /dev/null +++ b/itools/validators/base.py @@ -0,0 +1,205 @@ +# -*- coding: UTF-8 -*- +# Copyright (C) 2016 Sylvain Taverne +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# Import from standard library +import re + +# Import from itools +from itools.core import prototype, prototype_type +from itools.gettext import MSG + +# Import from here +from exceptions import ValidationError +from registry import register_validator + + +class BaseValidatorMetaclass(prototype_type): + + def __new__(mcs, name, bases, dict): + cls = prototype_type.__new__(mcs, name, bases, dict) + if 'validator_id' in dict: + register_validator(cls) + return cls + + +class validator_prototype(prototype): + + __metaclass__ = BaseValidatorMetaclass + + +class BaseValidator(validator_prototype): + + validator_id = None + errors = {'invalid': MSG(u'Enter a valid value')} + + def is_valid(self, value): + try: + self.check(value) + except ValidationError: + return False + return True + + + def check(self, value): + raise NotImplementedError('Validator is not configured') + + + def get_error_msg(self): + return self.msg + + + def raise_default_error(self, kw=None): + code, msg = self.errors.items()[0] + raise ValidationError(msg, code, kw) + + + def __call__(self, value): + return self.check(value) + + + +class EqualsValidator(BaseValidator): + + validator_id = 'equals-to' + base_value = None + errors = {'not-equals': MSG(u'The value should be equals to {base_value}')} + + def check(self, value): + if value != self.base_value: + kw = {'base_value': self.base_value} + self.raise_default_error(kw) + + + +class RegexValidator(BaseValidator): + + regex = None + inverse_match = False + + def check(self, value): + value = str(value) + r = re.compile(self.regex, 0) + if bool(r.search(value)) != (not self.inverse_match): + self.raise_default_error() + + + +class IntegerValidator(RegexValidator): + + validator_id = 'integer' + regex = '^-?\d+\Z' + errors = {'valid-integer': MSG(u'Enter a valid integer.')} + + + +class PositiveIntegerValidator(BaseValidator): + + validator_id = 'integer-positive' + errors = {'integer-positive': MSG(u'Positiver XXX')} + + def check(self, value): + if value < 0: + kw = {'value': value} + self.raise_default_error(kw) + + + +class PositiveIntegerNotNullValidator(BaseValidator): + + validator_id = 'integer-positive-not-null' + errors = {'integer-positive-not-null': MSG(u'XXX')} + + def check(self, value): + if value <= 0: + kw = {'value': value} + self.raise_default_error(kw) + + + +class MaxValueValidator(BaseValidator): + + validator_id = 'max-value' + errors = {'max-value': MSG(u'Ensure this value is less than or equal to {max_value}')} + max_value = None + + def check(self, value): + if value > self.max_value: + kw = {'max_value': self.max_value} + self.raise_default_error(kw) + + + +class MinValueValidator(BaseValidator): + + validator_id = 'min-value' + errors = {'min-value': MSG(u'Ensure this value is greater than or equal to {min_value}.')} + min_value = None + + def check(self, value): + if value < self.min_value: + kw = {'min_value': self.min_value} + self.raise_default_error(kw) + + + +class MinMaxValueValidator(BaseValidator): + + validator_id = 'min-max-value' + errors = {'min_max_value': MSG( + u'Ensure this value is greater than or equal to {min_value}.' + u'and value is less than or equal to {max_value}.')} + min_value = None + max_value = None + + def check(self, value): + if value < self.min_value or value > self.max_value: + kw = {'max_value': self.max_value} + self.raise_default_error(kw) + + + + +class MinLengthValidator(BaseValidator): + + validator_id = 'min-length' + min_length = 0 + errors = {'min_length': MSG(u'Error')} + + def check(self, value): + if len(value) < self.min_length: + kw = {'value': value, 'min_length': self.min_length} + self.raise_default_error(kw) + + + +class MaxLengthValidator(BaseValidator): + + validator_id = 'max-length' + max_length = 0 + errors = {'max_length': MSG(u'Error')} + + def check(self, value): + if len(value) > self.max_length: + kw = {'value': value, 'max_length': self.max_length} + self.raise_default_error(kw) + + + +class HexadecimalValidator(RegexValidator): + + validator_id = 'hexadecimal' + regex = '^#[A-Fa-f0-9]+$' + errors = {'invalid': MSG(u'Hexa invalide')} diff --git a/itools/validators/database.py b/itools/validators/database.py new file mode 100644 index 000000000..21a074ded --- /dev/null +++ b/itools/validators/database.py @@ -0,0 +1,39 @@ +# -*- coding: UTF-8 -*- +# Copyright (C) 2016 Sylvain Taverne +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# Import from itools +from itools.gettext import MSG + +# Import from here +from base import BaseValidator + + +class UniqueValidator(BaseValidator): + + validator_id = 'unique' + errors = {'unique': MSG(u'The field {nb_results} should be unique')} + + def check(self, value): + from itools.database import AndQuery, NotQuery + from itools.database import PhraseQuery + query = AndQuery( + NotQuery(PhraseQuery('abspath', str(self.resource.abspath))), + PhraseQuery(self.field_name, value)) + search = self.context.database.search(query) + nb_results = len(search) + if nb_results > 0: + kw = {'nb_results': nb_results} + self.raise_default_error(kw) diff --git a/itools/validators/exceptions.py b/itools/validators/exceptions.py new file mode 100644 index 000000000..12b9b1567 --- /dev/null +++ b/itools/validators/exceptions.py @@ -0,0 +1,44 @@ +# -*- coding: UTF-8 -*- +# Copyright (C) 2016 Sylvain Taverne +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +class ValidationError(Exception): + + errors = [] + + def __init__(self, msg=None, code=None, msg_params=None): + errors = [] + if type(msg) is list: + errors.extend(msg) + else: + errors.append((msg, code, msg_params)) + self.errors = errors + + + def get_messages(self): + l = [] + for msg, code, msg_params in self.errors: + l.append(msg.gettext(**msg_params)) + return l + + + def get_message(self): + messages = self.get_messages() + return '\n'.join(messages) + + + def __str__(self): + return self.get_message() diff --git a/itools/validators/files.py b/itools/validators/files.py new file mode 100644 index 000000000..afb27f31f --- /dev/null +++ b/itools/validators/files.py @@ -0,0 +1,117 @@ +# -*- coding: UTF-8 -*- +# Copyright (C) 2016 Sylvain Taverne +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# Import from standard library +from cStringIO import StringIO + +# Import from PIL +from PIL import Image as PILImage + +# Import from itools +from itools.gettext import MSG + +# Import from here +from base import BaseValidator +from exceptions import ValidationError + + + +class FileExtensionValidator(BaseValidator): + + validator_id = 'file-extension' + allowed_extensions = [] + errors = {'invalid_extension': MSG( + u"File extension '{extension}' is not allowed. " + u"Allowed extensions are: '{allowed_extensions}'.")} + + + def check(self, value): + extension = self.get_extension(value) + if extension not in self.allowed_extensions: + kw = {'value': value} + self.raise_default_error(kw) + + + def get_extension(self, value): + filename, mimetype, body = value + return filename.split('.')[-1] + + + +class ImageExtensionValidator(FileExtensionValidator): + + validator_id = 'image-extension' + allowed_extensions = ['jpeg', 'png', 'gif'] + + + +class MimetypesValidator(BaseValidator): + + validator_id = 'mimetypes' + authorized_mimetypes = [] + errors = {'bad_mimetype': MSG(u"XXX")} + + + def check(self, value): + filename, mimetype, body = value + if mimetype not in self.authorized_mimetypes: + kw = {'value': value} + self.raise_default_error(kw) + + + +class ImageMimetypesValidator(MimetypesValidator): + + validator_id = 'image-mimetypes' + authorized_mimetypes = ['image/jpeg', 'image/png', 'image/gif'] + + + +class FileSizeValidator(BaseValidator): + + validator_id = 'file-size' + max_size = 1024*1024*10 + errors = {'too_big': MSG(u'XXX')} + + def check(self, value): + filename, mimetype, body = value + size = len(body) + if size > self.max_size: + kw = {'size': size} + self.raise_default_error(kw) + + + +class ImagePixelsValidator(BaseValidator): + + validator_id = 'image-pixels' + max_pixels = 2000*2000 + + errors = {'too_much_pixels': MSG(u"L'image est trop grande."), + 'image_has_errors': MSG(u"L'image contient des erreurs")} + + def check(self, value): + filename, mimetype, body = value + data = StringIO(body) + try: + im = PILImage.open(data) + im.verify() + except Exception: + code = 'image_has_errors' + raise ValidationError(code, self.errors[code], {}) + if im.width * im.height > self.max_pixels: + code = 'too_much_pixels' + raise ValidationError(code, self.errors[code], {}) diff --git a/itools/validators/password.py b/itools/validators/password.py new file mode 100644 index 000000000..c3cbdafd1 --- /dev/null +++ b/itools/validators/password.py @@ -0,0 +1,50 @@ +# -*- coding: UTF-8 -*- +# Copyright (C) 2016 Sylvain Taverne +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# Import from standard library +from string import ascii_letters, digits + +# Import from itools +from itools.gettext import MSG + +# Import from here +from base import BaseValidator + + +class StrongPasswordValidator(BaseValidator): + """ + au minimum un caractère spécial ( *?./+#!,;:=) + at least one special character ( *?./+#!,;:=) + at least a number (1, 2, 3, ...)" + """ + min_length = 8 + + errors = { + 'too_short': MSG(u"This password is too short. It must contain at least {min_length} characters.") + } + help_msg = MSG(u"Your password must contain at least {min_length} characters.") + + def check(self, value): + has_letter = has_digit = has_special = False + for c in value: + if c in ascii_letters: + has_letter = True + elif c in digits: + has_digit = True + else: + has_special = True + if not has_letter or not has_digit or not has_special: + self.raise_default_error() diff --git a/itools/validators/registry.py b/itools/validators/registry.py new file mode 100644 index 000000000..73fc9a104 --- /dev/null +++ b/itools/validators/registry.py @@ -0,0 +1,27 @@ +# -*- coding: UTF-8 -*- +# Copyright (C) 2016 Sylvain Taverne +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + + + +validators_registry = {} + +def register_validator(cls): + validators_registry[cls.validator_id] = cls + + +def validator(name, **kw): + return validators_registry[name](**kw)() diff --git a/itools/web/context.py b/itools/web/context.py index 784671cda..50d872b52 100644 --- a/itools/web/context.py +++ b/itools/web/context.py @@ -42,6 +42,7 @@ from itools.i18n import format_datetime, format_date, format_time from itools.log import Logger, log_error, log_warning from itools.uri import decode_query, get_reference, Path, Reference +from itools.validators import ValidationError # Local imports from entities import Entity @@ -1162,19 +1163,33 @@ def _get_form_value(form, name, type=String, default=None): return value +def check_form_value(field, value): + for validator in field.get_validators(): + validator = validator( + title=field.title, context=context) + try: + validator.check(value) + except ValidationError, e: + msg = e.get_message() + raise FormError(msg, invalid=True) + + def get_form_value(form, name, type=String, default=None): + field, datatype = get_field_and_datatype(type) # Not multilingual is_multilingual = getattr(type, 'multilingual', False) if is_multilingual is False: - return _get_form_value(form, name, type, default) - + value = _get_form_value(form, name, type, default) + check_form_value(field, value) + return value # Multilingual values = {} for key, value in form.iteritems(): if key.startswith('%s:' % name): x, lang = key.split(':', 1) - values[lang] = _get_form_value(form, key, type, default) - + value =_get_form_value(form, key, type, default) + values[lang] = value + check_form_value(field, values) return values diff --git a/itools/web/views.py b/itools/web/views.py index 37768e4cf..0fdf87c15 100644 --- a/itools/web/views.py +++ b/itools/web/views.py @@ -40,7 +40,8 @@ -def process_form(get_value, schema): +def process_form(get_value, schema, error_msg=None): + messages = [] missings = [] invalids = [] values = {} @@ -49,13 +50,16 @@ def process_form(get_value, schema): try: values[name] = get_value(name, type=datatype) except FormError, e: + messages.append(e.get_message()) if e.missing: missings.append(name) elif e.invalid: invalids.append(name) if missings or invalids: + error_msg = error_msg or ERROR(u'Form values are invalid') raise FormError( - message=ERROR(u'There are errors, check below.'), + message=error_msg, + messages=messages, missing=len(missings)>0, invalid=len(invalids)>0, missings=missings, @@ -168,6 +172,7 @@ def get_schema(self, resource, context): return self.schema + form_error_message = ERROR(u'There are errors, check below') def _get_form(self, resource, context): """Form checks the request form and collect inputs consider the schema. This method also checks the request form and raise an @@ -180,7 +185,7 @@ def _get_form(self, resource, context): """ get_value = context.get_form_value schema = self.get_schema(resource, context) - return process_form(get_value, schema) + return process_form(get_value, schema, self.form_error_message) def get_value(self, resource, context, name, datatype): diff --git a/setup.conf b/setup.conf index c6d66844e..7fc2b1162 100644 --- a/setup.conf +++ b/setup.conf @@ -35,8 +35,8 @@ classifiers = " # Packages package_root = itools packages = "abnf core csv database datatypes fs gettext handlers html i18n ical - log loop odf office pdf pkg python relaxng rss srx stl tmx uri web workflow - xliff xml xmlfile" + log loop odf office pdf pkg python relaxng rss srx stl tmx uri validators web + workflow xliff xml xmlfile" # Requires requires = "reportlab(>=2.3)" diff --git a/test/test.py b/test/test.py index e22baf711..7dcab89c8 100644 --- a/test/test.py +++ b/test/test.py @@ -38,6 +38,7 @@ import test_tmx import test_uri import test_fs +import test_validators import test_web import test_workflow import test_xliff @@ -47,7 +48,8 @@ test_modules = [test_abnf, test_core, test_csv, test_database, test_datatypes, test_gettext, test_handlers, test_html, test_i18n, test_ical, test_odf, test_pdf, test_rss, test_srx, test_stl, test_tmx, test_uri, test_fs, - test_web, test_workflow, test_xliff, test_xml, test_xmlfile] + test_validators, test_web, test_workflow, test_xliff, test_xml, + test_xmlfile] loader = TestLoader() diff --git a/test/test_validators.py b/test/test_validators.py new file mode 100644 index 000000000..29e721e98 --- /dev/null +++ b/test/test_validators.py @@ -0,0 +1,67 @@ +# -*- coding: UTF-8 -*- +# Copyright (C) 2016 Sylvain Taverne +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# Import from the Standard Library +from unittest import TestCase, main + +# Import from itools +from itools.validators import validator + + +class ValidatorsTestCase(TestCase): + + + def test_hexadecimal(self): + v = validator('hexadecimal') + self.assertEqual(True, v.is_valid('#000000')) + + + def test_equals(self): + v = validator('equals-to', base_value=2) + self.assertEqual(True, v.is_valid(2)) + self.assertEqual(False, v.is_valid(3)) + + + def test_integer(self): + v = validator('integer') + self.assertEqual(True, v.is_valid(2)) + self.assertEqual(False, v.is_valid("a")) + + + def test_integer_positive(self): + v = validator('integer-positive') + self.assertEqual(True, v.is_valid(0)) + self.assertEqual(True, v.is_valid(2)) + self.assertEqual(False, v.is_valid(-1)) + + + def test_integer_positive_not_null(self): + v = validator('integer-positive-not-null') + self.assertEqual(True, v.is_valid(2)) + self.assertEqual(False, v.is_valid(-1)) + self.assertEqual(False, v.is_valid(0)) + + + def test_image_mimetypes(self): + v = validator('image-mimetypes') + image1 = 'image.png', 'image/png', None + image2 = 'image.png', 'application/xml', None + self.assertEqual(True, v.is_valid(image1)) + self.assertEqual(False, v.is_valid(image2)) + + +if __name__ == '__main__': + main()