Source code for pyramid_crud.forms
import wtforms_alchemy
import six
from wtforms.ext.csrf.form import SecureForm
from wtforms.ext.sqlalchemy.fields import QuerySelectField
from .util import get_pks, meta_property
from sqlalchemy.orm.session import object_session
from sqlalchemy.orm.interfaces import MANYTOONE
from sqlalchemy.inspection import inspect
from webob.multidict import MultiDict
import logging
try:
from collections import OrderedDict
except ImportError: # pragma: no cover
from ordereddict import OrderedDict
log = logging.getLogger(__name__)
class _CoreModelMeta(wtforms_alchemy.ModelFormMeta):
"""
Metaclass for :class:`_CoreModelForm`. Assignes some class properties. Not
to be used directly.
"""
def __new__(meta, name, bases, attrs):
# Copy over docstrings from parents
def get_mro_classes(bases):
return (mro_cls for base in bases for mro_cls in base.mro()
if mro_cls != object)
if not('__doc__' in attrs and attrs['__doc__']):
for mro_cls in get_mro_classes(bases):
doc = mro_cls.__doc__
if doc:
attrs['__doc__'] = doc
break
for attr, attribute in attrs.items():
if not attribute.__doc__:
for mro_cls in (mro_cls for mro_cls in get_mro_classes(bases)
if hasattr(mro_cls, attr)):
doc = getattr(getattr(mro_cls, attr), '__doc__')
if doc:
try:
attribute.__doc__ = doc
except AttributeError:
continue
break
cls = super(_CoreModelMeta, meta).__new__(meta, name, bases, attrs)
cls._add_relationship_fields()
return cls
@meta_property
def title(cls):
"""See inline documentation for ModelForm"""
return inspect(cls.Meta.model).class_.__name__
@meta_property
def title_plural(cls):
"""See inline documentation for ModelForm"""
return cls.title + "s"
@meta_property
def name(cls):
"""See inline documentation for ModelForm"""
return inspect(cls.Meta.model).class_.__name__.lower()
@meta_property
def field_names(cls):
"""
A property on the class that returns a list of field names for the
associated form.
:return: A list of all names defined in the field in the same order as
they are defined on the form.
:rtype: list of str
"""
return [field.name for field in cls()]
@six.add_metaclass(_CoreModelMeta)
class _CoreModelForm(wtforms_alchemy.ModelForm):
"""
Base class for all complex form actions. This is used instead of the usual
form class. Not to be used directly.
"""
def __init__(self, formdata=None, obj=None, *args, **kw):
self.formdata = formdata
super(_CoreModelForm, self).__init__(formdata, obj, *args, **kw)
self._obj = obj
@property
def primary_keys(self):
"""
Get a list of pairs ``name, value`` of primary key names and their
values on the current object.
"""
if self._obj is None:
raise AttributeError("No object attached")
return [(pk, getattr(self._obj, pk, None))
for pk in get_pks(self.Meta.model)]
@property
def fieldsets(self):
"""See inline documentation for ModelForm"""
default_fields = [field.name for field in self
if field.name != 'csrf_token']
return [{'title': '', 'fields': default_fields}]
def get_fieldsets(self):
"""
Get a list of all configured fieldsets, setting defaults where they are
missing.
"""
result = []
for original in self.fieldsets:
fieldset = {
'title': original.get('title', ''),
'template': original.get('template', 'horizontal'),
'fields': [getattr(self, f) for f in original['fields']],
}
result.append(fieldset)
return result
@classmethod
def _add_relationship_fields(cls):
for rel in cls._find_relationships_for_query():
if rel.direction != MANYTOONE:
continue
if not hasattr(cls, 'get_dbsession'):
raise ValueError('You need to define a get_dbsession classmethod')
def query():
session = cls.get_dbsession()
return session.query(rel.mapper.class_)
field = QuerySelectField(query_factory=query)
setattr(cls, rel.key, field)
@classmethod
def _find_relationships_for_query(cls):
if not cls.Meta.model:
return []
rels = inspect(cls.Meta.model).relationships
rels = [rel for rel in rels if rel.direction == MANYTOONE]
return rels
[docs]class CSRFForm(SecureForm):
"""
Base class from which new CSRF-protected forms are derived. Only use this
if you want to create a form without the extra model-functionality, i.e.
is normal form.
If you want to create a CSRF-protected model form use
:class:`CSRFModelForm`.
"""
[docs] def generate_csrf_token(self, csrf_context):
"""
Create a CSRF token from the given context (which is actually just a
:class:`pyramid.request.Request` instance). This is automatically
called during ``__init__``.
"""
self.request = csrf_context
return self.request.session.get_csrf_token()
[docs] def validate(self):
"""
Validate the form and with it the CSRF token. Logs a warning with the
error message and the remote IP address in case of an invalid token.
"""
result = super(CSRFForm, self).validate()
if not result and self.csrf_token.errors:
log.warn("Invalid CSRF token with error(s) '%s' from IP address "
"'%s'."
% (", ".join(self.csrf_token.errors),
self.request.client_addr))
return result
class ModelMeta(_CoreModelMeta):
def __new__(meta, name, bases, attrs):
attrs.setdefault("inlines", [])
cls = super(ModelMeta, meta).__new__(meta, name, bases, attrs)
for inline in cls.inlines:
inline._parent = cls
return cls
@six.add_metaclass(ModelMeta)
[docs]class ModelForm(_CoreModelForm):
"""
Base-class for all regular forms.
The following configuration options are available on this form in addition
to the full behavior described for `WTForms-Alchemy`_
.. _WTForms-Alchemy: https://wtforms-alchemy.readthedocs.org
.. note::
While this class can easily be the base for each form you want to
configure, it is strongly recommended to use the :class:`CSRFModelForm`
instead. It is almost no different than this form except for a new
``csrf_token`` field. Thus it should never hurt to subclass it instead
of this form.
Meta
This is the only mandatory argument. It is directly taken over from
`WTForms-Alchemy`_ so you should check out their documentation on this
class as it will provide you with a complete overview of what's
possible here.
.. _inlines:
inlines
A list of forms to use as inline forms. See :ref:`inline_forms`.
.. _fieldsets:
fieldsets
Optionally define fieldsets to group your form into categories. It
requires a list of dictionaries and in each dictionary, the following
attributes can/must be set:
* ``title``: A title to use for the fieldset. This is required but may
be the empty string (then no title is displayed).
* ``fields``: A list of field names that should be displayed together
in a fieldset. This is required.
* ``template``: The name of the fieldset template to load. This must be
the name of a file in the ``fieldsets`` directory of the current
theme **without** a file extension. It defaults to ``horizontal``
which uses bootstraps horizontal forms for each fieldset. See
:ref:`fieldset_templates` for details on available templates.
title
Set the title of your form. By default this returns the class name of
the model. It is used in different places such as the title of the
page.
title_plural:
The plural title. By default it is the title with an "s" appended,
however, you somtimes might want to override it because "Childs" just
looks stupid ;-)
name:
The name of this form. By default it uses the lowercase model class
name. This is used internally und you normally do not need to change
it.
get_dbsession:
Unfortunately, you have to define this ``classmethod`` on the form to
get support for the unique validator. It is documented in
`Unique Validator`_. This is a limitation we soon hope to overcome.
"""
@classmethod
def _relationship_key(cls, other_form):
"""
Get the name of the attribute that is the relationship between this
forms model and the model defined on another form.
By default the ``relationship_name`` attribute of ``other_form`` is
looked up and used, if it is present. Otherwise, the relationship is
determined dynamically.
:param other_form: The other form to which the relationship should be
found.
"""
# If explicitly defined, return it
if other_form.relationship_name:
return other_form.relationship_name
other_model = other_form.Meta.model
candidates = []
for relationship in inspect(cls.Meta.model).relationships:
if relationship.mapper.class_ == other_model:
candidates.append(relationship.key)
if len(candidates) == 0:
raise TypeError("Could not find relationship between the models "
"%s and %s" % (cls.Meta.model, other_model))
elif len(candidates) > 1:
raise TypeError("relationship between the models %s and %s is "
"ambigous. Please specify the "
"'relationship_name' attribute on %s"
% (cls.Meta.model, other_model, other_form))
return candidates[0]
[docs] def process(self, formdata=None, obj=None, **kwargs):
super(ModelForm, self).process(formdata, obj, **kwargs)
self.process_inline(formdata, obj, **kwargs)
[docs] def process_inline(self, formdata=None, obj=None, **kwargs):
"""
Process all inline fields. This sets the global attribute
``inline_fields`` which is a dict-like object that contains as keys
the name of all defined inline fields and as values a pair of
``inline, inline_forms`` where ``inline`` is the inline which the
name refers to and ``inline_forms`` is the list of form instances
associated with this inline.
"""
self.inline_fieldsets = OrderedDict()
for inline in self.inlines:
inline_forms = []
inline_formdata = {}
if formdata:
# create a dictionary of data by index for all existing form
# fields. It basically parses back its pattern of assigned
# names (i.e. inline.name_index_field.name).
# The created values can then be sent to the individual forms
# below based on their index.
count = int(formdata.get('%s_count' % inline.name, 0))
for index in range(count):
inline_formdata[index] = MultiDict()
for field in inline.field_names:
data = formdata.get('%s_%d_%s' % (inline.name,
index, field))
if data:
inline_formdata[index][field] = data
else:
count = None
# Find the matching relationship
# We determine this *outside* of the obj block because we want to
# raise this on the user if there is a problem ASAP.
key = self._relationship_key(inline)
# Add all existing items
if obj:
session = object_session(obj)
index = -1 # Needed in case there are no items yet
for index, item in enumerate(getattr(obj, key)):
delete_key = 'delete_%s_%d' % (inline.name, index)
if formdata and delete_key in formdata:
session.delete(item)
# make sure the list is reloaded
session.expire(obj)
else:
form = inline(inline_formdata.get(index), item)
form.is_extra = False
inline_forms.append(form)
max_index = index + 1
else:
max_index = 0
# Only show extra fields when no object is attached or the current
# form has them added.
if count is None:
if obj:
extra = 0
else:
extra = inline.extra
else:
extra = count - max_index
if formdata and 'add_%s' % inline.name in formdata:
extra += 1
# Add empty form items
for index in range(max_index, extra + max_index):
# Only add an extra field if deletion of it was not requested
delete_key = 'delete_%s_%d' % (inline.name, index)
if not formdata or delete_key not in formdata:
form = inline(inline_formdata.get(index))
form.is_extra = True
inline_forms.append(form)
# For all forms, rename them and reassign their IDs as well. Only
# by this measure can be guaranteed that each item can be addressed
# individually.
for index, form in enumerate(inline_forms):
for field in form:
field.name = "%s_%d_%s" % (inline.name, index, field.name)
field.id = field.name
self.inline_fieldsets[inline.name] = inline, inline_forms
[docs] def populate_obj(self, obj):
super(ModelForm, self).populate_obj(obj)
self.populate_obj_inline(obj)
[docs] def populate_obj_inline(self, obj):
"""
Populate all inline objects. It takes the usual ``obj`` argument that
is the **parent** of the inline fields. From these all other values
are derived and finally the objects are updated.
.. note::
Right now this assumes the relationship operation is a ``append``,
thus for example set collections won't work right now.
"""
session = object_session(obj)
for inline, forms in self.inline_fieldsets.values():
inline_model = inline.Meta.model
for index, inline_form in enumerate(forms):
# Get the primary keys from the form. This ensures that we
# update existing objects while new objects get inserted.
pks = inline.pks_from_formdata(self.formdata, index)
if pks is not None:
assert not inline_form.is_extra
inline_obj = session.query(inline.Meta.model).get(pks)
if inline_obj is None:
raise LookupError("Target with pks %s does not exist"
% str(pks))
else:
assert inline_form.is_extra
inline_obj = inline_model()
relationship_key = self._relationship_key(inline)
getattr(obj, relationship_key).append(inline_obj)
# Since the form was created like a standard form and the
# object was loaded either from the database or newly created
# and added to its associated object, we can now just populate
# it as we would do with a normal form and object.
inline_form.populate_obj(inline_obj)
[docs] def validate(self):
result = super(ModelForm, self).validate()
inline_result = self.validate_inline()
return result and inline_result
[docs] def validate_inline(self):
"""
Validate all inline forms. Implicitly called by :meth:`validate`.
This will also fill the ``form.errors`` dict with additional error
messages based on invalid inline fields using the same naming pattern
used for naming inline fields for display and form submission, i.e.
``inlinename_index_fieldname``.
Thus, if errors exist on an inline field, they can be fetched from the
global errors dict the same way regular errors are present in it.
"""
valid = True
for inline, forms in self.inline_fieldsets.values():
for index, inline_form in enumerate(forms):
if not inline_form.validate():
valid = False
for field, entry in inline_form.errors.items():
field_name = '%s_%d_%s' % (inline.name, index, field)
self.errors[field_name] = entry
return valid
@six.add_metaclass(_CoreModelMeta)
[docs]class BaseInLine(_CoreModelForm):
"""
Base-class for all inline forms. You normally don't subclass from this
directly unless you want to create a new inline type. However, all
inline types share the attributes inherited by this template.
Inline forms are forms that are not intended to be displayed by themselves
but instead are added to the :ref:`inlines <inlines>` attribute of a normal
form. They will then be displayed inside the normal form while editing,
allowing for multiple instance to be added, deleted or modified at the same
time. They are heavily inspired by Django's inline forms.
An inline form is configurable with the following attributes, additionally
to any attribute provided by `WTForms-Alchemy`_
.. _WTForms-Alchemy: https://wtforms-alchemy.readthedocs.org
Meta
This is the standard `WTForms-Alchemy` attribute to configure the
model. Check out their documentation for specific details.
relationship_name
The name of the *other side* of the relationship. Determined
automatically, unless there are multiple relationships between the
models in which case this must be overridden by the subclass.
For example: If this is the child form to be inlined, the other side
might be called ``children`` and this might be called ``parent`` (or
it might not even exist, there is no need for a bidrectional
relationship). The correct value would then be ``children`` *not*
``parent``.
extra
How many empty fields to display in which new objects can be added. Pay
attention that often fields require intputs and thus extra field may
often not be left empty. This is an intentional restriction to allow
client-side validation without javascript. So only specify this if you
are sure that items will always be added (note, however, that the extra
attribute is not used to enforce a minimum number of members in the
database). Defaults to ``0``.
is_extra
A boolean indicating whether this instance is an extra field or a
persisted database field. Set during parent's processing.
"""
extra = 0
relationship_name = None
@classmethod
[docs] def pks_from_formdata(cls, formdata, index):
"""
Get a list of primary key values in the order of the primary keys on
the model. The returned value is suitable to be passed to
:meth:`sqlalchemy.orm.query.Query.get`.
:param formdata: A :class:`webob.multidict.MultiDict` that contains all
parameters that were passed to the form.
:param index: The index of the element for which the primary key is
desired. From this, the correct field name to get from ``fromdata``
is determined.
:type index: int
:return: A tuple of primary keys that uniquely identify the object in
the database. The order is based on the order of primary keys in
the table as reported by SQLAlchemy.
:rtype: tuple
"""
pks = []
for pk in get_pks(cls.Meta.model):
key = '%s_%d_%s' % (cls.name, index, pk)
pk_val = formdata.get(key)
if pk_val is None or pk_val == '':
return
pk_val = int(pk_val)
pks.append(pk_val)
return tuple(pks)
@classmethod
def _find_relationships_for_query(cls):
# Prevent parent from being displayed inline
rels = _CoreModelForm._find_relationships_for_query()
if not rels:
return []
inline_key = cls._parent._relationship_key(cls)
rels = [rel for rel in rels if rel.back_populates != inline_key]
return rels
[docs]class TabularInLine(BaseInLine):
"""
A base class for a tabular inline display. Each row is displayed in a
table row with the field labels being displayed in the table head. This is
basically a list view of the fields only that you can edit and delete them
and even insert new ones.
"""
#: The default template for a tabular display. It gets resolved by
#: :meth:`CRUDView.get_template <pyramid_crud.views.CRUDView.get_template>`
#: and should usually not need changing here.
template = 'edit_inline/tabular'
[docs]class CSRFModelForm(ModelForm, CSRFForm):
"""
A form that adds a CSRF token to the form. Derive from this class for
security critical operations (read: you want it most of the time and it
doesn't hurt).
Do not derive from this for inline stuff and other composite forms: Only
the main form should use this as you only need one token per request.
All configuration is done exactly in the same way as with the
:class:`.ModelForm` except for one difference: An additional
``csrf_context`` argument is required. The pre-configured views and
templates already know how to utilize this field and work fine with
and without it.
"""
# Developer Note: This form works through multiple inheritance. But the
# CSRFForm is not a typical mixin, it derives from the Form class whereas
# ModelForm also derives from it. As a result, Python's C3 implementation
# resolves this a bit unintuitively. However, this actually saves as: The
# calling goes up to the wtforms_alchemy.ModelForm but then, instead of
# going to wtforms.Form, it goes to CSRFForm. Thus, as long as the parent
# is always called with super() throughout the inheritance chain, this
# works without needing to implement a custom __init__ that merges both
# forms.