diff -r 058bb3dc685f -r 0b59724cb3f2 cubicweb/web/views/forms.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cubicweb/web/views/forms.py Sat Jan 16 13:48:51 2016 +0100 @@ -0,0 +1,483 @@ +# copyright 2003-2014 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 . +""" +Base form classes +----------------- + +.. Note: + + Form is the glue that bind a context to a set of fields, and is rendered + using a form renderer. No display is actually done here, though you'll find + some attributes of form that are used to control the rendering process. + +Besides the automagic form we'll see later, there are roughly two main +form classes in |cubicweb|: + +.. autoclass:: cubicweb.web.views.forms.FieldsForm +.. autoclass:: cubicweb.web.views.forms.EntityFieldsForm + +As you have probably guessed, choosing between them is easy. Simply ask you the +question 'I am editing an entity or not?'. If the answer is yes, use +:class:`EntityFieldsForm`, else use :class:`FieldsForm`. + +Actually there exists a third form class: + +.. autoclass:: cubicweb.web.views.forms.CompositeForm + +but you'll use this one rarely. +""" + +__docformat__ = "restructuredtext en" + + +import time +import inspect + +from six import text_type + +from logilab.common import dictattr, tempattr +from logilab.common.decorators import iclassmethod, cached +from logilab.common.textutils import splitstrip + +from cubicweb import ValidationError, neg_role +from cubicweb.predicates import non_final_entity, match_kwargs, one_line_rset +from cubicweb.web import RequestError, ProcessFormError +from cubicweb.web import form +from cubicweb.web.views import uicfg +from cubicweb.web.formfields import guess_field + + +class FieldsForm(form.Form): + """This is the base class for fields based forms. + + **Attributes** + + The following attributes may be either set on subclasses or given on + form selection to customize the generated form: + + :attr:`needs_js` + sequence of javascript files that should be added to handle this form + (through :meth:`~cubicweb.web.request.Request.add_js`) + + :attr:`needs_css` + sequence of css files that should be added to handle this form (through + :meth:`~cubicweb.web.request.Request.add_css`) + + :attr:`domid` + value for the "id" attribute of the
tag + + :attr:`action` + value for the "action" attribute of the tag + + :attr:`onsubmit` + value for the "onsubmit" attribute of the tag + + :attr:`cssclass` + value for the "class" attribute of the tag + + :attr:`cssstyle` + value for the "style" attribute of the tag + + :attr:`cwtarget` + value for the "target" attribute of the tag + + :attr:`redirect_path` + relative to redirect to after submitting the form + + :attr:`copy_nav_params` + flag telling if navigation parameters should be copied back in hidden + inputs + + :attr:`form_buttons` + sequence of form control (:class:`~cubicweb.web.formwidgets.Button` + widgets instances) + + :attr:`form_renderer_id` + identifier of the form renderer to use to render the form + + :attr:`fieldsets_in_order` + sequence of fieldset names , to control order + + :attr:`autocomplete` + set to False to add 'autocomplete=off' in the form open tag + + **Generic methods** + + .. automethod:: cubicweb.web.form.Form.field_by_name(name, role=None) + .. automethod:: cubicweb.web.form.Form.fields_by_name(name, role=None) + + **Form construction methods** + + .. automethod:: cubicweb.web.form.Form.remove_field(field) + .. automethod:: cubicweb.web.form.Form.append_field(field) + .. automethod:: cubicweb.web.form.Form.insert_field_before(field, name, role=None) + .. automethod:: cubicweb.web.form.Form.insert_field_after(field, name, role=None) + .. automethod:: cubicweb.web.form.Form.add_hidden(name, value=None, **kwargs) + + **Form rendering methods** + + .. automethod:: cubicweb.web.views.forms.FieldsForm.render + + **Form posting methods** + + Once a form is posted, you can retrieve the form on the controller side and + use the following methods to ease processing. For "simple" forms, this + should looks like : + + .. sourcecode :: python + + form = self._cw.vreg['forms'].select('myformid', self._cw) + posted = form.process_posted() + # do something with the returned dictionary + + Notice that form related to entity edition should usually use the + `edit` controller which will handle all the logic for you. + + .. automethod:: cubicweb.web.views.forms.FieldsForm.process_posted + .. automethod:: cubicweb.web.views.forms.FieldsForm.iter_modified_fields + """ + __regid__ = 'base' + + + # attributes overrideable by subclasses or through __init__ + needs_js = ('cubicweb.ajax.js', 'cubicweb.edition.js',) + needs_css = ('cubicweb.form.css',) + action = None + cssclass = None + cssstyle = None + cwtarget = None + redirect_path = None + form_buttons = None + form_renderer_id = 'default' + fieldsets_in_order = None + autocomplete = True + + @property + def needs_multipart(self): + """true if the form needs enctype=multipart/form-data""" + return any(field.needs_multipart for field in self.fields) + + def _get_onsubmit(self): + try: + return self._onsubmit + except AttributeError: + return "return freezeFormButtons('%(domid)s');" % dictattr(self) + def _set_onsubmit(self, value): + self._onsubmit = value + onsubmit = property(_get_onsubmit, _set_onsubmit) + + def add_media(self): + """adds media (CSS & JS) required by this widget""" + if self.needs_js: + self._cw.add_js(self.needs_js) + if self.needs_css: + self._cw.add_css(self.needs_css) + + def render(self, formvalues=None, renderer=None, **kwargs): + """Render this form, using the `renderer` given as argument or the + default according to :attr:`form_renderer_id`. The rendered form is + returned as a unicode string. + + `formvalues` is an optional dictionary containing values that will be + considered as field's value. + + Extra keyword arguments will be given to renderer's :meth:`render` method. + """ + w = kwargs.pop('w', None) + self.build_context(formvalues) + if renderer is None: + renderer = self.default_renderer() + renderer.render(w, self, kwargs) + + def default_renderer(self): + return self._cw.vreg['formrenderers'].select( + self.form_renderer_id, self._cw, + rset=self.cw_rset, row=self.cw_row, col=self.cw_col or 0) + + formvalues = None + def build_context(self, formvalues=None): + """build form context values (the .context attribute which is a + dictionary with field instance as key associated to a dictionary + containing field 'name' (qualified), 'id', 'value' (for display, always + a string). + """ + if self.formvalues is not None: + return # already built + self.formvalues = formvalues or {} + # use a copy in case fields are modified while context is built (eg + # __linkto handling for instance) + for field in self.fields[:]: + for field in field.actual_fields(self): + field.form_init(self) + # store used field in an hidden input for later usage by a controller + fields = set() + eidfields = set() + for field in self.fields: + if field.eidparam: + eidfields.add(field.role_name()) + elif field.name not in self.control_fields: + fields.add(field.role_name()) + if fields: + self.add_hidden('_cw_fields', u','.join(fields)) + if eidfields: + self.add_hidden('_cw_entity_fields', u','.join(eidfields), + eidparam=True) + + _default_form_action_path = 'edit' + def form_action(self): + action = self.action + if action is None: + return self._cw.build_url(self._default_form_action_path) + return action + + # controller form processing methods ####################################### + + def iter_modified_fields(self, editedfields=None, entity=None): + """return a generator on field that has been modified by the posted + form. + """ + if editedfields is None: + try: + editedfields = self._cw.form['_cw_fields'] + except KeyError: + raise RequestError(self._cw._('no edited fields specified')) + entityform = entity and len(inspect.getargspec(self.field_by_name)) == 4 # XXX + for editedfield in splitstrip(editedfields): + try: + name, role = editedfield.split('-') + except Exception: + name = editedfield + role = None + if entityform: + field = self.field_by_name(name, role, eschema=entity.e_schema) + else: + field = self.field_by_name(name, role) + if field.has_been_modified(self): + yield field + + def process_posted(self): + """use this method to process the content posted by a simple form. it + will return a dictionary with field names as key and typed value as + associated value. + """ + with tempattr(self, 'formvalues', {}): # init fields value cache + errors = [] + processed = {} + for field in self.iter_modified_fields(): + try: + for field, value in field.process_posted(self): + processed[field.role_name()] = value + except ProcessFormError as exc: + errors.append((field, exc)) + if errors: + errors = dict((f.role_name(), text_type(ex)) for f, ex in errors) + raise ValidationError(None, errors) + return processed + + +class EntityFieldsForm(FieldsForm): + """This class is designed for forms used to edit some entities. It should + handle for you all the underlying stuff necessary to properly work with the + generic :class:`~cubicweb.web.views.editcontroller.EditController`. + """ + + __regid__ = 'base' + __select__ = (match_kwargs('entity') + | (one_line_rset() & non_final_entity())) + domid = 'entityForm' + uicfg_aff = uicfg.autoform_field + uicfg_affk = uicfg.autoform_field_kwargs + + @iclassmethod + def field_by_name(cls_or_self, name, role=None, eschema=None): + """return field with the given name and role. If field is not explicitly + defined for the form but `eclass` is specified, guess_field will be + called. + """ + try: + return super(EntityFieldsForm, cls_or_self).field_by_name(name, role) + except form.FieldNotFound: + if eschema is None or role is None or not name in eschema.schema: + raise + rschema = eschema.schema.rschema(name) + # XXX use a sample target type. Document this. + tschemas = rschema.targets(eschema, role) + fieldcls = cls_or_self.uicfg_aff.etype_get( + eschema, rschema, role, tschemas[0]) + kwargs = cls_or_self.uicfg_affk.etype_get( + eschema, rschema, role, tschemas[0]) + if kwargs is None: + kwargs = {} + if fieldcls: + if not isinstance(fieldcls, type): + return fieldcls # already and instance + return fieldcls(name=name, role=role, eidparam=True, **kwargs) + if isinstance(cls_or_self, type): + req = None + else: + req = cls_or_self._cw + field = guess_field(eschema, rschema, role, req=req, eidparam=True, **kwargs) + if field is None: + raise + return field + + def __init__(self, _cw, rset=None, row=None, col=None, **kwargs): + try: + self.edited_entity = kwargs.pop('entity') + except KeyError: + self.edited_entity = rset.complete_entity(row or 0, col or 0) + msg = kwargs.pop('submitmsg', None) + super(EntityFieldsForm, self).__init__(_cw, rset, row, col, **kwargs) + self.uicfg_aff = self._cw.vreg['uicfg'].select( + 'autoform_field', self._cw, entity=self.edited_entity) + self.uicfg_affk = self._cw.vreg['uicfg'].select( + 'autoform_field_kwargs', self._cw, entity=self.edited_entity) + self.add_hidden('__type', self.edited_entity.cw_etype, eidparam=True) + + self.add_hidden('eid', self.edited_entity.eid) + self.add_generation_time() + # mainform default to true in parent, hence default to True + if kwargs.get('mainform', True) or kwargs.get('mainentity', False): + self.add_hidden(u'__maineid', self.edited_entity.eid) + # If we need to directly attach the new object to another one + if '__linkto' in self._cw.form: + if msg: + msg = '%s %s' % (msg, self._cw._('and linked')) + else: + msg = self._cw._('entity linked') + if msg: + msgid = self._cw.set_redirect_message(msg) + self.add_hidden('_cwmsgid', msgid) + + def add_generation_time(self): + # use %f to prevent (unlikely) display in exponential format + self.add_hidden('__form_generation_time', '%.6f' % time.time(), + eidparam=True) + + def add_linkto_hidden(self): + """add the __linkto hidden field used to directly attach the new object + to an existing other one when the relation between those two is not + already present in the form. + + Warning: this method must be called only when all form fields are setup + """ + for (rtype, role), eids in self.linked_to.items(): + # if the relation is already setup by a form field, do not add it + # in a __linkto hidden to avoid setting it twice in the controller + try: + self.field_by_name(rtype, role) + except form.FieldNotFound: + for eid in eids: + self.add_hidden('__linkto', '%s:%s:%s' % (rtype, eid, role)) + + def render(self, *args, **kwargs): + self.add_linkto_hidden() + return super(EntityFieldsForm, self).render(*args, **kwargs) + + @property + @cached + def linked_to(self): + linked_to = {} + # case where this is an embeded creation form + try: + eid = int(self.cw_extra_kwargs['peid']) + except (KeyError, ValueError): + # When parent is being created, its eid is not numeric (e.g. 'A') + # hence ValueError. + pass + else: + ltrtype = self.cw_extra_kwargs['rtype'] + ltrole = neg_role(self.cw_extra_kwargs['role']) + linked_to[(ltrtype, ltrole)] = [eid] + # now consider __linkto if the current form is the main form + try: + self.field_by_name('__maineid') + except form.FieldNotFound: + return linked_to + for linkto in self._cw.list_form_param('__linkto'): + ltrtype, eid, ltrole = linkto.split(':') + linked_to.setdefault((ltrtype, ltrole), []).append(int(eid)) + return linked_to + + 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 not None: + return self.force_session_key + # XXX if this is a json request, suppose we should redirect to the + # entity primary view + if self._cw.ajax_request and self.edited_entity.has_eid(): + return '%s#%s' % (self.edited_entity.absolute_url(), self.domid) + # XXX we should not consider some url parameters that may lead to + # different url after a validation error + return '%s#%s' % (self._cw.url(), self.domid) + + def default_renderer(self): + return self._cw.vreg['formrenderers'].select( + self.form_renderer_id, self._cw, rset=self.cw_rset, row=self.cw_row, + col=self.cw_col, entity=self.edited_entity) + + def should_display_add_new_relation_link(self, rschema, existant, card): + return False + + # controller side method (eg POST reception handling) + + def actual_eid(self, eid): + # should be either an int (existant entity) or a variable (to be + # created entity) + assert eid or eid == 0, repr(eid) # 0 is a valid eid + try: + return int(eid) + except ValueError: + try: + return self._cw.data['eidmap'][eid] + except KeyError: + self._cw.data['eidmap'][eid] = None + return None + + def editable_relations(self): + return () + + +class CompositeFormMixIn(object): + __regid__ = 'composite' + form_renderer_id = __regid__ + + def __init__(self, *args, **kwargs): + super(CompositeFormMixIn, self).__init__(*args, **kwargs) + self.forms = [] + + def add_subform(self, subform): + """mark given form as a subform and append it""" + subform.parent_form = self + self.forms.append(subform) + + def build_context(self, formvalues=None): + super(CompositeFormMixIn, self).build_context(formvalues) + for form in self.forms: + form.build_context(formvalues) + + +class CompositeForm(CompositeFormMixIn, FieldsForm): + """Form composed of sub-forms. Typical usage is edition of multiple entities + at once. + """ + +class CompositeEntityForm(CompositeFormMixIn, EntityFieldsForm): + pass # XXX why is this class necessary?