web/form.py
author Sylvain Thénault <sylvain.thenault@logilab.fr>
Wed, 09 Dec 2015 17:44:16 +0100
changeset 10999 adc46197b8c9
parent 10907 9ae707db5265
permissions -rw-r--r--
[migration] test addition of a cube providing a custom final type This does not work yet. Related to #7569998

# copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
#
# This file is part of CubicWeb.
#
# CubicWeb is free software: you can redistribute it and/or modify it under the
# terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option)
# any later version.
#
# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
"""abstract form classes for CubicWeb web client"""
__docformat__ = "restructuredtext en"

from warnings import warn

from six import add_metaclass

from logilab.common.decorators import iclassmethod
from logilab.common.deprecation import deprecated

from cubicweb.appobject import AppObject
from cubicweb.view import NOINDEX, NOFOLLOW
from cubicweb.web import httpcache, formfields, controller, formwidgets as fwdgs

class FormViewMixIn(object):
    """abstract form view mix-in"""
    category = 'form'
    http_cache_manager = httpcache.NoHTTPCacheManager
    add_to_breadcrumbs = False

    def html_headers(self):
        """return a list of html headers (eg something to be inserted between
        <head> and </head> of the returned page

        by default forms are neither indexed nor followed
        """
        return [NOINDEX, NOFOLLOW]

    def linkable(self):
        """override since forms are usually linked by an action,
        so we don't want them to be listed by appli.possible_views
        """
        return False


###############################################################################

class metafieldsform(type):
    """metaclass for FieldsForm to retrieve fields defined as class attributes
    and put them into a single ordered list: '_fields_'.
    """
    def __new__(mcs, name, bases, classdict):
        allfields = []
        for base in bases:
            if hasattr(base, '_fields_'):
                allfields += base._fields_
        clsfields = (item for item in classdict.items()
                     if isinstance(item[1], formfields.Field))
        for fieldname, field in sorted(clsfields, key=lambda x: x[1].creation_rank):
            if not field.name:
                field.set_name(fieldname)
            allfields.append(field)
        classdict['_fields_'] = allfields
        return super(metafieldsform, mcs).__new__(mcs, name, bases, classdict)


class FieldNotFound(Exception):
    """raised by field_by_name when a field with the given name has not been
    found
    """

@add_metaclass(metafieldsform)
class Form(AppObject):
    __registry__ = 'forms'

    parent_form = None
    force_session_key = None
    domid = 'form'
    copy_nav_params = False
    control_fields = set( ('__form_id', '__errorurl', '__domid',
                           '__redirectpath', '_cwmsgid',
                           ) )

    def __init__(self, req, rset=None, row=None, col=None,
                 submitmsg=None, mainform=True, **kwargs):
        # process kwargs first so we can properly pass them to Form and match
        # order expectation (ie cw_extra_kwargs populated almost first)
        hiddens, extrakw = self._process_kwargs(kwargs)
        # now call ancestor init
        super(Form, self).__init__(req, rset=rset, row=row, col=col, **extrakw)
        # then continue with further specific initialization
        self.fields = list(self.__class__._fields_)
        for key, val in hiddens:
            self.add_hidden(key, val)
        if mainform:
            formid = kwargs.pop('formvid', self.__regid__)
            self.add_hidden(u'__form_id', formid)
            self._posting = self._cw.form.get('__form_id') == formid
        if mainform:
            self.add_hidden(u'__errorurl', self.session_key())
            self.add_hidden(u'__domid', self.domid)
            self.restore_previous_post(self.session_key())
        # XXX why do we need two different variables (mainform and copy_nav_params ?)
        if self.copy_nav_params:
            for param in controller.NAV_FORM_PARAMETERS:
                if not param in kwargs:
                    value = req.form.get(param)
                    if value:
                        self.add_hidden(param, value)
        if submitmsg is not None:
            self.set_message(submitmsg)

    def _process_kwargs(self, kwargs):
        hiddens = []
        extrakw = {}
        # search for navigation parameters and customization of existing
        # attributes; remaining stuff goes in extrakwargs
        for key, val in kwargs.items():
            if key in controller.NAV_FORM_PARAMETERS:
                hiddens.append( (key, val) )
            elif key == 'redirect_path':
                hiddens.append( (u'__redirectpath', val) )
            elif hasattr(self.__class__, key) and not key[0] == '_':
                setattr(self, key, val)
            else:
                extrakw[key] = val
        return hiddens, extrakw

    def set_message(self, submitmsg):
        """sets a submitmsg if exists, using _cwmsgid mechanism """
        cwmsgid = self._cw.set_redirect_message(submitmsg)
        self.add_hidden(u'_cwmsgid', cwmsgid)

    @property
    def root_form(self):
        """return the root form"""
        if self.parent_form is None:
            return self
        return self.parent_form.root_form

    @property
    def form_valerror(self):
        """the validation error exception if any"""
        if self.parent_form is None:
            # unset if restore_previous_post has not be called
            return getattr(self, '_form_valerror', None)
        return self.parent_form.form_valerror

    @property
    def form_previous_values(self):
        """previously posted values (on validation error)"""
        if self.parent_form is None:
            # unset if restore_previous_post has not be called
            return getattr(self, '_form_previous_values', {})
        return self.parent_form.form_previous_values

    @property
    def posting(self):
        """return True if the form is being posted, False if it is being
        generated.
        """
        # XXX check behaviour on regeneration after error
        if self.parent_form is None:
            return self._posting
        return self.parent_form.posting

    @iclassmethod
    def _fieldsattr(cls_or_self):
        if isinstance(cls_or_self, type):
            fields = cls_or_self._fields_
        else:
            fields = cls_or_self.fields
        return fields

    @iclassmethod
    def field_by_name(cls_or_self, name, role=None):
        """Return field with the given name and role.

        Raise :exc:`FieldNotFound` if the field can't be found.
        """
        for field in cls_or_self._fieldsattr():
            if field.name == name and field.role == role:
                return field
        raise FieldNotFound(name, role)

    @iclassmethod
    def fields_by_name(cls_or_self, name, role=None):
        """Return a list of fields with the given name and role."""
        return [field for field in cls_or_self._fieldsattr()
                if field.name == name and field.role == role]

    @iclassmethod
    def remove_field(cls_or_self, field):
        """Remove the given field."""
        cls_or_self._fieldsattr().remove(field)

    @iclassmethod
    def append_field(cls_or_self, field):
        """Append the given field."""
        cls_or_self._fieldsattr().append(field)

    @iclassmethod
    def insert_field_before(cls_or_self, field, name, role=None):
        """Insert the given field before the field of given name and role."""
        bfield = cls_or_self.field_by_name(name, role)
        fields = cls_or_self._fieldsattr()
        fields.insert(fields.index(bfield), field)

    @iclassmethod
    def insert_field_after(cls_or_self, field, name, role=None):
        """Insert the given field after the field of given name and role."""
        afield = cls_or_self.field_by_name(name, role)
        fields = cls_or_self._fieldsattr()
        fields.insert(fields.index(afield)+1, field)

    @iclassmethod
    def add_hidden(cls_or_self, name, value=None, **kwargs):
        """Append an hidden field to the form. `name`, `value` and extra keyword
        arguments will be given to the field constructor. The inserted field is
        returned.
        """
        kwargs.setdefault('ignore_req_params', True)
        kwargs.setdefault('widget', fwdgs.HiddenInput)
        field = formfields.StringField(name=name, value=value, **kwargs)
        if 'id' in kwargs:
            # by default, hidden input don't set id attribute. If one is
            # explicitly specified, ensure it will be set
            field.widget.setdomid = True
        cls_or_self.append_field(field)
        return field

    def session_key(self):
        """return the key that may be used to store / retreive data about a
        previous post which failed because of a validation error
        """
        if self.force_session_key is None:
            return '%s#%s' % (self._cw.url(), self.domid)
        return self.force_session_key

    def restore_previous_post(self, sessionkey):
        # get validation session data which may have been previously set.
        # deleting validation errors here breaks form reloading (errors are
        # no more available), they have to be deleted by application's publish
        # method on successful commit
        forminfo = self._cw.session.data.pop(sessionkey, None)
        if forminfo:
            self._form_previous_values = forminfo['values']
            self._form_valerror = forminfo['error']
            # if some validation error occurred on entity creation, we have to
            # get the original variable name from its attributed eid
            foreid = self.form_valerror.entity
            for var, eid in forminfo['eidmap'].items():
                if foreid == eid:
                    self.form_valerror.eid = var
                    break
            else:
                self.form_valerror.eid = foreid
        else:
            self._form_previous_values = {}
            self._form_valerror = None

    def field_error(self, field):
        """return field's error if specified in current validation exception"""
        if self.form_valerror:
            if field.eidparam and self.edited_entity.eid != self.form_valerror.eid:
                return None
            try:
                return self.form_valerror.errors.pop(field.role_name())
            except KeyError:
                if field.role and field.name in self.form_valerror:
                    warn('%s: errors key of attribute/relation should be suffixed by "-<role>"'
                         % self.form_valerror.__class__, DeprecationWarning)
                    return self.form_valerror.errors.pop(field.name)
        return None

    def remaining_errors(self):
        return sorted(self.form_valerror.errors.items())