web/views/forms.py
changeset 11057 0b59724cb3f2
parent 11052 058bb3dc685f
child 11058 23eb30449fe5
equal deleted inserted replaced
11052:058bb3dc685f 11057:0b59724cb3f2
     1 # copyright 2003-2014 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
       
     2 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
       
     3 #
       
     4 # This file is part of CubicWeb.
       
     5 #
       
     6 # CubicWeb is free software: you can redistribute it and/or modify it under the
       
     7 # terms of the GNU Lesser General Public License as published by the Free
       
     8 # Software Foundation, either version 2.1 of the License, or (at your option)
       
     9 # any later version.
       
    10 #
       
    11 # CubicWeb is distributed in the hope that it will be useful, but WITHOUT
       
    12 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
       
    13 # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
       
    14 # details.
       
    15 #
       
    16 # You should have received a copy of the GNU Lesser General Public License along
       
    17 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
       
    18 """
       
    19 Base form classes
       
    20 -----------------
       
    21 
       
    22 .. Note:
       
    23 
       
    24    Form is the glue that bind a context to a set of fields, and is rendered
       
    25    using a form renderer. No display is actually done here, though you'll find
       
    26    some attributes of form that are used to control the rendering process.
       
    27 
       
    28 Besides the automagic form we'll see later, there are roughly two main
       
    29 form classes in |cubicweb|:
       
    30 
       
    31 .. autoclass:: cubicweb.web.views.forms.FieldsForm
       
    32 .. autoclass:: cubicweb.web.views.forms.EntityFieldsForm
       
    33 
       
    34 As you have probably guessed, choosing between them is easy. Simply ask you the
       
    35 question 'I am editing an entity or not?'. If the answer is yes, use
       
    36 :class:`EntityFieldsForm`, else use :class:`FieldsForm`.
       
    37 
       
    38 Actually there exists a third form class:
       
    39 
       
    40 .. autoclass:: cubicweb.web.views.forms.CompositeForm
       
    41 
       
    42 but you'll use this one rarely.
       
    43 """
       
    44 
       
    45 __docformat__ = "restructuredtext en"
       
    46 
       
    47 
       
    48 import time
       
    49 import inspect
       
    50 
       
    51 from six import text_type
       
    52 
       
    53 from logilab.common import dictattr, tempattr
       
    54 from logilab.common.decorators import iclassmethod, cached
       
    55 from logilab.common.textutils import splitstrip
       
    56 
       
    57 from cubicweb import ValidationError, neg_role
       
    58 from cubicweb.predicates import non_final_entity, match_kwargs, one_line_rset
       
    59 from cubicweb.web import RequestError, ProcessFormError
       
    60 from cubicweb.web import form
       
    61 from cubicweb.web.views import uicfg
       
    62 from cubicweb.web.formfields import guess_field
       
    63 
       
    64 
       
    65 class FieldsForm(form.Form):
       
    66     """This is the base class for fields based forms.
       
    67 
       
    68     **Attributes**
       
    69 
       
    70     The following attributes may be either set on subclasses or given on
       
    71     form selection to customize the generated form:
       
    72 
       
    73     :attr:`needs_js`
       
    74       sequence of javascript files that should be added to handle this form
       
    75       (through :meth:`~cubicweb.web.request.Request.add_js`)
       
    76 
       
    77     :attr:`needs_css`
       
    78       sequence of css files that should be added to handle this form (through
       
    79       :meth:`~cubicweb.web.request.Request.add_css`)
       
    80 
       
    81     :attr:`domid`
       
    82       value for the "id" attribute of the <form> tag
       
    83 
       
    84     :attr:`action`
       
    85       value for the "action" attribute of the <form> tag
       
    86 
       
    87     :attr:`onsubmit`
       
    88       value for the "onsubmit" attribute of the <form> tag
       
    89 
       
    90     :attr:`cssclass`
       
    91       value for the "class" attribute of the <form> tag
       
    92 
       
    93     :attr:`cssstyle`
       
    94       value for the "style" attribute of the <form> tag
       
    95 
       
    96     :attr:`cwtarget`
       
    97       value for the "target" attribute of the <form> tag
       
    98 
       
    99     :attr:`redirect_path`
       
   100       relative to redirect to after submitting the form
       
   101 
       
   102     :attr:`copy_nav_params`
       
   103       flag telling if navigation parameters should be copied back in hidden
       
   104       inputs
       
   105 
       
   106     :attr:`form_buttons`
       
   107       sequence of form control (:class:`~cubicweb.web.formwidgets.Button`
       
   108       widgets instances)
       
   109 
       
   110     :attr:`form_renderer_id`
       
   111       identifier of the form renderer to use to render the form
       
   112 
       
   113     :attr:`fieldsets_in_order`
       
   114       sequence of fieldset names , to control order
       
   115 
       
   116     :attr:`autocomplete`
       
   117       set to False to add 'autocomplete=off' in the form open tag
       
   118 
       
   119     **Generic methods**
       
   120 
       
   121     .. automethod:: cubicweb.web.form.Form.field_by_name(name, role=None)
       
   122     .. automethod:: cubicweb.web.form.Form.fields_by_name(name, role=None)
       
   123 
       
   124     **Form construction methods**
       
   125 
       
   126     .. automethod:: cubicweb.web.form.Form.remove_field(field)
       
   127     .. automethod:: cubicweb.web.form.Form.append_field(field)
       
   128     .. automethod:: cubicweb.web.form.Form.insert_field_before(field, name, role=None)
       
   129     .. automethod:: cubicweb.web.form.Form.insert_field_after(field, name, role=None)
       
   130     .. automethod:: cubicweb.web.form.Form.add_hidden(name, value=None, **kwargs)
       
   131 
       
   132     **Form rendering methods**
       
   133 
       
   134     .. automethod:: cubicweb.web.views.forms.FieldsForm.render
       
   135 
       
   136     **Form posting methods**
       
   137 
       
   138     Once a form is posted, you can retrieve the form on the controller side and
       
   139     use the following methods to ease processing. For "simple" forms, this
       
   140     should looks like :
       
   141 
       
   142     .. sourcecode :: python
       
   143 
       
   144         form = self._cw.vreg['forms'].select('myformid', self._cw)
       
   145         posted = form.process_posted()
       
   146         # do something with the returned dictionary
       
   147 
       
   148     Notice that form related to entity edition should usually use the
       
   149     `edit` controller which will handle all the logic for you.
       
   150 
       
   151     .. automethod:: cubicweb.web.views.forms.FieldsForm.process_posted
       
   152     .. automethod:: cubicweb.web.views.forms.FieldsForm.iter_modified_fields
       
   153     """
       
   154     __regid__ = 'base'
       
   155 
       
   156 
       
   157     # attributes overrideable by subclasses or through __init__
       
   158     needs_js = ('cubicweb.ajax.js', 'cubicweb.edition.js',)
       
   159     needs_css = ('cubicweb.form.css',)
       
   160     action = None
       
   161     cssclass = None
       
   162     cssstyle = None
       
   163     cwtarget = None
       
   164     redirect_path = None
       
   165     form_buttons = None
       
   166     form_renderer_id = 'default'
       
   167     fieldsets_in_order = None
       
   168     autocomplete = True
       
   169 
       
   170     @property
       
   171     def needs_multipart(self):
       
   172         """true if the form needs enctype=multipart/form-data"""
       
   173         return any(field.needs_multipart for field in self.fields)
       
   174 
       
   175     def _get_onsubmit(self):
       
   176         try:
       
   177             return self._onsubmit
       
   178         except AttributeError:
       
   179             return "return freezeFormButtons('%(domid)s');" % dictattr(self)
       
   180     def _set_onsubmit(self, value):
       
   181         self._onsubmit = value
       
   182     onsubmit = property(_get_onsubmit, _set_onsubmit)
       
   183 
       
   184     def add_media(self):
       
   185         """adds media (CSS & JS) required by this widget"""
       
   186         if self.needs_js:
       
   187             self._cw.add_js(self.needs_js)
       
   188         if self.needs_css:
       
   189             self._cw.add_css(self.needs_css)
       
   190 
       
   191     def render(self, formvalues=None, renderer=None, **kwargs):
       
   192         """Render this form, using the `renderer` given as argument or the
       
   193         default according to :attr:`form_renderer_id`. The rendered form is
       
   194         returned as a unicode string.
       
   195 
       
   196         `formvalues` is an optional dictionary containing values that will be
       
   197         considered as field's value.
       
   198 
       
   199         Extra keyword arguments will be given to renderer's :meth:`render` method.
       
   200         """
       
   201         w = kwargs.pop('w', None)
       
   202         self.build_context(formvalues)
       
   203         if renderer is None:
       
   204             renderer = self.default_renderer()
       
   205         renderer.render(w, self, kwargs)
       
   206 
       
   207     def default_renderer(self):
       
   208         return self._cw.vreg['formrenderers'].select(
       
   209             self.form_renderer_id, self._cw,
       
   210             rset=self.cw_rset, row=self.cw_row, col=self.cw_col or 0)
       
   211 
       
   212     formvalues = None
       
   213     def build_context(self, formvalues=None):
       
   214         """build form context values (the .context attribute which is a
       
   215         dictionary with field instance as key associated to a dictionary
       
   216         containing field 'name' (qualified), 'id', 'value' (for display, always
       
   217         a string).
       
   218         """
       
   219         if self.formvalues is not None:
       
   220             return # already built
       
   221         self.formvalues = formvalues or {}
       
   222         # use a copy in case fields are modified while context is built (eg
       
   223         # __linkto handling for instance)
       
   224         for field in self.fields[:]:
       
   225             for field in field.actual_fields(self):
       
   226                 field.form_init(self)
       
   227         # store used field in an hidden input for later usage by a controller
       
   228         fields = set()
       
   229         eidfields = set()
       
   230         for field in self.fields:
       
   231             if field.eidparam:
       
   232                 eidfields.add(field.role_name())
       
   233             elif field.name not in self.control_fields:
       
   234                 fields.add(field.role_name())
       
   235         if fields:
       
   236             self.add_hidden('_cw_fields', u','.join(fields))
       
   237         if eidfields:
       
   238             self.add_hidden('_cw_entity_fields', u','.join(eidfields),
       
   239                             eidparam=True)
       
   240 
       
   241     _default_form_action_path = 'edit'
       
   242     def form_action(self):
       
   243         action = self.action
       
   244         if action is None:
       
   245             return self._cw.build_url(self._default_form_action_path)
       
   246         return action
       
   247 
       
   248     # controller form processing methods #######################################
       
   249 
       
   250     def iter_modified_fields(self, editedfields=None, entity=None):
       
   251         """return a generator on field that has been modified by the posted
       
   252         form.
       
   253         """
       
   254         if editedfields is None:
       
   255             try:
       
   256                 editedfields = self._cw.form['_cw_fields']
       
   257             except KeyError:
       
   258                 raise RequestError(self._cw._('no edited fields specified'))
       
   259         entityform = entity and len(inspect.getargspec(self.field_by_name)) == 4 # XXX
       
   260         for editedfield in splitstrip(editedfields):
       
   261             try:
       
   262                 name, role = editedfield.split('-')
       
   263             except Exception:
       
   264                 name = editedfield
       
   265                 role = None
       
   266             if entityform:
       
   267                 field = self.field_by_name(name, role, eschema=entity.e_schema)
       
   268             else:
       
   269                 field = self.field_by_name(name, role)
       
   270             if field.has_been_modified(self):
       
   271                 yield field
       
   272 
       
   273     def process_posted(self):
       
   274         """use this method to process the content posted by a simple form.  it
       
   275         will return a dictionary with field names as key and typed value as
       
   276         associated value.
       
   277         """
       
   278         with tempattr(self, 'formvalues', {}): # init fields value cache
       
   279             errors = []
       
   280             processed = {}
       
   281             for field in self.iter_modified_fields():
       
   282                 try:
       
   283                     for field, value in field.process_posted(self):
       
   284                         processed[field.role_name()] = value
       
   285                 except ProcessFormError as exc:
       
   286                     errors.append((field, exc))
       
   287             if errors:
       
   288                 errors = dict((f.role_name(), text_type(ex)) for f, ex in errors)
       
   289                 raise ValidationError(None, errors)
       
   290             return processed
       
   291 
       
   292 
       
   293 class EntityFieldsForm(FieldsForm):
       
   294     """This class is designed for forms used to edit some entities. It should
       
   295     handle for you all the underlying stuff necessary to properly work with the
       
   296     generic :class:`~cubicweb.web.views.editcontroller.EditController`.
       
   297     """
       
   298 
       
   299     __regid__ = 'base'
       
   300     __select__ = (match_kwargs('entity')
       
   301                   | (one_line_rset() & non_final_entity()))
       
   302     domid = 'entityForm'
       
   303     uicfg_aff = uicfg.autoform_field
       
   304     uicfg_affk = uicfg.autoform_field_kwargs
       
   305 
       
   306     @iclassmethod
       
   307     def field_by_name(cls_or_self, name, role=None, eschema=None):
       
   308         """return field with the given name and role. If field is not explicitly
       
   309         defined for the form but `eclass` is specified, guess_field will be
       
   310         called.
       
   311         """
       
   312         try:
       
   313             return super(EntityFieldsForm, cls_or_self).field_by_name(name, role)
       
   314         except form.FieldNotFound:
       
   315             if eschema is None or role is None or not name in eschema.schema:
       
   316                 raise
       
   317             rschema = eschema.schema.rschema(name)
       
   318             # XXX use a sample target type. Document this.
       
   319             tschemas = rschema.targets(eschema, role)
       
   320             fieldcls = cls_or_self.uicfg_aff.etype_get(
       
   321                 eschema, rschema, role, tschemas[0])
       
   322             kwargs = cls_or_self.uicfg_affk.etype_get(
       
   323                 eschema, rschema, role, tschemas[0])
       
   324             if kwargs is None:
       
   325                 kwargs = {}
       
   326             if fieldcls:
       
   327                 if not isinstance(fieldcls, type):
       
   328                     return fieldcls # already and instance
       
   329                 return fieldcls(name=name, role=role, eidparam=True, **kwargs)
       
   330             if isinstance(cls_or_self, type):
       
   331                 req = None
       
   332             else:
       
   333                 req = cls_or_self._cw
       
   334             field = guess_field(eschema, rschema, role, req=req, eidparam=True, **kwargs)
       
   335             if field is None:
       
   336                 raise
       
   337             return field
       
   338 
       
   339     def __init__(self, _cw, rset=None, row=None, col=None, **kwargs):
       
   340         try:
       
   341             self.edited_entity = kwargs.pop('entity')
       
   342         except KeyError:
       
   343             self.edited_entity = rset.complete_entity(row or 0, col or 0)
       
   344         msg = kwargs.pop('submitmsg', None)
       
   345         super(EntityFieldsForm, self).__init__(_cw, rset, row, col, **kwargs)
       
   346         self.uicfg_aff = self._cw.vreg['uicfg'].select(
       
   347             'autoform_field', self._cw, entity=self.edited_entity)
       
   348         self.uicfg_affk = self._cw.vreg['uicfg'].select(
       
   349             'autoform_field_kwargs', self._cw, entity=self.edited_entity)
       
   350         self.add_hidden('__type', self.edited_entity.cw_etype, eidparam=True)
       
   351 
       
   352         self.add_hidden('eid', self.edited_entity.eid)
       
   353         self.add_generation_time()
       
   354         # mainform default to true in parent, hence default to True
       
   355         if kwargs.get('mainform', True) or kwargs.get('mainentity', False):
       
   356             self.add_hidden(u'__maineid', self.edited_entity.eid)
       
   357             # If we need to directly attach the new object to another one
       
   358             if '__linkto' in self._cw.form:
       
   359                 if msg:
       
   360                     msg = '%s %s' % (msg, self._cw._('and linked'))
       
   361                 else:
       
   362                     msg = self._cw._('entity linked')
       
   363         if msg:
       
   364             msgid = self._cw.set_redirect_message(msg)
       
   365             self.add_hidden('_cwmsgid', msgid)
       
   366 
       
   367     def add_generation_time(self):
       
   368         # use %f to prevent (unlikely) display in exponential format
       
   369         self.add_hidden('__form_generation_time', '%.6f' % time.time(),
       
   370                         eidparam=True)
       
   371 
       
   372     def add_linkto_hidden(self):
       
   373         """add the __linkto hidden field used to directly attach the new object
       
   374         to an existing other one when the relation between those two is not
       
   375         already present in the form.
       
   376 
       
   377         Warning: this method must be called only when all form fields are setup
       
   378         """
       
   379         for (rtype, role), eids in self.linked_to.items():
       
   380             # if the relation is already setup by a form field, do not add it
       
   381             # in a __linkto hidden to avoid setting it twice in the controller
       
   382             try:
       
   383                 self.field_by_name(rtype, role)
       
   384             except form.FieldNotFound:
       
   385                 for eid in eids:
       
   386                     self.add_hidden('__linkto', '%s:%s:%s' % (rtype, eid, role))
       
   387 
       
   388     def render(self, *args, **kwargs):
       
   389         self.add_linkto_hidden()
       
   390         return super(EntityFieldsForm, self).render(*args, **kwargs)
       
   391 
       
   392     @property
       
   393     @cached
       
   394     def linked_to(self):
       
   395         linked_to = {}
       
   396         # case where this is an embeded creation form
       
   397         try:
       
   398             eid = int(self.cw_extra_kwargs['peid'])
       
   399         except (KeyError, ValueError):
       
   400             # When parent is being created, its eid is not numeric (e.g. 'A')
       
   401             # hence ValueError.
       
   402             pass
       
   403         else:
       
   404             ltrtype = self.cw_extra_kwargs['rtype']
       
   405             ltrole = neg_role(self.cw_extra_kwargs['role'])
       
   406             linked_to[(ltrtype, ltrole)] = [eid]
       
   407         # now consider __linkto if the current form is the main form
       
   408         try:
       
   409             self.field_by_name('__maineid')
       
   410         except form.FieldNotFound:
       
   411             return linked_to
       
   412         for linkto in self._cw.list_form_param('__linkto'):
       
   413             ltrtype, eid, ltrole = linkto.split(':')
       
   414             linked_to.setdefault((ltrtype, ltrole), []).append(int(eid))
       
   415         return linked_to
       
   416 
       
   417     def session_key(self):
       
   418         """return the key that may be used to store / retreive data about a
       
   419         previous post which failed because of a validation error
       
   420         """
       
   421         if self.force_session_key is not None:
       
   422             return self.force_session_key
       
   423         # XXX if this is a json request, suppose we should redirect to the
       
   424         # entity primary view
       
   425         if self._cw.ajax_request and self.edited_entity.has_eid():
       
   426             return '%s#%s' % (self.edited_entity.absolute_url(), self.domid)
       
   427         # XXX we should not consider some url parameters that may lead to
       
   428         # different url after a validation error
       
   429         return '%s#%s' % (self._cw.url(), self.domid)
       
   430 
       
   431     def default_renderer(self):
       
   432         return self._cw.vreg['formrenderers'].select(
       
   433             self.form_renderer_id, self._cw, rset=self.cw_rset, row=self.cw_row,
       
   434             col=self.cw_col, entity=self.edited_entity)
       
   435 
       
   436     def should_display_add_new_relation_link(self, rschema, existant, card):
       
   437         return False
       
   438 
       
   439     # controller side method (eg POST reception handling)
       
   440 
       
   441     def actual_eid(self, eid):
       
   442         # should be either an int (existant entity) or a variable (to be
       
   443         # created entity)
       
   444         assert eid or eid == 0, repr(eid) # 0 is a valid eid
       
   445         try:
       
   446             return int(eid)
       
   447         except ValueError:
       
   448             try:
       
   449                 return self._cw.data['eidmap'][eid]
       
   450             except KeyError:
       
   451                 self._cw.data['eidmap'][eid] = None
       
   452                 return None
       
   453 
       
   454     def editable_relations(self):
       
   455         return ()
       
   456 
       
   457 
       
   458 class CompositeFormMixIn(object):
       
   459     __regid__ = 'composite'
       
   460     form_renderer_id = __regid__
       
   461 
       
   462     def __init__(self, *args, **kwargs):
       
   463         super(CompositeFormMixIn, self).__init__(*args, **kwargs)
       
   464         self.forms = []
       
   465 
       
   466     def add_subform(self, subform):
       
   467         """mark given form as a subform and append it"""
       
   468         subform.parent_form = self
       
   469         self.forms.append(subform)
       
   470 
       
   471     def build_context(self, formvalues=None):
       
   472         super(CompositeFormMixIn, self).build_context(formvalues)
       
   473         for form in self.forms:
       
   474             form.build_context(formvalues)
       
   475 
       
   476 
       
   477 class CompositeForm(CompositeFormMixIn, FieldsForm):
       
   478     """Form composed of sub-forms. Typical usage is edition of multiple entities
       
   479     at once.
       
   480     """
       
   481 
       
   482 class CompositeEntityForm(CompositeFormMixIn, EntityFieldsForm):
       
   483     pass # XXX why is this class necessary?