pdnew/form.py

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)