Friday, 9 November 2012

Manipulating zope.schema field's properties dynamically

Why do we want to do that in first place? Lets consider an use-case where we get loginid and user's name from an AddForm. But, in EditForm we don't want the user to change the login ID. One way to implement this, is to, omit the loginid field in the edit form's form_fields attribute. Suppose we want to display the loginid as read only field in EditForm alone, then we need to manipulate the schema field's default properties.

from zope import interface, schema

class IUser(interface.Interface):
    loginid = schema.BytesLine(title=u"Login ID", required=True)
    name = schema.TextLine(title=u"User name", required=True)


The zope.schema allows us to set a property named readonly to schema fields in the interface. For our case, if we specify them in the interface we can't be able to get the loginid from the user in AddForm. So, we are going to manipulate this at runtime in EditForm.

Let's see its implementation using Grok

import grok

class EditUser(grok.EditForm):
    grok.context(IUser)
    grok.name('edit')
    form_fields = grok.AutoFields(IUser)

    def setUpWidgets(self, ignore_request=False):
        # This method is responsible for constructing the widgets from
        # form_fields. So, we override this method to manipulate the
        # schema field
        self.form_fields['loginid'].field.readonly = True
        super(EditUser, self).setUpWidgets(ignore_request)
        # Once we change the field's properties, its effect will be
        # seen across forms. i.e even the AddForm after viewing an
        # EditForm will have the 'loginid' as readonly field. So, we
        # revert them back to their originals
        self.form_fields['loginid'].field.readonly = False

    @grok.action('Save')
    def edit(self, **data):
        self.applyData(self.context, **data)
        self.redirect(self.url(self.context.__parent__, 'list'))


Adding custom validation in zope.formlib


If you are using ZTK, you would be familiar with zope.formlib and zope.schema libraries. These libraries provide a powerful mechanism to generate CRUD forms. In this, basic validation options can be specified in the interface via schema fields and by the constraint and invariant functions. I am not going to dive into those details as the documentation for it already exists on the Internet.

Let us consider a trivial example using Grok, in which, we shall try to check the validity of a form field

import grok
from zope import interface, schema

class IUser(interface.Interface):
    id = schema.BytesLine(title=u'User ID', required=True)

class AddUser(grok.AddForm):
    grok.name('add_user')
    form_fields = grok.AutoFields(IUser)
    
    @grok.action('Add')
    def add(self, **data):
        user = User()
        self.applyData(user, **data)
        self.context.add(user)
        self.redirect(self.url(self.context, 'list'))

The context for AddUser and the add() method of it are left to the reader's imagination ;)

As per the schema definition, zope.formlib will not allow the User's 'id' field in the add form to be empty. But, unfortunately it will accept spaces as a valid entry. This can be validated by adding an invariant to check the field's striped value. But, for the sake of simplicity we will use this example to enforce our custom validation.

Under the hood


Form's input and display elements are represented by formlib's widgets. There are default set of widgets associated with each schema field. On form submission, formlib tries to get the values from these widgets during which error checking is done based on the schema field's definition and then invariant validation is done. For our case, it checks whether the 'id' field is empty or not. If it is empty, ValidationError is raised. This ValidationError in turn is converted to WidgetInputError and appended to an error-list. Thereby, this error-list contains the list of all validation errors found in the form. An empty list indicates that there were no errors.
Error categories
If the form contains errors, the input fields will not be cleared and the error messages will be displayed. The errors are divided into two categories
  1. Widget level errors, which are associated with each widget and displayed along with them.
  2. Top level errors, which are usually displayed on the top of the form just below the status message. It contains both the invariant error messages and widget error messages.
Displaying top-level errors in form
In order to display the top-level error messages, formlib takes each error in the error-list displays them, as it is, if it is a string. If the error is not of type string, it gets the MultiAdapter view for the browser request and error to the interface IWidgetInputErrorView.

view = component.getMultiAdapter((error, self.request), 
                                 IWidgetInputError)

The error is displayed by calling the snippet() method on this view, which in turn, returns the error message as HTML snippet. 
Displaying widget-level errors in form
Widgets on the other hand, have an error state, which can be rendered in a form using its error() method. Whenever the error() method is called, it checks its internal error state, if there is an error it gets the multi-adapter view as mentioned above and displays the error by calling the snippet() method.

Implementation


As there is already an InvalidErrorView object which implements IWidgetInputErrorView and adapts interface.Invalid and IBrowserRequest, we shall replace the Widget's error() method with a callable error string for our induced errors.

Enough of Zope's internals, lets override the validate method of zope.formlib's FormBase class and write a helper function to set the error.

def set_form_error(widgets, wgt_name, emsg, elist):
    wgt = widgets.get(wgt_name)
    if wgt is None:
        raise interface.Invalid("Invalid widget (%s)" % (wgt_name,))
    if isinstance(elist, list) is False:
        raise interface.Invalid("Error list invalid")

    # We override the Widget's 'error' method
    wgt.error = lambda : emsg
    # Here we append the error message as string in the list.
    # As this corresponds to top-level error messages we
    # mention the widget's label in it so that the error
    # message can be meaningful.
    elist.append("%s: %s" % (wgt.label, emsg))

class AddUser(grok.AddForm):
    grok.name('add_user')
    form_fields = grok.AutoFields(IUser)

    def validate(self, action, data):
        ret = super(AddUser, self).validate(action, data)
        # If the form has basic errors we just display them
        if len(ret) != 0: return ret
        # If the form has no errors we do additional validation
        data['id'] = data['id'].strip()
        if len(data['id']) == 0:
            # Here we induce our custom error
            set_form_error(self.widgets, 'id', 'Invalid', ret)
        return ret

    @grok.action('Add')
    def add(self, **data):
        user = User()
        self.applyData(user, **data)
        self.context.add(user)
        self.redirect(self.url(self.context, 'list'))
 
I guess, the above piece of code is fairly simple now.