diff -r 058bb3dc685f -r 0b59724cb3f2 cubicweb/web/formfields.py --- /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 . +""" +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 - 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'%s' % + (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'') + if not self.required and self.typed_value(form): + # trick to be able to delete an uploaded file + wdgs.append(u'
') + 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'
') + + 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'

%s

' % 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, + }