web/formfields.py
author Rémi Cardona <remi.cardona@logilab.fr>
Wed, 16 Sep 2015 17:22:41 +0200
changeset 10696 4ba4be5553cf
parent 10612 84468b90e9c1
child 10716 9a9d57edb1c1
permissions -rw-r--r--
[py3k] unicode vs str vs bytes vs the world

# 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 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 potentialy 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)

    def __str__(self):
        return self.as_string(False).encode('UTF8')

    def __repr__(self):
        return self.as_string(True).encode('UTF8')

    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, 'im_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
    additionaly 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 additionaly
    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
    additionaly 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 additionaly
    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,
    }