1213 lines
32 KiB
Python
1213 lines
32 KiB
Python
# 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 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 <button name
|
|
|
|
def template_candidates(self, mode, format):
|
|
|
|
candidates = []
|
|
|
|
for cls in self.__class__.mro():
|
|
if issubclass(cls, Button):
|
|
candidates += [
|
|
f'{app.config["THEME"]}/templates/form/button/{cls._lowerclass}-{self.type}.{format}',
|
|
f'{app.config["THEME"]}/templates/form/button/{cls._lowerclass}.{format}',
|
|
]
|
|
|
|
if app.config['THEME'] != 'default':
|
|
for cls in self.__class__.mro():
|
|
if issubclass(cls, Button):
|
|
candidates += [
|
|
f'default/templates/form/button/{self._lowerclass}-{self.type}.{format}',
|
|
f'default/templates/form/button/{self._lowerclass}.{format}',
|
|
]
|
|
|
|
return candidates
|
|
|
|
def render(self, mode='full', format='html'):
|
|
|
|
self._rendered = True
|
|
return super().render(mode=mode, format=format)
|
|
|
|
class Field(rendering.Renderable):
|
|
|
|
type_converter = types.STRING
|
|
|
|
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.name = None
|
|
instance.prefix = None
|
|
|
|
return instance
|
|
|
|
def __init__(self, prefix=None, label=None, value=None, id=None, help=None, required=False, validators=None, **kwargs):
|
|
|
|
super().__init__(**kwargs)
|
|
|
|
self.prefix = prefix
|
|
self.name = None
|
|
self.label = label
|
|
self.value = value
|
|
self._id = id
|
|
self.help = help
|
|
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
|
|
|
|
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"Field name must be a string or None, {value} is neither.")
|
|
elif '.' in value:
|
|
raise ValueError(f"Field 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 == 'help':
|
|
|
|
value = markdown.MarkdownString(value)
|
|
|
|
# finally do the actual assignment via super()
|
|
super().__setattr__(name, value)
|
|
|
|
@property
|
|
def valid(self):
|
|
return len(self.errors) == 0
|
|
|
|
@property
|
|
def id(self):
|
|
if self._id:
|
|
return self._id
|
|
|
|
return self.name_qualified.replace('.', '-')
|
|
|
|
@id.setter
|
|
def id(self, value):
|
|
self._id = value
|
|
|
|
@property
|
|
def css_classes(self):
|
|
return f'field-{self.name} {self._lowerclass} {super().css_classes}'
|
|
|
|
@property
|
|
def html_value(self):
|
|
if self.value is None:
|
|
return ''
|
|
return self.value
|
|
|
|
def template_candidates(self, mode, format):
|
|
|
|
candidates = []
|
|
|
|
for cls in self.__class__.mro():
|
|
if issubclass(cls, Field):
|
|
candidates += [
|
|
f'{app.config["THEME"]}/templates/form/field/{cls._lowerclass}-{mode}.{format}',
|
|
f'{app.config["THEME"]}/templates/form/field/{cls._lowerclass}.{format}',
|
|
]
|
|
|
|
if app.config['THEME'] != 'default':
|
|
for cls in self.__class__.mro():
|
|
if issubclass(cls, Field):
|
|
candidates += [
|
|
f'default/templates/form/field/{self._lowerclass}-{mode}.{format}',
|
|
f'default/templates/form/field/{self._lowerclass}.{format}',
|
|
]
|
|
|
|
return candidates
|
|
|
|
def render(self, mode='full', format='html'):
|
|
|
|
self._rendered = True
|
|
return super().render(mode=mode, format=format)
|
|
|
|
def bind(self, value):
|
|
|
|
# called by Form.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
|
|
|
|
def validate(self, submit):
|
|
|
|
# called by Form.validate (after Form.bind)
|
|
|
|
if self.value is None:
|
|
|
|
if self._badparameter:
|
|
|
|
self.errors.append(ValidationError(f"Invalid input '{self.value}' for field '{self.label}'"))
|
|
|
|
elif self.required:
|
|
|
|
self.errors.append(ValidationError(f"Field '{self.label}' is required."))
|
|
|
|
for validator in self.validators:
|
|
|
|
try:
|
|
|
|
validator(self.value)
|
|
|
|
except ValidationError as e:
|
|
|
|
self.errors.append(e)
|
|
|
|
if len(self.errors):
|
|
raise self.errors
|
|
|
|
class ValueField(Field):
|
|
|
|
"""
|
|
Field class which wraps a static value.
|
|
"""
|
|
|
|
def bind(self, value):
|
|
# ignore values from request in order to use value passed to __init__.
|
|
# as this is only a "virtual" field never rendered or otherwise sent
|
|
# to the client, we'd only ever get None here (unless the client fucks
|
|
# with us) anyhow.
|
|
# SECURITY: Do NOT remove this function override. Doing so will allow
|
|
# attackers to POST values to overwrite values considered secure.
|
|
pass
|
|
|
|
def render(self, mode='full'):
|
|
return '' # no-op
|
|
|
|
class RenderableField(Field):
|
|
|
|
"""
|
|
Field class which just thinly wraps a Renderable object
|
|
for display in a form.
|
|
"""
|
|
|
|
def __init__(self, renderable, mode='teaser', **kwargs):
|
|
|
|
super().__init__(**kwargs)
|
|
|
|
self.renderable = renderable
|
|
self.mode = mode
|
|
|
|
def bind(self, value):
|
|
pass
|
|
|
|
def validate(self, submit):
|
|
pass
|
|
|
|
class GroupableField(Field):
|
|
|
|
def __init__(self, group=None, **kwargs):
|
|
|
|
super().__init__(**kwargs)
|
|
self.group = group
|
|
|
|
if group is not None:
|
|
self.type_converter = group.type_converter # is this even needed?
|
|
|
|
class Input(Field):
|
|
input_type = 'text'
|
|
|
|
class Hidden(Input):
|
|
|
|
#TODO: type_converter
|
|
input_type = 'hidden'
|
|
|
|
class Text(Field):
|
|
input_type = 'text'
|
|
|
|
class Search(Input):
|
|
input_type = 'search'
|
|
|
|
class Password(Input):
|
|
input_type = 'password'
|
|
|
|
class EMail(Input):
|
|
|
|
#TODO: type_converter
|
|
input_type = 'email'
|
|
|
|
class Tel(Input):
|
|
|
|
#TODO: type_converter
|
|
input_type = 'tel'
|
|
|
|
class URL(Input):
|
|
|
|
#TODO: type_converter
|
|
input_type = 'url'
|
|
|
|
class Float(Input):
|
|
|
|
type_converter = types.FLOAT
|
|
input_type = 'number'
|
|
|
|
def __init__(self, *args, min=None, max=None, step=0.01, **kwargs):
|
|
|
|
super().__init__(*args, **kwargs)
|
|
self.min = min
|
|
self.max = max
|
|
self.step = step
|
|
|
|
self.validators.append(functools.partial(validate_range, min, max))
|
|
|
|
class Integer(Float):
|
|
|
|
type_converter = types.INT
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
|
|
super().__init__(*args, **kwargs)
|
|
self.step = 1
|
|
|
|
class FloatRange(Float):
|
|
|
|
input_type = 'range'
|
|
|
|
class IntegerRange(FloatRange):
|
|
|
|
input_type = 'range'
|
|
type_converter = types.INT
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
|
|
super().__init__(*args, **kwargs)
|
|
self.step = 1
|
|
|
|
class Color(Input):
|
|
|
|
type_converter = types.COLOR
|
|
input_type = 'color'
|
|
|
|
@property
|
|
def html_value(self):
|
|
|
|
if self.value is None:
|
|
return None
|
|
|
|
return self.value.hex_rgb()
|
|
|
|
class Date(Input):
|
|
|
|
type_converter = types.DATE
|
|
input_type = 'date'
|
|
|
|
@property
|
|
def html_value(self):
|
|
|
|
value = super().html_value
|
|
|
|
if isinstance(value, datetime.date):
|
|
return value.strftime('%Y-%m-%d')
|
|
|
|
class Time(Input):
|
|
|
|
input_type = 'time'
|
|
type_converter = types.TIME
|
|
|
|
class DateTime(Input):
|
|
|
|
type_converter = types.DATETIME
|
|
input_type = 'datetime-local'
|
|
|
|
@property
|
|
def html_value(self):
|
|
|
|
value = super().html_value
|
|
|
|
if isinstance(value, datetime.datetime):
|
|
return value.strftime('%Y-%m-%dT%H:%M:%S')
|
|
|
|
class Checkbox(GroupableField, Input):
|
|
|
|
type_converter = types.BOOL
|
|
input_type = 'checkbox'
|
|
|
|
@property
|
|
def checked(self):
|
|
|
|
if self.group:
|
|
|
|
if self.group.values is None:
|
|
return False
|
|
|
|
return self.value in self.group.values
|
|
|
|
# no group means we're a bool
|
|
return bool(self.value)
|
|
|
|
def bind(self, value):
|
|
|
|
# called by Form.bind
|
|
|
|
if value is None:
|
|
value = '' # translate None to empty string because that's what click expects
|
|
|
|
if value == '':
|
|
|
|
value = False
|
|
|
|
if self.required:
|
|
|
|
e = ValidationError(f"Field '{self.label}' is required.")
|
|
self.errors.append(e)
|
|
|
|
raise e
|
|
|
|
try:
|
|
|
|
self.value = self.type_converter.convert(value, None, None)
|
|
|
|
except click.exceptions.BadParameter:
|
|
|
|
e = ValidationError(f"Invalid input '{value}' for field '{self.label}'")
|
|
self.errors.append(e)
|
|
|
|
raise e
|
|
|
|
if self.value == '':
|
|
# shouldn't be needed/possible to trigger, can probably be removed
|
|
self.value = None
|
|
|
|
class Radiobutton(GroupableField, Input):
|
|
|
|
input_type = 'radio'
|
|
type_converter = types.INT
|
|
|
|
@property
|
|
def checked(self):
|
|
|
|
if self.group:
|
|
|
|
if self.group.value is None:
|
|
return False
|
|
|
|
return self.value == self.group.value
|
|
|
|
return bool(self.value)
|
|
|
|
class File(Input):
|
|
|
|
type_converter = types.File()
|
|
input_type = 'file'
|
|
|
|
class TextArea(Field):
|
|
pass
|
|
|
|
class Select(Field):
|
|
|
|
def __init__(self, choices, **kwargs):
|
|
|
|
super().__init__(**kwargs)
|
|
self.choices = choices
|
|
|
|
class ForeignKeySelect(Select):
|
|
|
|
def __init__(self, db_field, **kwargs):
|
|
|
|
if not isinstance(db_field, peewee.ForeignKeyField):
|
|
raise TypeError("ForeignKeySelect db_field MUST be a ForeignKeyField.")
|
|
|
|
if type(db_field.rel_field) in (peewee.IdentityField, peewee.IntegerField):
|
|
# set type converter for integer types
|
|
# otherwise the default string converter is fine (for now)
|
|
self.type_converter = types.INT
|
|
|
|
choices = []
|
|
for instance in db_field.rel_model.select():
|
|
|
|
if hasattr(instance, 'title'):
|
|
choice_label = instance.title
|
|
elif hasattr(instance, 'name'):
|
|
choice_label = instance.name
|
|
elif hasattr(instance, 'id'):
|
|
choice_label = f"{instance.__class__.__name__} #{instance.id}"
|
|
else:
|
|
choice_label = repr(instance) # not pretty, but should be unique
|
|
|
|
choices.append(( getattr(instance, db_field.rel_field.name), choice_label ))
|
|
|
|
if 'label' not in kwargs:
|
|
kwargs['label'] = db_field.verbose_name or db_field.name
|
|
|
|
kwargs['choices'] = choices
|
|
super().__init__(**kwargs)
|
|
|
|
class Fieldset(FormBase, Field):
|
|
|
|
def validate(self, submit):
|
|
|
|
# do the same validation as Form(Base)
|
|
super().validate(submit)
|
|
|
|
# but escalate any errors upwards like a Field
|
|
if len(self.errors):
|
|
raise self.errors
|
|
|
|
class ProxyField(Field):
|
|
|
|
def __init__(self, field, **kwargs):
|
|
|
|
"""
|
|
prefix: parent fieldsets name
|
|
field: field object to proxy
|
|
"""
|
|
|
|
super().__init__(**kwargs)
|
|
|
|
self.field = field
|
|
self.name = field.name
|
|
|
|
@property
|
|
def id(self):
|
|
return self.field.id
|
|
|
|
@property
|
|
def css_classes(self):
|
|
return f'{self._lowerclass} {self.field.css_classes }{super().css_classes}'
|
|
|
|
@property
|
|
def label(self):
|
|
return self.field.label
|
|
|
|
@property
|
|
def value(self):
|
|
return self.field.value
|
|
|
|
@property
|
|
def required(self):
|
|
return self.field.required
|
|
|
|
@property
|
|
def errors(self):
|
|
return self.field.errors
|
|
|
|
def bind(self, value):
|
|
|
|
self.field.bind(value)
|
|
|
|
def validate(self, submit):
|
|
self.field.validate(submit)
|
|
|
|
def render(self, mode='full', format='html'):
|
|
return self.field.render(mode, format=format)
|
|
|
|
class ProxyFieldset(Fieldset):
|
|
|
|
def __init__(self, form, **kwargs):
|
|
|
|
super().__init__(**kwargs)
|
|
|
|
self.form = form
|
|
self.intro = form.intro
|
|
self.form.prefix = self.prefix
|
|
self.fields = form.fields
|
|
self.buttons = form.buttons
|
|
|
|
if 'name' in kwargs:
|
|
self.form.name = kwargs['name']
|
|
|
|
self.name = self.form.name
|
|
|
|
def __setattr__(self, name, value):
|
|
|
|
if name == 'name':
|
|
if hasattr(self, 'form'):
|
|
self.form.name = value
|
|
|
|
elif name == 'prefix':
|
|
if hasattr(self, 'form'):
|
|
self.form.prefix = value
|
|
|
|
super().__setattr__(name, value)
|
|
|
|
@property
|
|
def css_classes(self):
|
|
return f'{self._lowerclass} {super().css_classes} {self.form.css_classes}'
|
|
|
|
def bind(self, form, files):
|
|
self.form.bind(form, files)
|
|
|
|
def validate(self, submit):
|
|
self.form.validate(submit)
|
|
|
|
def process(self, submit):
|
|
self.form.process(submit)
|
|
|
|
def handle(self, skip_bind=False):
|
|
return self.form.handle(skip_bind=skip_bind)
|