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)
[docs] def setItemParent(self, itemParent): self.itemParent = itemParent
key = property(lambda self: itemKey(self))
[docs] def makeWidget(self): return self.widgetFactory(self.type)
[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 addGroup(self, *a, **k): return self.add(Group(*a, form=self.form, **k))
[docs] def addField(self, *a, **k): return self.add(Field(*a, form=self.form, **k))
[docs] def setItemParent(self, itemParent): self.itemParent = itemParent
[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 addField(self, *a, **k): return self.add(Field(*a, form=self, **k))
[docs] def addGroup(self, *a, **k): return self.add(Group(*a, form=self, **k))
[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 add(self, error): self.errors.append(error)
[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
[docs] def getFormErrors(self): return self.errors
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] @template.renderer def form(self, name): return self.__behaviour().form(name)
[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( IKnownForms ) class KnownForms(dict): pass
[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