--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/cubicweb/web/formfields.py Sat Jan 16 13:48:51 2016 +0100
@@ -0,0 +1,1269 @@
+# copyright 2003-2013 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/>.
+"""
+The Field class and basic fields
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. Note::
+ Fields are used to control what's edited in forms. They makes the link between
+ something to edit and its display in the form. Actual display is handled by a
+ widget associated to the field.
+
+Let first see the base class for fields:
+
+.. autoclass:: cubicweb.web.formfields.Field
+
+Now, you usually don't use that class but one of the concrete field classes
+described below, according to what you want to edit.
+
+Basic fields
+''''''''''''
+
+.. autoclass:: cubicweb.web.formfields.StringField()
+.. autoclass:: cubicweb.web.formfields.PasswordField()
+.. autoclass:: cubicweb.web.formfields.IntField()
+.. autoclass:: cubicweb.web.formfields.BigIntField()
+.. autoclass:: cubicweb.web.formfields.FloatField()
+.. autoclass:: cubicweb.web.formfields.BooleanField()
+.. autoclass:: cubicweb.web.formfields.DateField()
+.. autoclass:: cubicweb.web.formfields.DateTimeField()
+.. autoclass:: cubicweb.web.formfields.TimeField()
+.. autoclass:: cubicweb.web.formfields.TimeIntervalField()
+
+Compound fields
+''''''''''''''''
+
+.. autoclass:: cubicweb.web.formfields.RichTextField()
+.. autoclass:: cubicweb.web.formfields.FileField()
+.. autoclass:: cubicweb.web.formfields.CompoundField()
+
+.. autoclass cubicweb.web.formfields.EditableFileField() XXX should be a widget
+
+Entity specific fields and function
+'''''''''''''''''''''''''''''''''''
+
+.. autoclass:: cubicweb.web.formfields.RelationField()
+.. autofunction:: cubicweb.web.formfields.guess_field
+
+"""
+__docformat__ = "restructuredtext en"
+
+from warnings import warn
+from datetime import datetime, timedelta
+
+from six import PY2, text_type, string_types
+
+from logilab.mtconverter import xml_escape
+from logilab.common import nullobject
+from logilab.common.date import ustrftime
+from logilab.common.configuration import format_time
+from logilab.common.textutils import apply_units, TIME_UNITS
+
+from yams.schema import KNOWN_METAATTRIBUTES, role_name
+from yams.constraints import (SizeConstraint, StaticVocabularyConstraint,
+ FormatConstraint)
+
+from cubicweb import Binary, tags, uilib
+from cubicweb.utils import support_args
+from cubicweb.web import INTERNAL_FIELD_VALUE, ProcessFormError, eid_param, \
+ formwidgets as fw
+from cubicweb.web.views import uicfg
+
+class UnmodifiedField(Exception):
+ """raise this when a field has not actually been edited and you want to skip
+ it
+ """
+
+def normalize_filename(filename):
+ return filename.split('\\')[-1]
+
+def vocab_sort(vocab):
+ """sort vocabulary, considering option groups"""
+ result = []
+ partresult = []
+ for label, value in vocab:
+ if value is None: # opt group start
+ if partresult:
+ result += sorted(partresult)
+ partresult = []
+ result.append( (label, value) )
+ else:
+ partresult.append( (label, value) )
+ result += sorted(partresult)
+ return result
+
+_MARKER = nullobject()
+
+class Field(object):
+ """This class is the abstract base class for all fields. It hold a bunch
+ of attributes which may be used for fine control of the behaviour of a
+ concrete field.
+
+ **Attributes**
+
+ All the attributes described below have sensible default value which may be
+ overriden by named arguments given to field's constructor.
+
+ :attr:`name`
+ base name of the field (basestring). The actual input name is returned by
+ the :meth:`input_name` method and may differ from that name (for instance
+ if `eidparam` is true).
+ :attr:`id`
+ DOM identifier (default to the same value as `name`), should be unique in
+ a form.
+ :attr:`label`
+ label of the field (default to the same value as `name`).
+ :attr:`help`
+ help message about this field.
+ :attr:`widget`
+ widget associated to the field. Each field class has a default widget
+ class which may be overriden per instance.
+ :attr:`value`
+ field value. May be an actual value or a callable which should take the
+ form as argument and return a value.
+ :attr:`choices`
+ static vocabulary for this field. May be a list of values, a list of
+ (label, value) tuples or a callable which should take the form and field
+ as arguments and return a list of values or a list of (label, value).
+ :attr:`required`
+ bool flag telling if the field is required or not.
+ :attr:`sort`
+ bool flag telling if the vocabulary (either static vocabulary specified
+ in `choices` or dynamic vocabulary fetched from the form) should be
+ sorted on label.
+ :attr:`internationalizable`
+ bool flag telling if the vocabulary labels should be translated using the
+ current request language.
+ :attr:`eidparam`
+ bool flag telling if this field is linked to a specific entity
+ :attr:`role`
+ when the field is linked to an entity attribute or relation, tells the
+ role of the entity in the relation (eg 'subject' or 'object'). If this is
+ not an attribute or relation of the edited entity, `role` should be
+ `None`.
+ :attr:`fieldset`
+ optional fieldset to which this field belongs to
+ :attr:`order`
+ key used by automatic forms to sort fields
+ :attr:`ignore_req_params`
+ when true, this field won't consider value potentially specified using
+ request's form parameters (eg you won't be able to specify a value using for
+ instance url like http://mywebsite.com/form?field=value)
+
+ .. currentmodule:: cubicweb.web.formfields
+
+ **Generic methods**
+
+ .. automethod:: Field.input_name
+ .. automethod:: Field.dom_id
+ .. automethod:: Field.actual_fields
+
+ **Form generation methods**
+
+ .. automethod:: form_init
+ .. automethod:: typed_value
+
+ **Post handling methods**
+
+ .. automethod:: process_posted
+ .. automethod:: process_form_value
+
+ """
+ # default widget associated to this class of fields. May be overriden per
+ # instance
+ widget = fw.TextInput
+ # does this field requires a multipart form
+ needs_multipart = False
+ # class attribute used for ordering of fields in a form
+ __creation_rank = 0
+
+ eidparam = False
+ role = None
+ id = None
+ help = None
+ required = False
+ choices = None
+ sort = True
+ internationalizable = False
+ fieldset = None
+ order = None
+ value = _MARKER
+ fallback_on_none_attribute = False
+ ignore_req_params = False
+
+ def __init__(self, name=None, label=_MARKER, widget=None, **kwargs):
+ for key, val in kwargs.items():
+ assert hasattr(self.__class__, key) and not key[0] == '_', key
+ setattr(self, key, val)
+ self.name = name
+ if label is _MARKER:
+ label = name or _MARKER
+ self.label = label
+ # has to be done after other attributes initialization
+ self.init_widget(widget)
+ # ordering number for this field instance
+ self.creation_rank = Field.__creation_rank
+ Field.__creation_rank += 1
+
+ def as_string(self, repr=True):
+ l = [u'<%s' % self.__class__.__name__]
+ for attr in ('name', 'eidparam', 'role', 'id', 'value'):
+ value = getattr(self, attr)
+ if value is not None and value is not _MARKER:
+ l.append('%s=%r' % (attr, value))
+ if repr:
+ l.append('@%#x' % id(self))
+ return u'%s>' % ' '.join(l)
+
+ def __unicode__(self):
+ return self.as_string(False)
+
+ if PY2:
+ def __str__(self):
+ return self.as_string(False).encode('UTF8')
+ else:
+ __str__ = __unicode__
+
+ def __repr__(self):
+ return self.as_string(True)
+
+ def init_widget(self, widget):
+ if widget is not None:
+ self.widget = widget
+ elif self.choices and not self.widget.vocabulary_widget:
+ self.widget = fw.Select()
+ if isinstance(self.widget, type):
+ self.widget = self.widget()
+
+ def set_name(self, name):
+ """automatically set .label when name is set"""
+ assert name
+ self.name = name
+ if self.label is _MARKER:
+ self.label = name
+
+ def is_visible(self):
+ """return true if the field is not an hidden field"""
+ return not isinstance(self.widget, fw.HiddenInput)
+
+ def actual_fields(self, form):
+ """Fields may be composed of other fields. For instance the
+ :class:`~cubicweb.web.formfields.RichTextField` is containing a format
+ field to define the text format. This method returns actual fields that
+ should be considered for display / edition. It usually simply return
+ self.
+ """
+ yield self
+
+ def format_value(self, req, value):
+ """return value suitable for display where value may be a list or tuple
+ of values
+ """
+ if isinstance(value, (list, tuple)):
+ return [self.format_single_value(req, val) for val in value]
+ return self.format_single_value(req, value)
+
+ def format_single_value(self, req, value):
+ """return value suitable for display"""
+ if value is None or value is False:
+ return u''
+ if value is True:
+ return u'1'
+ return text_type(value)
+
+ def get_widget(self, form):
+ """return the widget instance associated to this field"""
+ return self.widget
+
+ def input_name(self, form, suffix=None):
+ """Return the 'qualified name' for this field, e.g. something suitable
+ to use as HTML input name. You can specify a suffix that will be
+ included in the name when widget needs several inputs.
+ """
+ # caching is necessary else we get some pb on entity creation :
+ # entity.eid is modified from creation mark (eg 'X') to its actual eid
+ # (eg 123), and then `field.input_name()` won't return the right key
+ # anymore if not cached (first call to input_name done *before* eventual
+ # eid affectation).
+ #
+ # note that you should NOT use @cached else it will create a memory leak
+ # on persistent fields (eg created once for all on a form class) because
+ # of the 'form' appobject argument: the cache will keep growing as new
+ # form are created...
+ try:
+ return form.formvalues[(self, 'input_name', suffix)]
+ except KeyError:
+ name = self.role_name()
+ if suffix is not None:
+ name += suffix
+ if self.eidparam:
+ name = eid_param(name, form.edited_entity.eid)
+ form.formvalues[(self, 'input_name', suffix)] = name
+ return name
+
+ def role_name(self):
+ """return <field.name>-<field.role> if role is specified, else field.name"""
+ assert self.name, 'field without a name (give it to constructor for explicitly built fields)'
+ if self.role is not None:
+ return role_name(self.name, self.role)
+ return self.name
+
+ def dom_id(self, form, suffix=None):
+ """Return the HTML DOM identifier for this field, e.g. something
+ suitable to use as HTML input id. You can specify a suffix that will be
+ included in the name when widget needs several inputs.
+ """
+ id = self.id or self.role_name()
+ if suffix is not None:
+ id += suffix
+ if self.eidparam:
+ return eid_param(id, form.edited_entity.eid)
+ return id
+
+ def typed_value(self, form, load_bytes=False):
+ """Return the correctly typed value for this field in the form context.
+ """
+ if self.eidparam and self.role is not None:
+ entity = form.edited_entity
+ if form._cw.vreg.schema.rschema(self.name).final:
+ if entity.has_eid() or self.name in entity.cw_attr_cache:
+ value = getattr(entity, self.name)
+ if value is not None or not self.fallback_on_none_attribute:
+ return value
+ elif entity.has_eid() or entity.cw_relation_cached(self.name, self.role):
+ value = [r[0] for r in entity.related(self.name, self.role)]
+ if value or not self.fallback_on_none_attribute:
+ return value
+ return self.initial_typed_value(form, load_bytes)
+
+ def initial_typed_value(self, form, load_bytes):
+ if self.value is not _MARKER:
+ if callable(self.value):
+ return self.value(form, self)
+ return self.value
+ formattr = '%s_%s_default' % (self.role, self.name)
+ if self.eidparam and self.role is not None:
+ if form._cw.vreg.schema.rschema(self.name).final:
+ return form.edited_entity.e_schema.default(self.name)
+ return form.linked_to.get((self.name, self.role), ())
+ return None
+
+ def example_format(self, req):
+ """return a sample string describing what can be given as input for this
+ field
+ """
+ return u''
+
+ def render(self, form, renderer):
+ """render this field, which is part of form, using the given form
+ renderer
+ """
+ widget = self.get_widget(form)
+ return widget.render(form, self, renderer)
+
+ def vocabulary(self, form, **kwargs):
+ """return vocabulary for this field. This method will be
+ called by widgets which requires a vocabulary.
+
+ It should return a list of tuple (label, value), where value
+ *must be a unicode string*, not a typed value.
+ """
+ assert self.choices is not None
+ if callable(self.choices):
+ # pylint: disable=E1102
+ if getattr(self.choices, '__self__', None) is self:
+ vocab = self.choices(form=form, **kwargs)
+ else:
+ vocab = self.choices(form=form, field=self, **kwargs)
+ else:
+ vocab = self.choices
+ if vocab and not isinstance(vocab[0], (list, tuple)):
+ vocab = [(x, x) for x in vocab]
+ if self.internationalizable:
+ # the short-cirtcuit 'and' boolean operator is used here
+ # to permit a valid empty string in vocabulary without
+ # attempting to translate it by gettext (which can lead to
+ # weird strings display)
+ vocab = [(label and form._cw._(label), value)
+ for label, value in vocab]
+ if self.sort:
+ vocab = vocab_sort(vocab)
+ return vocab
+
+ # support field as argument to avoid warning when used as format field value
+ # callback
+ def format(self, form, field=None):
+ """return MIME type used for the given (text or bytes) field"""
+ if self.eidparam and self.role == 'subject':
+ entity = form.edited_entity
+ if entity.e_schema.has_metadata(self.name, 'format') and (
+ entity.has_eid() or '%s_format' % self.name in entity.cw_attr_cache):
+ return form.edited_entity.cw_attr_metadata(self.name, 'format')
+ return form._cw.property_value('ui.default-text-format')
+
+ def encoding(self, form):
+ """return encoding used for the given (text) field"""
+ if self.eidparam:
+ entity = form.edited_entity
+ if entity.e_schema.has_metadata(self.name, 'encoding') and (
+ entity.has_eid() or '%s_encoding' % self.name in entity):
+ return form.edited_entity.cw_attr_metadata(self.name, 'encoding')
+ return form._cw.encoding
+
+ def form_init(self, form):
+ """Method called at form initialization to trigger potential field
+ initialization requiring the form instance. Do nothing by default.
+ """
+ pass
+
+ def has_been_modified(self, form):
+ for field in self.actual_fields(form):
+ if field._has_been_modified(form):
+ return True # XXX
+ return False # not modified
+
+ def _has_been_modified(self, form):
+ # fields not corresponding to an entity attribute / relations
+ # are considered modified
+ if not self.eidparam or not self.role or not form.edited_entity.has_eid():
+ return True # XXX
+ try:
+ if self.role == 'subject':
+ previous_value = getattr(form.edited_entity, self.name)
+ else:
+ previous_value = getattr(form.edited_entity,
+ 'reverse_%s' % self.name)
+ except AttributeError:
+ # fields with eidparam=True but not corresponding to an actual
+ # attribute or relation
+ return True
+ # if it's a non final relation, we need the eids
+ if isinstance(previous_value, (list, tuple)):
+ # widget should return a set of untyped eids
+ previous_value = set(e.eid for e in previous_value)
+ try:
+ new_value = self.process_form_value(form)
+ except ProcessFormError:
+ return True
+ except UnmodifiedField:
+ return False # not modified
+ if previous_value == new_value:
+ return False # not modified
+ return True
+
+ def process_form_value(self, form):
+ """Return the correctly typed value posted for this field."""
+ try:
+ return form.formvalues[(self, form)]
+ except KeyError:
+ value = form.formvalues[(self, form)] = self._process_form_value(form)
+ return value
+
+ def _process_form_value(self, form):
+ widget = self.get_widget(form)
+ value = widget.process_field_data(form, self)
+ return self._ensure_correctly_typed(form, value)
+
+ def _ensure_correctly_typed(self, form, value):
+ """widget might to return date as a correctly formatted string or as
+ correctly typed objects, but process_for_value must return a typed value.
+ Override this method to type the value if necessary
+ """
+ return value or None
+
+ def process_posted(self, form):
+ """Return an iterator on (field, value) that has been posted for
+ field returned by :meth:`~cubicweb.web.formfields.Field.actual_fields`.
+ """
+ for field in self.actual_fields(form):
+ if field is self:
+ try:
+ value = field.process_form_value(form)
+ if field.no_value(value) and field.required:
+ raise ProcessFormError(form._cw._("required field"))
+ yield field, value
+ except UnmodifiedField:
+ continue
+ else:
+ # recursive function: we might have compound fields
+ # of compound fields (of compound fields of ...)
+ for field, value in field.process_posted(form):
+ yield field, value
+
+ @staticmethod
+ def no_value(value):
+ """return True if the value can be considered as no value for the field"""
+ return value is None
+
+
+class StringField(Field):
+ """Use this field to edit unicode string (`String` yams type). This field
+ additionally support a `max_length` attribute that specify a maximum size for
+ the string (`None` meaning no limit).
+
+ Unless explicitly specified, the widget for this field will be:
+
+ * :class:`~cubicweb.web.formwidgets.Select` if some vocabulary is specified
+ using `choices` attribute
+
+ * :class:`~cubicweb.web.formwidgets.TextInput` if maximum size is specified
+ using `max_length` attribute and this length is inferior to 257.
+
+ * :class:`~cubicweb.web.formwidgets.TextArea` in all other cases
+ """
+ widget = fw.TextArea
+ size = 45
+ placeholder = None
+
+ def __init__(self, name=None, max_length=None, **kwargs):
+ self.max_length = max_length # must be set before super call
+ super(StringField, self).__init__(name=name, **kwargs)
+
+ def init_widget(self, widget):
+ if widget is None:
+ if self.choices:
+ widget = fw.Select()
+ elif self.max_length and self.max_length < 257:
+ widget = fw.TextInput()
+
+ super(StringField, self).init_widget(widget)
+ if isinstance(self.widget, fw.TextArea):
+ self.init_text_area(self.widget)
+ elif isinstance(self.widget, fw.TextInput):
+ self.init_text_input(self.widget)
+
+ if self.placeholder:
+ self.widget.attrs.setdefault('placeholder', self.placeholder)
+
+ def init_text_input(self, widget):
+ if self.max_length:
+ widget.attrs.setdefault('size', min(self.size, self.max_length))
+ widget.attrs.setdefault('maxlength', self.max_length)
+
+ def init_text_area(self, widget):
+ if self.max_length and self.max_length < 513:
+ widget.attrs.setdefault('cols', 60)
+ widget.attrs.setdefault('rows', 5)
+
+ def set_placeholder(self, placeholder):
+ self.placeholder = placeholder
+ if self.widget and self.placeholder:
+ self.widget.attrs.setdefault('placeholder', self.placeholder)
+
+
+class PasswordField(StringField):
+ """Use this field to edit password (`Password` yams type, encoded python
+ string).
+
+ Unless explicitly specified, the widget for this field will be
+ a :class:`~cubicweb.web.formwidgets.PasswordInput`.
+ """
+ widget = fw.PasswordInput
+ def form_init(self, form):
+ if self.eidparam and form.edited_entity.has_eid():
+ # see below: value is probably set but we can't retreive it. Ensure
+ # the field isn't show as a required field on modification
+ self.required = False
+
+ def typed_value(self, form, load_bytes=False):
+ if self.eidparam:
+ # no way to fetch actual password value with cw
+ if form.edited_entity.has_eid():
+ return ''
+ return self.initial_typed_value(form, load_bytes)
+ return super(PasswordField, self).typed_value(form, load_bytes)
+
+
+class RichTextField(StringField):
+ """This compound field allow edition of text (unicode string) in
+ a particular format. It has an inner field holding the text format,
+ that can be specified using `format_field` argument. If not specified
+ one will be automaticall generated.
+
+ Unless explicitly specified, the widget for this field will be a
+ :class:`~cubicweb.web.formwidgets.FCKEditor` or a
+ :class:`~cubicweb.web.formwidgets.TextArea`. according to the field's
+ format and to user's preferences.
+ """
+
+ widget = None
+ def __init__(self, format_field=None, **kwargs):
+ super(RichTextField, self).__init__(**kwargs)
+ self.format_field = format_field
+
+ def init_text_area(self, widget):
+ pass
+
+ def get_widget(self, form):
+ if self.widget is None:
+ if self.use_fckeditor(form):
+ return fw.FCKEditor()
+ widget = fw.TextArea()
+ self.init_text_area(widget)
+ return widget
+ return self.widget
+
+ def get_format_field(self, form):
+ if self.format_field:
+ return self.format_field
+ # we have to cache generated field since it's use as key in the
+ # context dictionary
+ req = form._cw
+ try:
+ return req.data[self]
+ except KeyError:
+ fkwargs = {'eidparam': self.eidparam, 'role': self.role}
+ if self.use_fckeditor(form):
+ # if fckeditor is used and format field isn't explicitly
+ # deactivated, we want an hidden field for the format
+ fkwargs['widget'] = fw.HiddenInput()
+ fkwargs['value'] = 'text/html'
+ else:
+ # else we want a format selector
+ fkwargs['widget'] = fw.Select()
+ fcstr = FormatConstraint()
+ fkwargs['choices'] = fcstr.vocabulary(form=form)
+ fkwargs['internationalizable'] = True
+ fkwargs['value'] = self.format
+ fkwargs['eidparam'] = self.eidparam
+ field = StringField(name=self.name + '_format', **fkwargs)
+ req.data[self] = field
+ return field
+
+ def actual_fields(self, form):
+ yield self
+ format_field = self.get_format_field(form)
+ if format_field:
+ yield format_field
+
+ def use_fckeditor(self, form):
+ """return True if fckeditor should be used to edit entity's attribute named
+ `attr`, according to user preferences
+ """
+ if form._cw.use_fckeditor():
+ return self.format(form) == 'text/html'
+ return False
+
+ def render(self, form, renderer):
+ format_field = self.get_format_field(form)
+ if format_field:
+ # XXX we want both fields to remain vertically aligned
+ if format_field.is_visible():
+ format_field.widget.attrs['style'] = 'display: block'
+ result = format_field.render(form, renderer)
+ else:
+ result = u''
+ return result + self.get_widget(form).render(form, self, renderer)
+
+
+class FileField(StringField):
+ """This compound field allow edition of binary stream (`Bytes` yams
+ type). Three inner fields may be specified:
+
+ * `format_field`, holding the file's format.
+ * `encoding_field`, holding the file's content encoding.
+ * `name_field`, holding the file's name.
+
+ Unless explicitly specified, the widget for this field will be a
+ :class:`~cubicweb.web.formwidgets.FileInput`. Inner fields, if any,
+ will be added to a drop down menu at the right of the file input.
+ """
+ widget = fw.FileInput
+ needs_multipart = True
+
+ def __init__(self, format_field=None, encoding_field=None, name_field=None,
+ **kwargs):
+ super(FileField, self).__init__(**kwargs)
+ self.format_field = format_field
+ self.encoding_field = encoding_field
+ self.name_field = name_field
+
+ def actual_fields(self, form):
+ yield self
+ if self.format_field:
+ yield self.format_field
+ if self.encoding_field:
+ yield self.encoding_field
+ if self.name_field:
+ yield self.name_field
+
+ def typed_value(self, form, load_bytes=False):
+ if self.eidparam and self.role is not None:
+ if form.edited_entity.has_eid():
+ if load_bytes:
+ return getattr(form.edited_entity, self.name)
+ # don't actually load data
+ # XXX value should reflect if some file is already attached
+ # * try to display name metadata
+ # * check length(data) / data != null
+ return True
+ return False
+ return super(FileField, self).typed_value(form, load_bytes)
+
+ def render(self, form, renderer):
+ wdgs = [self.get_widget(form).render(form, self, renderer)]
+ if self.format_field or self.encoding_field:
+ divid = '%s-advanced' % self.input_name(form)
+ wdgs.append(u'<a href="%s" title="%s"><img src="%s" alt="%s"/></a>' %
+ (xml_escape(uilib.toggle_action(divid)),
+ form._cw._('show advanced fields'),
+ xml_escape(form._cw.data_url('puce_down.png')),
+ form._cw._('show advanced fields')))
+ wdgs.append(u'<div id="%s" class="hidden">' % divid)
+ if self.name_field:
+ wdgs.append(self.render_subfield(form, self.name_field, renderer))
+ if self.format_field:
+ wdgs.append(self.render_subfield(form, self.format_field, renderer))
+ if self.encoding_field:
+ wdgs.append(self.render_subfield(form, self.encoding_field, renderer))
+ wdgs.append(u'</div>')
+ if not self.required and self.typed_value(form):
+ # trick to be able to delete an uploaded file
+ wdgs.append(u'<br/>')
+ wdgs.append(tags.input(name=self.input_name(form, u'__detach'),
+ type=u'checkbox'))
+ wdgs.append(form._cw._('detach attached file'))
+ return u'\n'.join(wdgs)
+
+ def render_subfield(self, form, field, renderer):
+ return (renderer.render_label(form, field)
+ + field.render(form, renderer)
+ + renderer.render_help(form, field)
+ + u'<br/>')
+
+ def _process_form_value(self, form):
+ posted = form._cw.form
+ if self.input_name(form, u'__detach') in posted:
+ # drop current file value on explictily asked to detach
+ return None
+ try:
+ value = posted[self.input_name(form)]
+ except KeyError:
+ # raise UnmodifiedField instead of returning None, since the later
+ # will try to remove already attached file if any
+ raise UnmodifiedField()
+ # value is a 2-uple (filename, stream) or a list of such
+ # tuples (multiple files)
+ try:
+ if isinstance(value, list):
+ value = value[0]
+ form.warning('mutiple files provided, however '
+ 'only the first will be picked')
+ filename, stream = value
+ except ValueError:
+ raise UnmodifiedField()
+ # XXX avoid in memory loading of posted files. Requires Binary handling changes...
+ value = Binary(stream.read())
+ if not value.getvalue(): # usually an unexistant file
+ value = None
+ else:
+ # set filename on the Binary instance, may be used later in hooks
+ value.filename = normalize_filename(filename)
+ return value
+
+
+# XXX turn into a widget
+class EditableFileField(FileField):
+ """This compound field allow edition of binary stream as
+ :class:`~cubicweb.web.formfields.FileField` but expect that stream to
+ actually contains some text.
+
+ If the stream format is one of text/plain, text/html, text/rest,
+ text/markdown
+ then a :class:`~cubicweb.web.formwidgets.TextArea` will be additionally
+ displayed, allowing to directly the file's content when desired, instead
+ of choosing a file from user's file system.
+ """
+ editable_formats = (
+ 'text/plain', 'text/html', 'text/rest', 'text/markdown')
+
+ def render(self, form, renderer):
+ wdgs = [super(EditableFileField, self).render(form, renderer)]
+ if self.format(form) in self.editable_formats:
+ data = self.typed_value(form, load_bytes=True)
+ if data:
+ encoding = self.encoding(form)
+ try:
+ form.formvalues[(self, form)] = data.getvalue().decode(encoding)
+ except UnicodeError:
+ pass
+ else:
+ if not self.required:
+ msg = form._cw._(
+ 'You can either submit a new file using the browse button above'
+ ', or choose to remove already uploaded file by checking the '
+ '"detach attached file" check-box, or edit file content online '
+ 'with the widget below.')
+ else:
+ msg = form._cw._(
+ 'You can either submit a new file using the browse button above'
+ ', or edit file content online with the widget below.')
+ wdgs.append(u'<p><b>%s</b></p>' % msg)
+ wdgs.append(fw.TextArea(setdomid=False).render(form, self, renderer))
+ # XXX restore form context?
+ return '\n'.join(wdgs)
+
+ def _process_form_value(self, form):
+ value = form._cw.form.get(self.input_name(form))
+ if isinstance(value, text_type):
+ # file modified using a text widget
+ return Binary(value.encode(self.encoding(form)))
+ return super(EditableFileField, self)._process_form_value(form)
+
+
+class BigIntField(Field):
+ """Use this field to edit big integers (`BigInt` yams type). This field
+ additionally support `min` and `max` attributes that specify a minimum and/or
+ maximum value for the integer (`None` meaning no boundary).
+
+ Unless explicitly specified, the widget for this field will be a
+ :class:`~cubicweb.web.formwidgets.TextInput`.
+ """
+ default_text_input_size = 10
+
+ def __init__(self, min=None, max=None, **kwargs):
+ super(BigIntField, self).__init__(**kwargs)
+ self.min = min
+ self.max = max
+
+ def init_widget(self, widget):
+ super(BigIntField, self).init_widget(widget)
+ if isinstance(self.widget, fw.TextInput):
+ self.widget.attrs.setdefault('size', self.default_text_input_size)
+
+ def _ensure_correctly_typed(self, form, value):
+ if isinstance(value, string_types):
+ value = value.strip()
+ if not value:
+ return None
+ try:
+ return int(value)
+ except ValueError:
+ raise ProcessFormError(form._cw._('an integer is expected'))
+ return value
+
+
+class IntField(BigIntField):
+ """Use this field to edit integers (`Int` yams type). Similar to
+ :class:`~cubicweb.web.formfields.BigIntField` but set max length when text
+ input widget is used (the default).
+ """
+ default_text_input_size = 5
+
+ def init_widget(self, widget):
+ super(IntField, self).init_widget(widget)
+ if isinstance(self.widget, fw.TextInput):
+ self.widget.attrs.setdefault('maxlength', 15)
+
+
+class BooleanField(Field):
+ """Use this field to edit booleans (`Boolean` yams type).
+
+ Unless explicitly specified, the widget for this field will be a
+ :class:`~cubicweb.web.formwidgets.Radio` with yes/no values. You
+ can change that values by specifing `choices`.
+ """
+ widget = fw.Radio
+
+ def __init__(self, allow_none=False, **kwargs):
+ super(BooleanField, self).__init__(**kwargs)
+ self.allow_none = allow_none
+
+ def vocabulary(self, form):
+ if self.choices:
+ return super(BooleanField, self).vocabulary(form)
+ if self.allow_none:
+ return [(form._cw._('indifferent'), ''),
+ (form._cw._('yes'), '1'),
+ (form._cw._('no'), '0')]
+ # XXX empty string for 'no' in that case for bw compat
+ return [(form._cw._('yes'), '1'), (form._cw._('no'), '')]
+
+ def format_single_value(self, req, value):
+ """return value suitable for display"""
+ if self.allow_none:
+ if value is None:
+ return u''
+ if value is False:
+ return '0'
+ return super(BooleanField, self).format_single_value(req, value)
+
+ def _ensure_correctly_typed(self, form, value):
+ if self.allow_none:
+ if value:
+ return bool(int(value))
+ return None
+ return bool(value)
+
+
+class FloatField(IntField):
+ """Use this field to edit floats (`Float` yams type). This field additionally
+ support `min` and `max` attributes as the
+ :class:`~cubicweb.web.formfields.IntField`.
+
+ Unless explicitly specified, the widget for this field will be a
+ :class:`~cubicweb.web.formwidgets.TextInput`.
+ """
+ def format_single_value(self, req, value):
+ formatstr = req.property_value('ui.float-format')
+ if value is None:
+ return u''
+ return formatstr % float(value)
+
+ def render_example(self, req):
+ return self.format_single_value(req, 1.234)
+
+ def _ensure_correctly_typed(self, form, value):
+ if isinstance(value, string_types):
+ value = value.strip()
+ if not value:
+ return None
+ try:
+ return float(value)
+ except ValueError:
+ raise ProcessFormError(form._cw._('a float is expected'))
+ return None
+
+
+class TimeIntervalField(StringField):
+ """Use this field to edit time interval (`Interval` yams type).
+
+ Unless explicitly specified, the widget for this field will be a
+ :class:`~cubicweb.web.formwidgets.TextInput`.
+ """
+ widget = fw.TextInput
+
+ def format_single_value(self, req, value):
+ if value:
+ value = format_time(value.days * 24 * 3600 + value.seconds)
+ return text_type(value)
+ return u''
+
+ def example_format(self, req):
+ """return a sample string describing what can be given as input for this
+ field
+ """
+ return u'20s, 10min, 24h, 4d'
+
+ def _ensure_correctly_typed(self, form, value):
+ if isinstance(value, string_types):
+ value = value.strip()
+ if not value:
+ return None
+ try:
+ value = apply_units(value, TIME_UNITS)
+ except ValueError:
+ raise ProcessFormError(form._cw._('a number (in seconds) or 20s, 10min, 24h or 4d are expected'))
+ return timedelta(0, value)
+
+
+class DateField(StringField):
+ """Use this field to edit date (`Date` yams type).
+
+ Unless explicitly specified, the widget for this field will be a
+ :class:`~cubicweb.web.formwidgets.JQueryDatePicker`.
+ """
+ widget = fw.JQueryDatePicker
+ format_prop = 'ui.date-format'
+ etype = 'Date'
+
+ def format_single_value(self, req, value):
+ if value:
+ return ustrftime(value, req.property_value(self.format_prop))
+ return u''
+
+ def render_example(self, req):
+ return self.format_single_value(req, datetime.now())
+
+ def _ensure_correctly_typed(self, form, value):
+ if isinstance(value, string_types):
+ value = value.strip()
+ if not value:
+ return None
+ try:
+ value = form._cw.parse_datetime(value, self.etype)
+ except ValueError as ex:
+ raise ProcessFormError(text_type(ex))
+ return value
+
+
+class DateTimeField(DateField):
+ """Use this field to edit datetime (`Datetime` yams type).
+
+ Unless explicitly specified, the widget for this field will be a
+ :class:`~cubicweb.web.formwidgets.JQueryDateTimePicker`.
+ """
+ widget = fw.JQueryDateTimePicker
+ format_prop = 'ui.datetime-format'
+ etype = 'Datetime'
+
+
+class TimeField(DateField):
+ """Use this field to edit time (`Time` yams type).
+
+ Unless explicitly specified, the widget for this field will be a
+ :class:`~cubicweb.web.formwidgets.JQueryTimePicker`.
+ """
+ widget = fw.JQueryTimePicker
+ format_prop = 'ui.time-format'
+ etype = 'Time'
+
+
+# XXX use cases where we don't actually want a better widget?
+class CompoundField(Field):
+ """This field shouldn't be used directly, it's designed to hold inner
+ fields that should be conceptually groupped together.
+ """
+ def __init__(self, fields, *args, **kwargs):
+ super(CompoundField, self).__init__(*args, **kwargs)
+ self.fields = fields
+
+ def subfields(self, form):
+ return self.fields
+
+ def actual_fields(self, form):
+ # don't add [self] to actual fields, compound field is usually kinda
+ # virtual, all interesting values are in subfield. Skipping it may avoid
+ # error when processed by the editcontroller : it may be marked as required
+ # while it has no value, hence generating a false error.
+ return list(self.fields)
+
+ @property
+ def needs_multipart(self):
+ return any(f.needs_multipart for f in self.fields)
+
+
+class RelationField(Field):
+ """Use this field to edit a relation of an entity.
+
+ Unless explicitly specified, the widget for this field will be a
+ :class:`~cubicweb.web.formwidgets.Select`.
+ """
+
+ @staticmethod
+ def fromcardinality(card, **kwargs):
+ kwargs.setdefault('widget', fw.Select(multiple=card in '*+'))
+ return RelationField(**kwargs)
+
+ def choices(self, form, limit=None):
+ """Take care, choices function for relation field instance should take
+ an extra 'limit' argument, with default to None.
+
+ This argument is used by the 'unrelateddivs' view (see in autoform) and
+ when it's specified (eg not None), vocabulary returned should:
+ * not include already related entities
+ * have a max size of `limit` entities
+ """
+ entity = form.edited_entity
+ # first see if its specified by __linkto form parameters
+ if limit is None:
+ linkedto = self.relvoc_linkedto(form)
+ if linkedto:
+ return linkedto
+ # it isn't, search more vocabulary
+ vocab = self.relvoc_init(form)
+ else:
+ vocab = []
+ vocab += self.relvoc_unrelated(form, limit)
+ if self.sort:
+ vocab = vocab_sort(vocab)
+ return vocab
+
+ def relvoc_linkedto(self, form):
+ linkedto = form.linked_to.get((self.name, self.role))
+ if linkedto:
+ buildent = form._cw.entity_from_eid
+ return [(buildent(eid).view('combobox'), text_type(eid))
+ for eid in linkedto]
+ return []
+
+ def relvoc_init(self, form):
+ entity, rtype, role = form.edited_entity, self.name, self.role
+ vocab = []
+ if not self.required:
+ vocab.append(('', INTERNAL_FIELD_VALUE))
+ # vocabulary doesn't include current values, add them
+ if form.edited_entity.has_eid():
+ rset = form.edited_entity.related(self.name, self.role)
+ vocab += [(e.view('combobox'), text_type(e.eid))
+ for e in rset.entities()]
+ return vocab
+
+ def relvoc_unrelated(self, form, limit=None):
+ entity = form.edited_entity
+ rtype = entity._cw.vreg.schema.rschema(self.name)
+ if entity.has_eid():
+ done = set(row[0] for row in entity.related(rtype, self.role))
+ else:
+ done = None
+ result = []
+ rsetsize = None
+ for objtype in rtype.targets(entity.e_schema, self.role):
+ if limit is not None:
+ rsetsize = limit - len(result)
+ result += self._relvoc_unrelated(form, objtype, rsetsize, done)
+ if limit is not None and len(result) >= limit:
+ break
+ return result
+
+ def _relvoc_unrelated(self, form, targettype, limit, done):
+ """return unrelated entities for a given relation and target entity type
+ for use in vocabulary
+ """
+ if done is None:
+ done = set()
+ res = []
+ entity = form.edited_entity
+ for entity in entity.unrelated(self.name, targettype, self.role, limit,
+ lt_infos=form.linked_to).entities():
+ if entity.eid in done:
+ continue
+ done.add(entity.eid)
+ res.append((entity.view('combobox'), text_type(entity.eid)))
+ return res
+
+ def format_single_value(self, req, value):
+ return text_type(value)
+
+ def process_form_value(self, form):
+ """process posted form and return correctly typed value"""
+ try:
+ return form.formvalues[(self, form)]
+ except KeyError:
+ value = self._process_form_value(form)
+ # if value is None, there are some remaining pending fields, we'll
+ # have to recompute this later -> don't cache in formvalues
+ if value is not None:
+ form.formvalues[(self, form)] = value
+ return value
+
+ def _process_form_value(self, form):
+ """process posted form and return correctly typed value"""
+ widget = self.get_widget(form)
+ values = widget.process_field_data(form, self)
+ if values is None:
+ values = ()
+ elif not isinstance(values, list):
+ values = (values,)
+ eids = set()
+ rschema = form._cw.vreg.schema.rschema(self.name)
+ for eid in values:
+ if not eid or eid == INTERNAL_FIELD_VALUE:
+ continue
+ typed_eid = form.actual_eid(eid)
+ # if entity doesn't exist yet
+ if typed_eid is None:
+ # inlined relations of to-be-created **subject entities** have
+ # to be handled separatly
+ if self.role == 'object' and rschema.inlined:
+ form._cw.data['pending_inlined'][eid].add( (form, self) )
+ else:
+ form._cw.data['pending_others'].add( (form, self) )
+ return None
+ eids.add(typed_eid)
+ return eids
+
+ @staticmethod
+ def no_value(value):
+ """return True if the value can be considered as no value for the field"""
+ # value is None is the 'not yet ready value, consider the empty set
+ return value is not None and not value
+
+
+_AFF_KWARGS = uicfg.autoform_field_kwargs
+
+def guess_field(eschema, rschema, role='subject', req=None, **kwargs):
+ """This function return the most adapted field to edit the given relation
+ (`rschema`) where the given entity type (`eschema`) is the subject or object
+ (`role`).
+
+ The field is initialized according to information found in the schema,
+ though any value can be explicitly specified using `kwargs`.
+ """
+ fieldclass = None
+ rdef = eschema.rdef(rschema, role)
+ if role == 'subject':
+ targetschema = rdef.object
+ if rschema.final:
+ if rdef.get('internationalizable'):
+ kwargs.setdefault('internationalizable', True)
+ else:
+ targetschema = rdef.subject
+ card = rdef.role_cardinality(role)
+ kwargs['name'] = rschema.type
+ kwargs['role'] = role
+ kwargs['eidparam'] = True
+ kwargs.setdefault('required', card in '1+')
+ if role == 'object':
+ kwargs.setdefault('label', (eschema.type, rschema.type + '_object'))
+ else:
+ kwargs.setdefault('label', (eschema.type, rschema.type))
+ kwargs.setdefault('help', rdef.description)
+ if rschema.final:
+ fieldclass = FIELDS[targetschema]
+ if fieldclass is StringField:
+ if eschema.has_metadata(rschema, 'format'):
+ # use RichTextField instead of StringField if the attribute has
+ # a "format" metadata. But getting information from constraints
+ # may be useful anyway...
+ for cstr in rdef.constraints:
+ if isinstance(cstr, StaticVocabularyConstraint):
+ raise Exception('rich text field with static vocabulary')
+ return RichTextField(**kwargs)
+ # init StringField parameters according to constraints
+ for cstr in rdef.constraints:
+ if isinstance(cstr, StaticVocabularyConstraint):
+ kwargs.setdefault('choices', cstr.vocabulary)
+ break
+ for cstr in rdef.constraints:
+ if isinstance(cstr, SizeConstraint) and cstr.max is not None:
+ kwargs['max_length'] = cstr.max
+ return StringField(**kwargs)
+ if fieldclass is FileField:
+ if req:
+ aff_kwargs = req.vreg['uicfg'].select('autoform_field_kwargs', req)
+ else:
+ aff_kwargs = _AFF_KWARGS
+ for metadata in KNOWN_METAATTRIBUTES:
+ metaschema = eschema.has_metadata(rschema, metadata)
+ if metaschema is not None:
+ metakwargs = aff_kwargs.etype_get(eschema, metaschema, 'subject')
+ kwargs['%s_field' % metadata] = guess_field(eschema, metaschema,
+ req=req, **metakwargs)
+ return fieldclass(**kwargs)
+ return RelationField.fromcardinality(card, **kwargs)
+
+
+FIELDS = {
+ 'String' : StringField,
+ 'Bytes': FileField,
+ 'Password': PasswordField,
+
+ 'Boolean': BooleanField,
+ 'Int': IntField,
+ 'BigInt': BigIntField,
+ 'Float': FloatField,
+ 'Decimal': StringField,
+
+ 'Date': DateField,
+ 'Datetime': DateTimeField,
+ 'TZDatetime': DateTimeField,
+ 'Time': TimeField,
+ 'TZTime': TimeField,
+ 'Interval': TimeIntervalField,
+ }