poobrains/poobrains/form/__init__.py

695 lines
21 KiB
Python

# external imports
import time
import copy
import functools
import collections
import peewee
import werkzeug
import click
import flask
# parent imports
#import poobrains
from poobrains import app, request
import poobrains.errors
import poobrains.helpers
import poobrains.rendering
# internal imports
from . import fields
from . import types
from . import validators
class FormMeta(poobrains.helpers.MetaCompatibility, poobrains.helpers.ClassOrInstanceBound):
pass
class BaseForm(poobrains.rendering.Renderable, metaclass=FormMeta):
class Meta:
abstract = True
_external_fields = None
custom_id = None
fields = None
controls = None
prefix = None
name = None
title = None
def __new__(cls, *args, **kw):
instance = super(BaseForm, cls).__new__(cls)
instance.fields = poobrains.helpers.CustomOrderedDict()
instance.controls = poobrains.helpers.CustomOrderedDict()
clone_attributes = []
for attr_name in dir(cls):
cls_attr = getattr(cls, attr_name)
if isinstance(cls_attr, (fields.BaseField, Fieldset, Button)):
attr = getattr(instance, attr_name)
clone_attributes.append((attr_name, attr))
for (attr_name, attr) in sorted(clone_attributes, key=lambda x: getattr(x[1], '_created')): # Get elements in the same order they were defined in, as noted by _created property
kw = {}
for propname in attr._meta.clone_props:
value = copy.deepcopy(getattr(attr, propname))
kw[propname] = value
kw['name'] = attr_name
clone = attr.__class__(**kw)
setattr(instance, attr_name, clone)
return instance
def __init__(self, prefix=None, name=None, title=None, custom_id=None, **kwargs):
self._external_fields = []
super(BaseForm, self).__init__(**kwargs)
self._name = name
self._title = title
self.prefix = prefix
self.custom_id = custom_id
def __getattribute__(self, name):
try:
r = super().__getattribute__(name)
if isinstance(r, (fields.BaseField, Fieldset)):
if r.name in self:
return self[r.name] # needed for fields that are already defined in self.__class__, so the value from the class isn't used, but its clone
return r
except AttributeError:
if (not self.fields is None) and name in self.fields:
return self.fields[name]
raise
#TODO:
# test if this alternative doesn't fuck anything up, it'd be better:
#if name in self.fields:
# return self.fields[name]
#return super().__getattribute__(name)
def __setattr__(self, name, value):
if name == 'prefix':
super(BaseForm, self).__setattr__(name, value)
for field in self.fields.values():
field.prefix = self.child_prefix
for button in self.controls.values():
button.prefix = self.child_prefix
elif isinstance(value, (fields.BaseField, Fieldset)):
value.name = name
self.add_field(value)
elif (not self.fields is None) and name in self.fields:
if name in dir(self):
super().__setattr__(name, value) # Assume any non-field values that are also in __dict__ are naming conflicts where __getattribute__ won't return the field anyways
else:
raise ValueError(f"Tried overriding form field '{name}' of form {self.__class__.__name__} with non-Field type {str(type(value))}.")
elif isinstance(value, Button):
value.name = name
value.prefix = self.child_prefix
self.controls[name] = value
else:
super(BaseForm, self).__setattr__(name, value)
def __getitem__(self, name):
return self.fields[name]
def __setitem__(self, name, value):
value.name = name
self.add_field(value)
def __delitem__(self, name):
del(self.fields[name])
def __iter__(self):
"""
Iterate over this forms renderable fields.
"""
for field in self.fields.values():
if isinstance(field, (fields.Field, Fieldset)) and field.name not in self._external_fields:
yield field
def __contains__(self, item):
return item in self.fields
def keys(self):
return self.fields.keys()
def __str__(self):
return f"`{self.__class__.__name__}: {self.name}`"
@property
def name(self):
if not hasattr(self, '_name'):
# name might be accessed during __new__ alrealdy
# __setattr__ with field object -> add_field -> child_prefix -> name
return None
if self._name:
return self._name
return self.__class__.__name__.lower()
@name.setter
def name(self, value):
if not value is None:
assert not '.' in value, f"Form names *must* not contain dots, but '{value}' does."
value = value.lower()
self._name = value
@property
def title(self):
if self._title:
return self._title
return self.__class__.__name__.lower()
@title.setter
def title(self, value):
self._title = value
@property
def child_prefix(self):
if self.prefix:
return '.'.join((self.prefix, self.name))
return self.name
@property
def renderable_fields(self):
return [field for field in self]
@property
def fieldsets(self):
return [field for field in self if isinstance(field, Fieldset)]
@property
def ref_id(self):
""" HTML 'id' attribute (to enable assigning fields outside that <form> element). """
if self.custom_id:
return self.custom_id
if self.prefix:
return '-'.join((poobrains.helpers.clean_string(self.prefix), poobrains.helpers.clean_string(self.name)))
return self.name
def empty(self): # TODO: find out why I didn't make this @property
for field in self:
if not field.empty:
return False
return True
@property
def readonly(self):
for field in self:
if not field.readonly:
return False
return True
#def _add_external_field(self, field):
def add_field(self, field, external=False):
"""
Add a field to this form.
`external`: Field is to be rendered outside of this form, but processed by it.
Fields like this can be created by passing a Form object to the Field constructor.
"""
if not isinstance(field, (fields.BaseField, Fieldset)):
raise ValueError(f"Tried adding non-field type '{type(field).__name__}' to form '{self.name}'.")
if isinstance(field, fields.Checkbox) and field.name in self.fields and type(field) == type(self.fields[field.name]): # checkboxes/radio inputs can pop up multiple times, but belong to the same name
self.fields[field.name].choices.extend(field.choices)
else:
field.prefix = self.child_prefix
self.fields[field.name] = field
if external:
self._external_fields.append(field.name)
def bind(self, values, files):
if not values is None:
compound_error = poobrains.errors.CompoundError()
actionable_fields = [f for f in self]
actionable_fields += [self.fields[name] for name in self._external_fields]
for field in actionable_fields:
if not field.readonly:
source = files if isinstance(field, fields.File) else values
if not field.name in source:
if field.multi or isinstance(field, Fieldset):
field_values = werkzeug.datastructures.MultiDict()
elif field.type == types.BOOL:
field_values = False # boolean values via checkbox can only ever be implied false. yay html!
else:
field_values = ''
elif field.multi:
field_values = source.getlist(field.name)
else:
field_values = source[field.name]
try:
if isinstance(field, Fieldset):
sub_files = files[field.name] if field.name in files else werkzeug.datastructures.MultiDict()
field.bind(field_values, sub_files)
else:
field.bind(field_values)
except poobrains.errors.CompoundError as ce:
for e in ce.errors:
compound_error.append(e)
for name, control in self.controls.items():
if isinstance(control, Button):
control.value = values.get(name, False)
if len(compound_error):
raise compound_error
def render_fields(self):
"""
Render fields of this form which have not yet been rendered.
"""
rendered_fields = u''
for field in self:
if not field.rendered:
rendered_fields += field.render()
return rendered_fields
def render_controls(self):
"""
Render controls for this form.
"""
rendered_controls = u''
for control in self.controls.values():
if not control.rendered:
rendered_controls += control.render()
return rendered_controls
def templates(self, mode=None):
tpls = []
for x in [self.__class__] + self.__class__.ancestors():
name = x.__name__.lower()
if issubclass(x, BaseForm):
tpls.append(f'form/{name}.jinja')
if mode:
tpls.append(f'form/{name}-{mode}.jinja')
else:
tpls.append(f'{name}.jinja')
if mode:
tpls.append(f'{name}-{mode}.jinja')
return tpls
def child_submit(self, submit):
components = submit.split('.', 1)
if len(components) == 2:
return components[1]
def validate(self, submit):
self.validate_submitting_fieldset(submit)
def validate_all_fieldsets(self, submit):
for fieldset in self.fieldsets:
fieldset.validate(submit)
def is_submitting_fieldset(self, submit):
return not '.' in submit
def find_submitting_fieldset(self, submit):
if '.' in submit:
fieldset_name, fieldset_submit = submit.split('.', 1)
if fieldset_name in self.fields:
if isinstance(self.fields[fieldset_name], Fieldset):
return self.fields[fieldset_name]
else:
raise NameError(f"{fieldset_name} in form {self.__class__.__name__} is not a Fieldset, but was apparently submitted to!")
else:
raise NameError(f"Unknown Fieldset to submit to: {fieldset_name}")
def validate_submitting_fieldset(self, submit):
fieldset = self.find_submitting_fieldset(submit)
if not fieldset is None:
fieldset.validate(self.child_submit(submit))
def process_all_fieldsets(self, submit):
for fieldset in self.fieldsets:
if submit.startswith(fieldset.name):
# modify submit string for the submitting fieldset so it's correctly "scoped".
submit = submit[len(fieldset.name)+1:]
fieldset.process(submit)
def process_submitting_fieldset(self, submit):
""" Call .process on the Fieldset the used submit Button is located on (or do nothing if the submit is directly in this form)."""
fieldset = self.find_submitting_fieldset(submit)
return fieldset.process(self.child_submit(submit))
def process(self, submit):
return self.process_submitting_fieldset(submit)
class Form(BaseForm):
"""
A form. Can be constructed in multiple ways.
The easiest is just subclassing, like:
``` python
@app.expose('/newperson')
class MyPersonForm(poobrains.form.Form):
name_prefix = poobrains.form.fields.Text(label='Prefix', help_text="Name prefix, ex: 'Dr.'")
name_first = poobrains.form.fields.Text(label='First name', required=True)
name_midfix = poobrains.form.fields.Text(label='Midfix', help_text="Name midfix, nickname for example. Will be put in quotes.")
name_last = poobrains.form.fields.Text(label='Last name')
gender = poobrains.form.fields.Text(label='Gender', help_text="Your gender. Optional. Comes fully Unicode-enabled.")
hair_color = poobrains.form.fields.ColorField(label='Hair color')
```
This will give you a `render`able form. In order for it to do anything,
you'll also have to implement the `process` function.
To expand on the example above, this would look like:
``` python
def process(self, submit):
new_person = Person() # We'll just assume there's a `Storable` named `Person` already defined.
new_person.name_prefix = self.fields['name_prefix'].value # values get automatically filled in with data from `flask.request.form`
new_person.name_first = self.fields['name_first'].value
new_person.name_midfix = self.fields['name_midfix'].value
new_person.name_last = self.fields['name_last'].value
new_person.gender = self.fields['gender'].value
new_person.hair_color = self.fields['hair_color'].value
new_person.save()
return poobrains.redirect(new_person.url('full'))
```
Another way is dynamically building the form.
This can be done either from outside:
``` python
form = poobrains.form.Form()
form.foo = poobrains.form.Button('submit', label='PUSH ME')
```
or from inside by subclassing again:
``` python
class MyForm(poobrains.form.Form):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.foo = poobrains.form.Button('submit', label='PUSH ME')
```
Especially the last method offers a nice amount of flexibility
to build complex, application-like dynamic forms.
**NOTE**:
We've tried giving access to a forms' fields through object properties
(ex: `formobj.foo` to get field 'foo') wherever possible, but sometimes
you might not get around a naming collision, for example when using
'name', 'title' or 'prefix' as form name.
Assignment in these cases (`formobj.foo = SomeFieldClass(label="Foo!")`)
*will* still work, but accessing them (`do_something(formobj.foo)`) will
return the value of the actual property in the object. For these cases,
dict-style access (`formobj['foo']`) still works for assigning *and*
accessing form fields.
"""
method = None
action = None
class Meta:
abstract = True
def __init__(self, prefix=None, name=None, title=None, method=None, action=None, custom_id=None, **kwargs):
super(Form, self).__init__(prefix=prefix, name=name, title=title, custom_id=custom_id, **kwargs)
self.method = method if method else 'POST'
self.action = action if action else ''
@classmethod
def class_view(cls, mode='full', **kwargs):
instance = cls(**kwargs)
return instance.view(mode)
@poobrains.helpers.themed
def view(self, mode='full', **kwargs):
"""
view function to be called in a flask request context
"""
if request.method == self.method:
submit = request.form['submit'][len(self.ref_id)+1:]
values = request.form.get(self.name, werkzeug.datastructures.MultiDict())
files = request.files.get(self.name, werkzeug.datastructures.FileMultiDict())
try:
self.bind(values, files)
self.validate(submit)
try:
return self.process(submit)
except poobrains.errors.CompoundError as e:
for error in e.errors:
flask.flash(error.message, 'error')
except poobrains.errors.CompoundError as e:
for error in e.errors:
flask.flash(str(error), 'error')
except poobrains.errors.ValidationError as e:
flask.flash(str(e), 'error')
if e.field:
self.fields[e.field].append(e)
if not request.form['submit'].startswith(self.ref_id): # means the right form was submitted, should be implied by the path tho…
app.logger.error(f"Form {self.name}: submit button of another form used: {request.form['submit']}")
flask.flash("The form you just used might be broken. Bug someone if this problem persists.", 'error')
return self
class Fieldset(BaseForm):
errors = None
readonly = None
rendered = None
multi = False
_default = werkzeug.datastructures.MultiDict()
class Meta:
abstract = True
clone_props = ['name', 'title']
def __new__(cls, *args, **kwargs):
instance = super(Fieldset, cls).__new__(cls, *args, **kwargs)
instance._created = time.time()
return instance
def __init__(self, *args, **kw):
self.rendered = False
self.readonly = False
self.errors = []
super(Fieldset, self).__init__(*args, **kw)
def render(self, mode='full'):
self.rendered = True
return super(Fieldset, self).render(mode)
def __setattr__(self, name, value):
if name == 'name' and isinstance(value, str):
assert not '.' in value, f"Form Field names *must* not contain dots: {value}" # FIXME: assert bad! raise exception
super(Fieldset, self).__setattr__(name, value)
class ProxyFieldset(Fieldset):
""" A fieldset wrapping a form object """
form = None
def __init__(self, form, **kwargs):
super(ProxyFieldset, self).__init__(**kwargs)
self.form = form
self.title = form.title
self.fields = form.fields # TODO: is this done by reference? will probably fuck up if not.
def bind(self, values, files):
self.form.bind(values, files)
def validate(self, submit):
self.form.validate(submit)
def process(self, submit):
self.form.process(submit)
class TabbedFieldsets(Fieldset):
def __init__(self, fieldsets, value=None, **kwargs):
super().__init__(**kwargs)
for fieldset in fieldsets:
self[fieldset.name] = fieldset
self.value = value or fieldsets[0].name # make the first tab :checked # FIXME: shouldn't we rather do this for self.default?
@property
def idx_tabs(self):
return enumerate(self.fields.values())
def bind(self, values, files):
if isinstance(values, str):
self.value = values
else: # we just assume MultiDict
self.value = values.get('value')
super().bind(values, files)
def process_viewed(self, submit):
fieldset_name = self.value
if submit.startswith(fieldset_name):
# modify submit string for the submitting fieldset so it's correctly "scoped".
submit = submit[len(fieldset_name)+1:]
return self.fields[fieldset_name].process(submit)
def render(self, mode='full'):
return super().render(mode=mode)
class Button(poobrains.rendering.Renderable):
name = None
type = None
value = None
label = None
class Meta:
clone_props = ['type', 'name', 'value', 'label']
def __new__(cls, type, name=None, value=None, label=None, disabled=None, **kwargs):
#instance = super(Button, cls).__new__(cls, **kwargs)
instance = super(Button, cls).__new__(cls)
instance._created = time.time()
return instance
def __init__(self, type, name=None, value=None, label=None, disabled=None, **kwargs):
super(Button, self).__init__(**kwargs)
self.name = name
self.type = type
self.value = value
self.label = label
if disabled is None:
self.disabled = False
else:
self.disabled = disabled
self.rendered = False
@property
def ref_id(self):
""" HTML 'id' attribute value"""
if self.prefix:
return '-'.join((poobrains.helpers.clean_string(self.prefix), poobrains.helpers.clean_string(self.name)))
return self.name
def templates(self, mode=None):
r = []
for cls in self.__class__.mro():
if issubclass(cls, Button):
base = cls.__name__.lower()
r.append(f'form/{base}-{self.type}.jinja')
r.append(f'form/{base}.jinja')
return r
def render(self, mode='full'):
self.rendered = True
return super().render(mode=mode)
class DoubleOptInButton(Button):
def __new__(cls, message, *args, **kwargs):
return super().__new__(cls, *args, **kwargs)
def __init__(self, message, **kwargs):
super().__init__(**kwargs)
self.message = message