# builtins import re import time import copy import datetime import functools # third-party import click import click.types as types import flask import peewee # internals from application import app import util import exceptions import rendering import markdown # instantiate types that don't have default instances types.DATETIME = types.DateTime([ '%Y-%m-%d', '%Y-%m-%d %H:%M', '%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M:%S.%f', '%Y-%m-%dT%H:%M', '%Y-%m-%dT%H:%M:%S', '%Y-%m-%dT%H:%M:%S.%f']) class DateParamType(types.DateTime): def convert(self, value, param, ctx): v = super().convert(value, param, ctx) if isinstance(v, datetime.datetime): v = v.date() return v types.DATE = DateParamType(['%Y-%m-%d']) class TimeParamType(types.ParamType): def convert(self, value, param, ctx): components = value.split(':') try: components = [int(component) for component in components] except ValueError: self.fail(f"Non-integer component in time input: '{value}'", param, ctx) hour, minute, second = [None, None, None] if len(components) == 3: hour, minute, second = components elif len(components) == 2: hour, minute = components else: self.fail(f"Invalid time input: '{value}'", param, ctx) if hour < 0 or hour > 24: self.fail(f"Hour out of range for time input: {hour}, must be between 0 and 24", param, ctx) if hour >= 24 and minute > 0: self.fail(f"Time input past 24:00: '{value}'", param, ctx) if minute < 0 or minute > 59: self.fail(f"Minute out of range for time input: {minute}", param, ctx) if second is not None and ( second < 0 or second > 59 ): self.fail(f"Second out of range for time input: {second}", param, ctx) return datetime.time(*components) types.TIME = TimeParamType() class ColorParamType(types.ParamType): def convert(self, value, param, ctx): try: return util.Color.from_hex(value) except ValueError: self.fail("Invalid value for color.", param, ctx) types.COLOR = ColorParamType() class ValidationError(exceptions.ExposedException): pass def validate_range(min, max, value): if min is not None and value < min: raise ValidationError(f"Value below minimum ({min}).") if max is not None and value > max: raise ValidationError(f"Value above maximum ({max}).") def validate_regexp(pattern, value): if value is None or not re.match(pattern, value): raise ValidationError(f"Value does not match '{pattern}'.") class MultiGroup: """ A form element group with multiple values (i.e. for checkboxes). """ def __init__(self, type, name, label=None, values=None, required=False, validators=None): self.type_converter = type self.name = name self.label = label or name.capitalize() self.values = values or [] self.required = required self.validators = validators or [] self.errors = exceptions.CompoundException() self._bound = False self._validated = False def bind(self, values): if not self._bound: # called by Form.bind for value in values: if value == '': value = None if value is not None: try: # attempt to convert value to pythonic type value = self.type_converter.convert(value, None, None) except click.exceptions.BadParameter: app.logger.warning(f"BadParameter for group '{self.label}', this is likely either a bug or someone's fucking with POST values.") value = None self.values.append(value) self._bound = True def validate(self, submit): # called by Form.validate (after Form.bind) if not self._validated: if len(self.values) == 0 and self.required: self.errors.append(ValidationError(f"At least one item in group '{self.label}' must be checked.")) for validator in self.validators: for value in self.values: try: validator(self.value) except ValidationError as e: self.errors.append(e) self._validated = True if len(self.errors): raise self.errors class Group: """ A form element group with a single value (i.e. for radiobuttons). """ def __init__(self, type, name, label=None, value=None, required=False, validators=None): self.type_converter = type self.name = name self.label = label or name.capitalize() self.value = value self.required = required self.validators = validators or [] self.errors = exceptions.CompoundException() self._badparameter = False # hint set in bind to let .validate know type conversion failed self._bound = False self._validated = False def bind(self, values): if not self._bound: if len(values): value = values[0] else: value = None # from here on, it's the same as Field.bind if value == '': value = None if value is not None: try: # attempt to convert value to pythonic type value = self.type_converter.convert(value, None, None) except click.exceptions.BadParameter: self._badparameter = True value = None self.value = value self._bound = True def validate(self, submit): # called by Form.validate (after Form.bind) if not self._validated: if self.value is None and self.required: self.errors.append(ValidationError(f"One item in group '{self.label}' must be selected.")) for validator in self.validators: try: validator(self.value) except ValidationError as e: self.errors.append(e) self._validated = True if len(self.errors): raise self.errors class ElementContainer: def __init__(self, form): self.form = form self.elements = {} self.order = () def __getitem__(self, key): return self.elements[key] def __setitem__(self, key, value): value.prefix = self.form.name_qualified value.name = key if not key in self.order: # only add key if it doesn't already exist # i.e. replacing fields conserves order new_order = list(self.order) new_order.append(key) self.order = tuple(new_order) self.elements[key] = value def __delitem__(self, key): new_order = list(self.order) new_order.remove(key) self.order = tuple(new_order) del self.elements[key] def __len__(self): return self.elements.__len__() def __iter__(self): return self.order.__iter__() def __contains__(self, key): return key in self.elements def keys(self): return self.order def items(self): return [(key, self.elements[key]) for key in self.order] # TODO: generator-based solution? def values(self): return [self.elements[key] for key in self.order] def clear(self): self.order = () self.elements.clear() def position(self, key, index): if index >= len(self): raise ValueError(f"Position {index} not possible for container with {len(self)} elements. Note that indexes are 0-based.") elif index == -1: index = len(self) - 1 old_order = list(self.order) old_order.remove(key) new_order = [] for k in old_order[:index]: new_order.append(k) new_order.append(key) for k in old_order[index:]: new_order.append(k) self.order = tuple(new_order) class FieldContainer(ElementContainer): def __setitem__(self, key, value): if not isinstance(value, Field): raise TypeError("Form elements must be derived from form.Field.") super().__setitem__(key, value) class ButtonContainer(ElementContainer): def __setitem__(self, key, value): if not isinstance(value, Button): raise TypeError("Form elements must be derived from form.Button.") super().__setitem__(key, value) class FormBase(rendering.Renderable): def __new__(cls, *args, **kwargs): instance = super().__new__(cls) instance._created = time.time() instance._rendered = False # set up attributes already needed during __new__/__init__ instance.fields = FieldContainer(instance) instance.buttons = ButtonContainer(instance) instance.name = None instance.prefix = None for attr_name in dir(cls): attr = getattr(cls, attr_name) if isinstance(attr, Field): clone = copy.deepcopy(attr) clone.name = attr_name instance[attr_name] = clone elif isinstance(attr, Button): clone = copy.deepcopy(attr) clone.name = attr_name instance.buttons[attr_name] = clone instance.fields.order = tuple([field.name for field in sorted(instance.fields.values(), key=lambda x: x._created)]) instance.buttons.order = tuple([button.name for button in sorted(instance.buttons.values(), key=lambda x: x._created)]) return instance def __init__(self, prefix=None, name=None, title=None, id=None, action=None, method='POST', **kwargs): super().__init__(**kwargs) self.name = name if name else self._lowerclass self.prefix = prefix # NOTE: implicitly sets self.name_qualified through __setattr__ self.title = title self.id = id self.action = action self.method = method self.errors = exceptions.CompoundException() self.intro = None def __setattr__(self, name, value): if name == 'name': if value is None: self.name_qualified = None else: if not isinstance(value, str): raise ValueError(f"Form name must be a string or None, {value} is neither.") elif '.' in value: raise ValueError(f"Form name must not contain dots, but '{value}' does.") if self.prefix: self.name_qualified = f'{self.prefix}.{value}' else: self.name_qualified = value elif name == 'prefix': # determine correct prefix for children of this form (fields, fieldsets, buttons) if self.name is not None: if value is not None: # new prefix != None self.name_qualified = f'{value}.{self.name}' else: self.name_qualified = self.name elif name == 'name_qualified': if value is not None: # ignore setting qualified name during construction for field in self.fields.values(): field.prefix = value for button in self.buttons.values(): button.prefix = value # finally do the actual assignment via super() super().__setattr__(name, value) # start dictionary interface def __getitem__(self, key): return self.fields[key] def __setitem__(self, key, value): self.fields[key] = value def __delitem__(self, key): del self.fields[key] def __len__(self): return self.fields.__len__() def __iter__(self): return self.fields.__iter__() def keys(self): return self.fields.keys() def items(self): return self.fields.items() def values(self): return self.fields.values() def clear(self): self.fields.clear() # end dictionary interface @property def css_classes(self): return f'form-{self.name} {self._lowerclass} {super().css_classes}' @property def valid(self): if 'errors' in dir(self): return len(self.errors) == 0 return True @property def fields_pending_render(self): return [field for field in self.fields.values() if not field._rendered] @property def buttons_pending_render(self): return [button for button in self.buttons.values() if not button._rendered] def template_candidates(self, mode, format): candidates = [] for cls in self.__class__.mro(): if issubclass(cls, FormBase): candidates += [ f'{app.config["THEME"]}/templates/form/{cls._lowerclass}-{mode}.{format}', f'{app.config["THEME"]}/templates/form/{cls._lowerclass}.{format}', ] if app.config['THEME'] != 'default': for cls in self.__class__.mro(): if issubclass(cls, FormBase): candidates += [ f'default/templates/form/{self._lowerclass}-{mode}.{format}', f'default/templates/form/{self._lowerclass}.{format}', ] return candidates def bind(self, form, files): # form is flask.request.form or equivalent # files is flask.request.files or equivalent for field in self.fields.values(): try: if isinstance(field, Fieldset): field.bind(form, files) elif isinstance(field, GroupableField) and field.group is not None: # File isn't groupable, so source is always form field.group.bind(form.getlist(field.group.name)) else: if isinstance(field, File): source = files else: source = form field.bind(source.get(field.name_qualified)) except ValidationError as e: self.errors.append(e) def submit_relative(self, submit): # get the submit identifier relative to self if not submit.startswith(f'{self.name_qualified}.'): raise ValueError(f"Submit '{submit}' not in form/fieldset '{self.name_qualified}'.") return submit[len(self.name_qualified) + 1:] def submitted_fieldset(self, submit): submit_relative = self.submit_relative(submit) if '.' in submit_relative: # submit on child fieldset pressed # so we found out which fieldset fieldset_name, fieldset_submit = submit_relative.split('.', 1) if fieldset_name not in self.fields: # see if it exists raise ValueError(f"Submit '{submit}' in inexistant fieldset '{fieldset_name}'.") elif not isinstance(self.fields[fieldset_name], Fieldset): # and is actually a fieldset raise ValueError(f"Submit '{submit}' for field '{fieldset_name}' which isn't a fieldset.") return self.fields[fieldset_name] def validate(self, submit): # called after bind, so field elements have their .value set for field in self.fields.values(): # we iterate through our own fields try: if isinstance(field, GroupableField) and field.group is not None: field.group.validate(submit) else: field.validate(submit) # and validate those except exceptions.CompoundException as e: # collecting any validation errors self.errors.extend(e.exceptions) def process(self, submit): # default processing function is a no-op. this way, route callbacks # can act based directly on field object values, tho this is only # recommended for trivial use-cases. pass def handle(self, skip_bind=False): if 'submit' not in flask.request.form: self.errors.append(ValidationError("Missing submit information.")) return submit = flask.request.form.get('submit') if not skip_bind: # bind is called once by the topmost form element being .handle()d. # this is done so form field values input in the browser are always # conserved, no matter on which fieldset/form the submit button was # pushed. self.bind(flask.request.form, flask.request.files) # find submitting element, either self or a child fieldset fieldset = self.submitted_fieldset(submit) if fieldset is not None: # handle fieldset, but skip binding, as we already did that return fieldset.handle(skip_bind=True) else: submit_relative = self.submit_relative(submit) self.validate(submit_relative) if len(self.errors): for e in self.errors: flask.flash(str(e), 'error') return self.process(submit_relative) class Form(FormBase): pass class Button(rendering.Renderable): def __new__(cls, *args, **kwargs): instance = super().__new__(cls) instance._created = time.time() instance._rendered = False return instance def __init__(self, prefix=None, type='submit', name=None, label='Submit', id=None, **kwargs): super().__init__(**kwargs) self.prefix = None self.type = type self.label = label self.id = id self.name = name # NOTE button-submit outputs "submit" for