diff -r ea9eab290dcd -r e8032965f37a web/views/forms.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/views/forms.py Fri May 29 14:19:30 2009 +0200 @@ -0,0 +1,524 @@ +"""some base form classes for CubicWeb web client + +:organization: Logilab +:copyright: 2001-2009 LOGILAB S.A. (Paris, FRANCE), license is LGPL v2. +:contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr +:license: GNU Lesser General Public License, v2.1 - http://www.gnu.org/licenses +""" +__docformat__ = "restructuredtext en" + +from warnings import warn + +from logilab.common.compat import any +from logilab.common.decorators import iclassmethod + +from cubicweb.selectors import non_final_entity, match_kwargs, one_line_rset +from cubicweb.web import INTERNAL_FIELD_VALUE, eid_param +from cubicweb.web import form, formwidgets as fwdgs +from cubicweb.web.controller import NAV_FORM_PARAMETERS +from cubicweb.web.formfields import HiddenInitialValueField, StringField + + +class FieldsForm(form.Form): + id = 'base' + + is_subform = False + + # attributes overrideable through __init__ + internal_fields = ('__errorurl',) + NAV_FORM_PARAMETERS + needs_js = ('cubicweb.ajax.js', 'cubicweb.edition.js',) + needs_css = ('cubicweb.form.css',) + domid = 'form' + title = None + action = None + onsubmit = "return freezeFormButtons('%(domid)s');" + cssclass = None + cssstyle = None + cwtarget = None + redirect_path = None + set_error_url = True + copy_nav_params = False + form_buttons = None # form buttons (button widgets instances) + form_renderer_id = 'default' + + def __init__(self, req, rset=None, row=None, col=None, submitmsg=None, + **kwargs): + super(FieldsForm, self).__init__(req, rset, row=row, col=col) + self.fields = list(self.__class__._fields_) + for key, val in kwargs.items(): + if key in NAV_FORM_PARAMETERS: + self.form_add_hidden(key, val) + else: + assert hasattr(self.__class__, key) and not key[0] == '_', key + setattr(self, key, val) + if self.set_error_url: + self.form_add_hidden('__errorurl', self.session_key()) + if self.copy_nav_params: + for param in NAV_FORM_PARAMETERS: + if not param in kwargs: + value = req.form.get(param) + if value: + self.form_add_hidden(param, value) + if submitmsg is not None: + self.form_add_hidden('__message', submitmsg) + self.context = None + if 'domid' in kwargs:# session key changed + self.restore_previous_post(self.session_key()) + + @iclassmethod + def _fieldsattr(cls_or_self): + if isinstance(cls_or_self, type): + fields = cls_or_self._fields_ + else: + fields = cls_or_self.fields + return fields + + @iclassmethod + def field_by_name(cls_or_self, name, role='subject'): + """return field with the given name and role. + Raise FieldNotFound if the field can't be found. + """ + for field in cls_or_self._fieldsattr(): + if field.name == name and field.role == role: + return field + raise form.FieldNotFound(name) + + @iclassmethod + def fields_by_name(cls_or_self, name, role='subject'): + """return a list of fields with the given name and role""" + return [field for field in cls_or_self._fieldsattr() + if field.name == name and field.role == role] + + @iclassmethod + def remove_field(cls_or_self, field): + """remove a field from form class or instance""" + cls_or_self._fieldsattr().remove(field) + + @iclassmethod + def append_field(cls_or_self, field): + """append a field to form class or instance""" + cls_or_self._fieldsattr().append(field) + + @iclassmethod + def insert_field_before(cls_or_self, new_field, name, role='subject'): + field = cls_or_self.field_by_name(name, role) + fields = cls_or_self._fieldsattr() + fields.insert(fields.index(field), new_field) + + @iclassmethod + def insert_field_after(cls_or_self, new_field, name, role='subject'): + field = cls_or_self.field_by_name(name, role) + fields = cls_or_self._fieldsattr() + fields.insert(fields.index(field)+1, new_field) + + @property + def form_needs_multipart(self): + """true if the form needs enctype=multipart/form-data""" + return any(field.needs_multipart for field in self.fields) + + def form_add_hidden(self, name, value=None, **kwargs): + """add an hidden field to the form""" + field = StringField(name=name, widget=fwdgs.HiddenInput, initial=value, + **kwargs) + if 'id' in kwargs: + # by default, hidden input don't set id attribute. If one is + # explicitly specified, ensure it will be set + field.widget.setdomid = True + self.append_field(field) + return field + + def add_media(self): + """adds media (CSS & JS) required by this widget""" + if self.needs_js: + self.req.add_js(self.needs_js) + if self.needs_css: + self.req.add_css(self.needs_css) + + def form_render(self, **values): + """render this form, using the renderer given in args or the default + FormRenderer() + """ + renderer = values.pop('renderer', None) + if renderer is None: + renderer = self.form_default_renderer() + return renderer.render(self, values) + + def form_default_renderer(self): + return self.vreg.select_object('formrenderers', self.form_renderer_id, + self.req, self.rset, + row=self.row, col=self.col) + + def form_build_context(self, rendervalues=None): + """build form context values (the .context attribute which is a + dictionary with field instance as key associated to a dictionary + containing field 'name' (qualified), 'id', 'value' (for display, always + a string). + + rendervalues is an optional dictionary containing extra kwargs given to + form_render() + """ + self.context = context = {} + # ensure rendervalues is a dict + if rendervalues is None: + rendervalues = {} + # use a copy in case fields are modified while context is build (eg + # __linkto handling for instance) + for field in self.fields[:]: + for field in field.actual_fields(self): + field.form_init(self) + value = self.form_field_display_value(field, rendervalues) + context[field] = {'value': value, + 'name': self.form_field_name(field), + 'id': self.form_field_id(field), + } + + def form_field_display_value(self, field, rendervalues, load_bytes=False): + """return field's *string* value to use for display + + looks in + 1. previously submitted form values if any (eg on validation error) + 2. req.form + 3. extra kw args given to render_form + 4. field's typed value + + values found in 1. and 2. are expected te be already some 'display' + value while those found in 3. and 4. are expected to be correctly typed. + """ + value = self._req_display_value(field) + if value is None: + if field.name in rendervalues: + value = rendervalues[field.name] + else: + value = self.form_field_value(field, load_bytes) + if callable(value): + value = value(self) + if value != INTERNAL_FIELD_VALUE: + value = field.format_value(self.req, value) + return value + + def _req_display_value(self, field): + qname = self.form_field_name(field) + if qname in self.form_previous_values: + return self.form_previous_values[qname] + if qname in self.req.form: + return self.req.form[qname] + if field.name in self.req.form: + return self.req.form[field.name] + return None + + def form_field_value(self, field, load_bytes=False): + """return field's *typed* value""" + myattr = '%s_%s_default' % (field.role, field.name) + if hasattr(self, myattr): + return getattr(self, myattr)() + value = field.initial + if callable(value): + value = value(self) + return value + + def form_field_error(self, field): + """return validation error for widget's field, if any""" + if self._field_has_error(field): + self.form_displayed_errors.add(field.name) + return u'%s' % self.form_valerror.errors[field.name] + return u'' + + def form_field_format(self, field): + """return MIME type used for the given (text or bytes) field""" + return self.req.property_value('ui.default-text-format') + + def form_field_encoding(self, field): + """return encoding used for the given (text) field""" + return self.req.encoding + + def form_field_name(self, field): + """return qualified name for the given field""" + return field.name + + def form_field_id(self, field): + """return dom id for the given field""" + return field.id + + def form_field_vocabulary(self, field, limit=None): + """return vocabulary for the given field. Should be overriden in + specific forms using fields which requires some vocabulary + """ + raise NotImplementedError + + def _field_has_error(self, field): + """return true if the field has some error in given validation exception + """ + return self.form_valerror and field.name in self.form_valerror.errors + + +class EntityFieldsForm(FieldsForm): + id = 'base' + __select__ = (match_kwargs('entity') | (one_line_rset & non_final_entity())) + + internal_fields = FieldsForm.internal_fields + ('__type', 'eid', '__maineid') + domid = 'entityForm' + + def __init__(self, *args, **kwargs): + self.edited_entity = kwargs.pop('entity', None) + msg = kwargs.pop('submitmsg', None) + super(EntityFieldsForm, self).__init__(*args, **kwargs) + if self.edited_entity is None: + self.edited_entity = self.complete_entity(self.row or 0, self.col or 0) + self.form_add_hidden('__type', eidparam=True) + self.form_add_hidden('eid') + if msg: + # If we need to directly attach the new object to another one + self.form_add_hidden('__message', msg) + if not self.is_subform: + for linkto in self.req.list_form_param('__linkto'): + self.form_add_hidden('__linkto', linkto) + msg = '%s %s' % (msg, self.req._('and linked')) + + def _field_has_error(self, field): + """return true if the field has some error in given validation exception + """ + return super(EntityFieldsForm, self)._field_has_error(field) \ + and self.form_valerror.eid == self.edited_entity.eid + + def _relation_vocabulary(self, rtype, targettype, role, + limit=None, done=None): + """return unrelated entities for a given relation and target entity type + for use in vocabulary + """ + if done is None: + done = set() + rset = self.edited_entity.unrelated(rtype, targettype, role, limit) + res = [] + for entity in rset.entities(): + if entity.eid in done: + continue + done.add(entity.eid) + res.append((entity.view('combobox'), entity.eid)) + return res + + def _req_display_value(self, field): + value = super(EntityFieldsForm, self)._req_display_value(field) + if value is None: + value = self.edited_entity.linked_to(field.name, field.role) + if value: + searchedvalues = ['%s:%s:%s' % (field.name, eid, field.role) + for eid in value] + # remove associated __linkto hidden fields + for field in self.fields_by_name('__linkto'): + if field.initial in searchedvalues: + self.remove_field(field) + else: + value = None + return value + + def _form_field_default_value(self, field, load_bytes): + defaultattr = 'default_%s' % field.name + if hasattr(self.edited_entity, defaultattr): + # XXX bw compat, default_ on the entity + warn('found %s on %s, should be set on a specific form' + % (defaultattr, self.edited_entity.id), DeprecationWarning) + value = getattr(self.edited_entity, defaultattr) + if callable(value): + value = value() + else: + value = super(EntityFieldsForm, self).form_field_value(field, + load_bytes) + return value + + def form_default_renderer(self): + return self.vreg.select_object('formrenderers', self.form_renderer_id, + self.req, self.rset, + row=self.row, col=self.col, + entity=self.edited_entity) + + def form_build_context(self, values=None): + """overriden to add edit[s|o] hidden fields and to ensure schema fields + have eidparam set to True + + edit[s|o] hidden fields are used to indicate the value for the + associated field before the (potential) modification made when + submitting the form. + """ + eschema = self.edited_entity.e_schema + for field in self.fields[:]: + for field in field.actual_fields(self): + fieldname = field.name + if fieldname != 'eid' and ( + (eschema.has_subject_relation(fieldname) or + eschema.has_object_relation(fieldname))): + field.eidparam = True + self.fields.append(HiddenInitialValueField(field)) + return super(EntityFieldsForm, self).form_build_context(values) + + def form_field_value(self, field, load_bytes=False): + """return field's *typed* value + + overriden to deal with + * special eid / __type / edits- / edito- fields + * lookup for values on edited entities + """ + attr = field.name + entity = self.edited_entity + if attr == 'eid': + return entity.eid + if not field.eidparam: + return super(EntityFieldsForm, self).form_field_value(field, load_bytes) + if attr.startswith('edits-') or attr.startswith('edito-'): + # edit[s|o]- fieds must have the actual value stored on the entity + assert hasattr(field, 'visible_field') + vfield = field.visible_field + assert vfield.eidparam + if entity.has_eid(): + return self.form_field_value(vfield) + return INTERNAL_FIELD_VALUE + if attr == '__type': + return entity.id + if self.schema.rschema(attr).is_final(): + attrtype = entity.e_schema.destination(attr) + if attrtype == 'Password': + return entity.has_eid() and INTERNAL_FIELD_VALUE or '' + if attrtype == 'Bytes': + if entity.has_eid(): + if load_bytes: + return getattr(entity, attr) + # XXX value should reflect if some file is already attached + return True + return False + if entity.has_eid() or attr in entity: + value = getattr(entity, attr) + else: + value = self._form_field_default_value(field, load_bytes) + return value + # non final relation field + if entity.has_eid() or entity.relation_cached(attr, field.role): + value = [r[0] for r in entity.related(attr, field.role)] + else: + value = self._form_field_default_value(field, load_bytes) + return value + + def form_field_format(self, field): + """return MIME type used for the given (text or bytes) field""" + entity = self.edited_entity + if field.eidparam and entity.e_schema.has_metadata(field.name, 'format') and ( + entity.has_eid() or '%s_format' % field.name in entity): + return self.edited_entity.attr_metadata(field.name, 'format') + return self.req.property_value('ui.default-text-format') + + def form_field_encoding(self, field): + """return encoding used for the given (text) field""" + entity = self.edited_entity + if field.eidparam and entity.e_schema.has_metadata(field.name, 'encoding') and ( + entity.has_eid() or '%s_encoding' % field.name in entity): + return self.edited_entity.attr_metadata(field.name, 'encoding') + return super(EntityFieldsForm, self).form_field_encoding(field) + + def form_field_name(self, field): + """return qualified name for the given field""" + if field.eidparam: + return eid_param(field.name, self.edited_entity.eid) + return field.name + + def form_field_id(self, field): + """return dom id for the given field""" + if field.eidparam: + return eid_param(field.id, self.edited_entity.eid) + return field.id + + def form_field_vocabulary(self, field, limit=None): + """return vocabulary for the given field""" + role, rtype = field.role, field.name + method = '%s_%s_vocabulary' % (role, rtype) + try: + vocabfunc = getattr(self, method) + except AttributeError: + try: + # XXX bw compat, __vocabulary on the entity + vocabfunc = getattr(self.edited_entity, method) + except AttributeError: + vocabfunc = getattr(self, '%s_relation_vocabulary' % role) + else: + warn('found %s on %s, should be set on a specific form' + % (method, self.edited_entity.id), DeprecationWarning) + # NOTE: it is the responsibility of `vocabfunc` to sort the result + # (direclty through RQL or via a python sort). This is also + # important because `vocabfunc` might return a list with + # couples (label, None) which act as separators. In these + # cases, it doesn't make sense to sort results afterwards. + return vocabfunc(rtype, limit) + + def subject_relation_vocabulary(self, rtype, limit=None): + """defaut vocabulary method for the given relation, looking for + relation's object entities (i.e. self is the subject) + """ + entity = self.edited_entity + if isinstance(rtype, basestring): + rtype = entity.schema.rschema(rtype) + done = None + assert not rtype.is_final(), rtype + if entity.has_eid(): + done = set(e.eid for e in getattr(entity, str(rtype))) + result = [] + rsetsize = None + for objtype in rtype.objects(entity.e_schema): + if limit is not None: + rsetsize = limit - len(result) + result += self._relation_vocabulary(rtype, objtype, 'subject', + rsetsize, done) + if limit is not None and len(result) >= limit: + break + return result + + def object_relation_vocabulary(self, rtype, limit=None): + """defaut vocabulary method for the given relation, looking for + relation's subject entities (i.e. self is the object) + """ + entity = self.edited_entity + if isinstance(rtype, basestring): + rtype = entity.schema.rschema(rtype) + done = None + if entity.has_eid(): + done = set(e.eid for e in getattr(entity, 'reverse_%s' % rtype)) + result = [] + rsetsize = None + for subjtype in rtype.subjects(entity.e_schema): + if limit is not None: + rsetsize = limit - len(result) + result += self._relation_vocabulary(rtype, subjtype, 'object', + rsetsize, done) + if limit is not None and len(result) >= limit: + break + return result + + def subject_in_state_vocabulary(self, rtype, limit=None): + """vocabulary method for the in_state relation, looking for relation's + object entities (i.e. self is the subject) according to initial_state, + state_of and next_state relation + """ + entity = self.edited_entity + if not entity.has_eid() or not entity.in_state: + # get the initial state + rql = 'Any S where S state_of ET, ET name %(etype)s, ET initial_state S' + rset = self.req.execute(rql, {'etype': str(entity.e_schema)}) + if rset: + return [(rset.get_entity(0, 0).view('combobox'), rset[0][0])] + return [] + results = [] + for tr in entity.in_state[0].transitions(entity): + state = tr.destination_state[0] + results.append((state.view('combobox'), state.eid)) + return sorted(results) + + +class CompositeForm(FieldsForm): + """form composed for sub-forms""" + id = 'composite' + form_renderer_id = id + + def __init__(self, *args, **kwargs): + super(CompositeForm, self).__init__(*args, **kwargs) + self.forms = [] + + def form_add_subform(self, subform): + """mark given form as a subform and append it""" + subform.is_subform = True + self.forms.append(subform)