Source code for gavo.formal.form
"""
Form implementation and high-level renderers.
"""
import weakref
from zope.interface import Interface
from twisted.internet import defer
from twisted.python.components import registerAdapter
from twisted.web import iweb
from twisted.web import resource
from twisted.web import server
from twisted.web import template
from twisted.web.template import tags as T
from zope.interface import implementer
from . import iformal, util, validation, nevowc
FORMS_KEY = b'__nevow_form__'
[docs]class Action(object):
"""Tracks an action that has been added to a form.
"""
def __init__(self, callback, name, validate, label):
if not util.validIdentifier(name):
import warnings
warnings.warn('[0.9] Invalid action name %r. This will become an error in the future.' %
name, FutureWarning, stacklevel=3)
self.callback = callback
self.name = name
self.validate = validate
if label is None:
self.label = util.titleFromName(name)
else:
self.label = label
[docs]def itemKey(item):
"""
Build the form item's key. This currently always is the item name.
"""
return item.name
# The original formal code included ancestor names. We don't
# want this in DaCHS since our parameter names may be important (e.g.
# in VO protocols we're funneling through the formal parsers).
[docs]class Field(object):
itemParent = None
def __init__(self, name, type, widgetFactory=None, label=None,
description=None, cssClass=None, form=None, default=None):
if not util.validIdentifier(name):
raise ValueError('%r is an invalid field name'%name)
if label is None:
label = util.titleFromName(name)
if widgetFactory is None:
widgetFactory = iformal.IWidget
self.name = name
self.type = type
self.widgetFactory = widgetFactory
self.label = label
self.description = description
self.cssClass = cssClass
self.default = default
# form can already be a weakproxy if we're a child of a group or so
if form is None:
# for testing only
self.form = None
elif isinstance(form, weakref.ProxyType):
self.form = form
else:
self.form = weakref.proxy(form)
key = property(lambda self: itemKey(self))
[docs] def process(self, request, form, args, errors):
# If the type is immutable then copy the original value to args in case
# another validation error causes this field to be re-rendered.
if self.type.immutable:
args[self.key] = form.data.get(self.key)
return
# Process the input using the widget, storing the data back on the form.
try:
if self.default is not None:
form.data[self.key] = self.makeWidget(
).processInput(request, self.key, args, self.default)
else:
form.data[self.key] = self.makeWidget(
).processInput(request, self.key, args)
except validation.FieldError as e:
if e.fieldName is None:
e.fieldName = self.key
errors.add(e)
[docs]@implementer(iweb.IRenderable)
class FieldFragment(nevowc.CommonRenderers, template.Element):
loader = template.TagLoader(
T.div(id=template.slot('fieldId'), class_=template.slot('cls'),
render='field')[
T.label(class_='label', for_=template.slot('id'))[template.slot('label')],
T.div(class_='inputs')[template.slot('inputs')],
template.slot('description'),
template.slot('message'),
])
hiddenLoader = template.TagLoader(
T.transparent(render='field')[template.slot('inputs')])
def __init__(self, field):
self.fieldInstance = field
# Nasty hack to work out if this is a hidden field. Keep the widget
# for later anyway.
self.widget = field.makeWidget()
if getattr(self.widget, 'inputType', None) == 'hidden':
self.loader = self.hiddenLoader
[docs] @template.renderer
def field(self, request, tag):
# The field we're rendering
field = self.fieldInstance
formData = self.fieldInstance.form.data
formErrors = self.fieldInstance.form.errors
# Find any error
if formErrors:
error = formErrors.getFieldError(field.key)
# field.render wants almost unprocessed requests.args if
# there was an error.
formData = util.CaseSemisensitiveDict(
[(k.decode("utf-8", "ignore"),v)
for k,v in request.args.items()])
else:
error = None
# Build the error message
if error is None:
message = ''
else:
message = T.div(class_='message')[error.message]
# Create the widget (it's created in __init__ as a hack)
widget = self.widget
# Build the list of CSS classes
classes = [
'field',
field.type.__class__.__name__.lower(),
widget.__class__.__name__.lower(),
]
if field.type.required:
classes.append('required')
if field.cssClass:
classes.append(field.cssClass)
if error:
classes.append('error')
# Create the widget and decide the method that should be called
if field.type.immutable:
render = widget.renderImmutable
else:
render = widget.render
# Fill the slots
tag.slotData = {}
tag.fillSlots(id=util.render_cssid(field.key),
fieldId=[util.render_cssid(field.key), '-field'],
cls=' '.join(classes),
label=field.label,
inputs=render(request, field.key, formData,
formErrors),
message=message,
description=T.div(class_='description')[field.description or ''])
return tag(render="mapping")
registerAdapter(FieldFragment, Field, iweb.IRenderable)
[docs]class AddHelperMixin(object):
"""
A mixin that provides methods for common uses of add(...).
"""
def __getitem__(self, items):
"""
Overridden to allow stan-style construction of forms.
"""
# Items may be a list or a scalar so stick a scalar into a list
# immediately to simplify the code.
try:
items = iter(items)
except TypeError:
items = [items]
# Add each item
for item in items:
self.add(item)
# Return myself
return self
[docs]class Group(object):
itemParent = None
def __init__(self, name, label=None, description=None, cssClass=None,
form=None):
if label is None:
label = util.titleFromName(name)
self.name = name
self.label = label
self.description = description
self.cssClass = cssClass
self.items = FormItems(self)
# Forward to FormItems methods
self.add = self.items.add
self.getItemByName = self.items.getItemByName
self.form = weakref.proxy(form)
key = property(lambda self: itemKey(self))
[docs] def process(self, request, form, args, errors):
for item in self.items:
item.process(request, form, args, errors)
[docs]class GroupFragment(template.Element):
loader = template.TagLoader(
T.fieldset(id=template.slot('id'), class_=template.slot('cssClass'),
render='_group')[
T.legend[template.slot('label')],
T.div(class_='description')[template.slot('description')],
template.slot('items'),
]
)
def __init__(self, group):
super(GroupFragment, self).__init__()
self.group = group
@template.renderer
def _group(self, request, tag):
# Get a reference to the group, for simpler code.
group = self.group
# Build the CSS class string
cssClass = ['group']
if group.cssClass is not None:
cssClass.append(group.cssClass)
cssClass = ' '.join(cssClass)
# Fill the slots
tag.fillSlots(
id=util.render_cssid(group.key),
cssClass=cssClass,
label=group.label,
description=group.description or '',
items=[iweb.IRenderable(item) for item in
group.items])
return tag
registerAdapter(GroupFragment, Group, iweb.IRenderable)
[docs]@implementer( iformal.IForm )
class Form(AddHelperMixin, object):
callback = None
actions = None
def __init__(self, callback=None):
if callback is not None:
self.callback = callback
self.data = {}
self.items = FormItems(None)
self.errors = FormErrors()
# Forward to FormItems methods
self.add = self.items.add
self.getItemByName = self.items.getItemByName
self.actionMaterial = None
[docs] def addAction(self, callback, name="submit", validate=True, label=None):
if self.actions is None:
self.actions = []
if name in [action.name for action in self.actions]:
raise ValueError('Action with name %r already exists.' % name)
self.actions.append( Action(callback, name, validate, label) )
[docs] def process(self, request):
charset = 'utf-8'
# Get the request args and decode the arg names
args = util.CaseSemisensitiveDict(
[(k.decode(charset),v) for k,v in request.args.items()])
# Find the callback to use, defaulting to the form default
callback, validate = self.callback, True
if self.actions is not None:
for action in self.actions:
if action.name in args:
# Remove it from the data
args.pop(action.name)
# Remember the callback and whether to validate
callback, validate = action.callback, action.validate
break
# IE does not send a button name in the POST args for forms containing
# a single field when the user presses <enter> to submit the form. If
# we only have one possible action then we can safely assume that's the
# action to take.
#
# If there are 0 or 2+ actions then we can't assume anything because we
# have no idea what order the buttons are on the page (someone might
# have altered the DOM using JavaScript for instance). In that case
# throw an error and make it a problem for the developer.
if callback is None:
if self.actions is None or len(self.actions) != 1:
raise Exception('The form has no callback and no action was found.')
else:
callback, validate = self.actions[0].callback, \
self.actions[0].validate
# Remember the args in case validation fails.
self.errors.data = args
# Iterate the items and collect the form data and/or errors.
for item in self.items:
item.process(request, self, args, self.errors)
if self.errors and validate:
return self.errors
d = defer.maybeDeferred(callback, request, self, self.data)
d.addErrback(self._cbFormProcessingFailed, request)
return d
def _cbFormProcessingFailed(self, failure, request):
failure.trap(validation.FormError, validation.FieldError)
self.errors.add(failure.value)
return self.errors
[docs]class FormItems(object):
"""
A managed collection of form items.
"""
def __init__(self, itemParent):
self.items = []
self.itemParent = itemParent
def __iter__(self):
return iter(self.items)
[docs] def add(self, item):
# Check the item name is unique
if item.name in [i.name for i in self.items]:
raise ValueError('Item named %r already added to %r' %
(item.name, self))
# Add to child items and set self the parent
self.items.append(item)
item.setItemParent(self.itemParent)
return item
[docs] def getItemByName(self, name):
# since we have flat names in DaCHS, we need to look
# into each subordinate container. Original formal
# had hierarchical names for that.
for item in self.items:
if item.name==name:
return item
try:
return item.getItemByName(name)
except (AttributeError, KeyError):
# child either is no container or doesn't have the item
pass
raise KeyError("No item called %r" % name)
[docs]@implementer( iformal.IFormErrors )
class FormErrors(object):
def __init__(self):
self.errors = []
[docs] def getFieldError(self, name):
fieldErrors = [e for e in self.errors if isinstance(e, validation.FieldError)]
for error in fieldErrors:
if error.fieldName == name:
return error
def __bool__(self):
return len(self.errors) != 0
[docs]class FormsResourceBehaviour(object):
"""
I provide the IResource behaviour needed to process and render a page
containing a Form.
"""
def __init__(self, **k):
parent = k.pop('parent')
super(FormsResourceBehaviour, self).__init__(**k)
self.parent = parent
self.forms = {}
[docs] def runAction(self, request, formName):
form = self.locateForm(request, formName)
return self._processForm(form, request)
[docs] @template.renderer
def form(self, name):
def render(request, tag):
form = self.locateForm(request, name)
# put in the proper defaults from request arguments
# so people can bookmark forms; we're not interested
# in validation problems for them, so errors is
# just something with an add method.
ignoredErrors = set()
args = util.CaseSemisensitiveDict((k.decode("utf-8"), v)
for k,v in request.args.items())
for key, value in args.items():
try:
form.getItemByName(key
).process(
request, form, args, ignoredErrors)
except Exception:
# don't fail on extra or bad input
pass
# Create a keyed tag that will render the form when flattened.
tag = T.transparent(key=name)[iweb.IRenderable(form)]
return tag
return render
def _processForm(self, form, request):
d = defer.succeed(request)
d.addCallback(form.process)
return d
[docs] def locateForm(self, request, name):
"""Locate a form by name.
Initially, forms are located by looking for a form_<name>
attribute in our parent. Once a form has been found, we cache
it in request.
This ensures that the form that is located during form processing
will be the same instance that is located when a form is rendered
after validation failure.
"""
if not hasattr(request, "formal_forms"):
request.formal_forms = {}
form = request.formal_forms.get(name)
if form is not None:
return form
factory = self.parent
form = factory.formFactory(request, name)
if form is None:
raise Exception('Form %r not found'%name)
form.name = name
request.formal_forms[name] = form
return form
[docs]class ResourceWithForm(nevowc.TemplatedPage):
"""A t.w Resource with a template that has one or more forms.
To handle serious errors occurring during form processing,
override the crash(failure, request) method. More benign
errors are handled through form errors are and being rendered
into the normal form.
By default, GET requests do not run actions. If your
actions don't change state, you should be ok with setting
a class variable processOnGET, though.
"""
# You'll probably want to override the crash(failure, request) method...
__formsBehaviour = None
processOnGET = False
def __behaviour(self):
if self.__formsBehaviour is None:
self.__formsBehaviour = FormsResourceBehaviour(parent=self)
return self.__formsBehaviour
[docs] def render(self, request, customCallback=None):
def gotResult(result):
if isinstance(result, resource.Resource):
res = result.render(request)
if res==server.NOT_DONE_YET:
return res
else:
request.finish()
return server.NOT_DONE_YET
else:
return super(ResourceWithForm, self).render_POST(request)
formName = request.args.pop(FORMS_KEY, [b""])[0].decode("utf-8")
if formName and (request.method==b"POST" or self.processOnGET):
d = defer.maybeDeferred(
self.__behaviour().runAction, request, formName)
d.addCallback(customCallback or gotResult)
d.addErrback(self.crash, request)
else:
return super(ResourceWithForm, self).render_POST(request)
return server.NOT_DONE_YET
[docs] def crash(self, failure, request):
# the following is just for simpler trial operation; comment out
# in production
# import sys; failure.printTraceback(file=sys.stdout)
request.setResponseCode(500)
request.setHeader("content-type", "text/plain")
request.write(b"Unhandled exception while handling the form:\n\n")
request.write((failure.getErrorMessage()+"\n\n").encode("utf-8"))
request.write(b"You will probably want to complain to the operators.\n")
request.write(b"If you *are* the operator, override"
b" the page.crash(failure, request) method.")
request.finish()
return server.NOT_DONE_YET
[docs] def formFactory(self, request, name):
factory = getattr(self, 'form_%s'%name, None)
if factory is not None:
return factory(request)
s = super(ResourceWithForm, self)
if hasattr(s,'formFactory'):
return s.formFactory(request, name)
class IKnownForms(Interface):
"""Marker interface used to locate a dict instance containing the named
forms we know about during this request.
"""
[docs]@implementer(iweb.IRenderable)
class FormRenderer(nevowc.CommonRenderers):
loader = template.TagLoader(
T.form(**{'id': template.slot('formName'), 'action': template.slot('formAction'),
'class': 'nevow-form', 'method': 'post', 'enctype':
'multipart/form-data', 'accept-charset': 'utf-8'})[
T.div[
T.input(type='hidden', name='_charset_'),
T.input(type='hidden', name=FORMS_KEY, value=template.slot('formName')),
template.slot('formErrors'),
template.slot('formItems'),
T.div(class_='actions')[
template.slot('formActions'),
],
],
]
)
def __init__(self, original, *a, **k):
super(FormRenderer, self).__init__(*a, **k)
self.original = original
[docs] def render(self, request):
data = self.original.data
tag = T.transparent[self.loader.load()](render="mapping")
tag.fillSlots(
formName=self.original.name,
formAction=request.path,
formErrors=self._renderErrors(request, data),
formItems=self._renderItems(request, data),
formActions=self._renderActions(request, data))
return tag
def _renderErrors(self, request, data):
if not self.original.errors:
return ''
errors = self.original.errors.getFormErrors()
errorList = T.ul()
for error in errors:
if isinstance(error, validation.FormError):
errorList[ T.li[ error.message ] ]
for error in errors:
if isinstance(error, validation.FieldError):
item = self.original.getItemByName(error.fieldName)
errorList[ T.li[ T.strong[ item.label, ' : ' ], error.message ] ]
return T.div(class_='errors')[ T.p['Please correct the following error(s):'], errorList ]
def _renderItems(self, request, data):
if self.original.items is None:
yield ''
return
for item in self.original.items:
yield iweb.IRenderable(item)
def _renderActions(self, request, data):
if self.original.actions is None:
yield ''
return
for action in self.original.actions:
yield self._renderAction(request, action)
if self.original.actionMaterial:
yield self.original.actionMaterial
def _renderAction(self, request, data):
return T.input(type='submit', id='%s-action-%s'%(self.original.name, data.name), name=data.name, value=data.label)
registerAdapter(FormRenderer, Form, iweb.IRenderable)
# vi:et:sw=4:sta