cubicweb/web/form.py
changeset 11057 0b59724cb3f2
parent 10907 9ae707db5265
child 11767 432f87a63057
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cubicweb/web/form.py	Sat Jan 16 13:48:51 2016 +0100
@@ -0,0 +1,284 @@
+# 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())