Source code for cubicweb.web.formfields

# copyright 2003-2016 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# contact https://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 <https://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.TZDatetimeField()
.. 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

"""


from datetime import datetime, timedelta

import pytz

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, neg_role
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()


[docs]class Field: """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 and the field 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 = ["<%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("{}={!r}".format(attr, value)) if repr: l.append("@%#x" % id(self)) return "%s>" % " ".join(l) def __str__(self): return self.as_string(False) 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)
[docs] 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 "" if value is True: return "1" return str(value) def get_widget(self, form): """return the widget instance associated to this field""" return self.widget
[docs] 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
[docs] 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
[docs] 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 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 "" 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
[docs] def form_init(self, form): """Method called at form initialization to trigger potential field initialization requiring the form instance. Do nothing by default. """
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 = {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
[docs] 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 return e.g. a 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
[docs] 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 ...) yield from field.process_posted(form)
@staticmethod def no_value(value): """return True if the value can be considered as no value for the field""" return value is None
[docs]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().__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().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)
[docs]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().typed_value(form, load_bytes)
[docs]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().__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 = "" return result + self.get_widget(form).render(form, self, renderer)
[docs]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().__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().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( '<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('<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("</div>") if not self.required and self.typed_value(form): # trick to be able to delete an uploaded file wdgs.append("<br/>") wdgs.append( tags.input(name=self.input_name(form, "__detach"), type="checkbox") ) wdgs.append(form._cw._("detach attached file")) return "\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) + "<br/>" ) def _process_form_value(self, form): posted = form._cw.form if self.input_name(form, "__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().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("<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, str): # file modified using a text widget return Binary(value.encode(self.encoding(form))) return super()._process_form_value(form)
[docs]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().__init__(**kwargs) self.min = min self.max = max def init_widget(self, widget): super().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, str): value = value.strip() if not value: return None try: return int(value) except ValueError: raise ProcessFormError(form._cw._("an integer is expected")) return value
[docs]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().init_widget(widget) if isinstance(self.widget, fw.TextInput): self.widget.attrs.setdefault("maxlength", 15)
[docs]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().__init__(**kwargs) self.allow_none = allow_none def vocabulary(self, form): if self.choices: return super().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 "" if value is False: return "0" return super().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)
[docs]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 "" 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, str): value = value.strip() if not value: return None try: return float(value) except ValueError: raise ProcessFormError(form._cw._("a float is expected")) return None
[docs]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: return format_time(value.days * 24 * 3600 + value.seconds) return "" def example_format(self, req): """return a sample string describing what can be given as input for this field """ return "20s, 10min, 24h, 4d" def _ensure_correctly_typed(self, form, value): if isinstance(value, str): 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)
[docs]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 "" def render_example(self, req): return self.format_single_value(req, datetime.now()) def _ensure_correctly_typed(self, form, value): if isinstance(value, str): value = value.strip() if not value: return None try: value = form._cw.parse_datetime(value, self.etype) except ValueError as ex: raise ProcessFormError(str(ex)) return value
[docs]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"
[docs]class TZDatetimeField(DateTimeField): """Use this field to edit a timezone-aware datetime (`TZDatetime` yams type). Note the posted values are interpreted as UTC, so you may need to convert them client-side, using some javascript in the corresponding widget. """ def _ensure_correctly_typed(self, form, value): tz_naive = super()._ensure_correctly_typed(form, value) if not tz_naive: return None return tz_naive.replace(tzinfo=pytz.utc)
[docs]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?
[docs]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().__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)
[docs]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`. """ @classmethod def fromcardinality(cls, card, **kwargs): kwargs.setdefault("widget", fw.Select(multiple=card in "*+")) return cls(**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"), str(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"), str(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 = {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"), str(entity.eid))) return res def format_single_value(self, req, value): return str(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
[docs]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 # We do not know how to use more than the first rdef of the given rschema: rdef = eschema.rdef(rschema, role, takefirst=True) 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) composite = getattr(rdef, "composite", None) kwargs["name"] = rschema.type kwargs["role"] = role kwargs["eidparam"] = True # don't mark composite relation as required, we want the composite element # to be removed when not linked to its parent kwargs.setdefault("required", card in "1+" and composite != neg_role(role)) 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 = kwargs.pop("fieldclass", FIELDS[targetschema]) if issubclass(fieldclass, 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 ) elif issubclass(fieldclass, 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 fieldclass(**kwargs) else: fieldclass = kwargs.pop("fieldclass", RelationField) return fieldclass.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": TZDatetimeField, "Time": TimeField, "TZTime": TimeField, "Interval": TimeIntervalField, }