diff --git a/cms_form/README.rst b/cms_form/README.rst new file mode 100644 index 00000000..6b1f2f90 --- /dev/null +++ b/cms_form/README.rst @@ -0,0 +1,222 @@ +.. image:: https://img.shields.io/badge/licence-lgpl--3-blue.svg + :target: http://www.gnu.org/licenses/LGPL-3.0-standalone.html + :alt: License: LGPL-3 + +CMS Form +======== + +Basic website contents form framework. Allows to define front-end forms for every models in a simple way. + +If you are tired of re-defining every time an edit form or a search form for your odoo website, +this is the module you are looking for. + +Features +======== + +* automatic form generation (create, write, search) +* automatic route generation (create, write, search) +* automatic machinery based on fields' type: + * widget rendering + * field value load (from existing instance or from request) + * field value extraction (from request) + * field value write (to existing instance) + +* highly customizable +* works with every odoo model +* works also without any model +* add handy attributes to models inheriting from ``website.published.mixin``: + * ``cms_add_url``: lead to create form view. By default ``/cms/form/create/my.model`` + * ``cms_edit_url``: lead to edit form view. By default ``/cms/form/edit/my.model/model_id`` + * ``cms_search_url``: lead to search form view. By default ``/cms/form/search/my.model`` + +Usage +===== + +Create / Edit form +------------------ + +Just inherit from ``cms.form`` to add a form for your model. Quick example for partner: + +.. code-block:: python + + class PartnerForm(models.AbstractModel): + + _name = 'cms.form.res.partner' + _inherit = 'cms.form' + _form_model = 'res.partner' + _form_model_fields = ('name', 'country_id') + _form_required_fields = ('name', 'country_id') + + +In this case you'll have form with the following characteristics: + +* works with ``res.partner`` model +* have only ``name`` and ``country_id`` fields +* both fields are required (is not possible to submit the form w/out one of those values) + +Here's the result: + +|preview_create| +|preview_edit| + +The form will be automatically available on these routes: + +* ``/cms/form/create/res.partner`` to create new partners +* ``/cms/form/edit/res.partner/1`` edit existing partners (partner id=1 in this case) + +NOTE: default generic routes work if the form's name is ``cms.form.`` + model name, like ``cms.form.res.partner``. +If you want you can easily define your own controller and give your form a different name, +and have more elegant routes like ```/partner/edit/partner-slug-1``. +Take a look at `cms_form_example <../cms_form_example>`_. + +By default, the form is rendered as an horizontal twitter bootstrap form, but you can provide your own templates of course. +By default, fields are ordered by their order in the model's schema. You can tweak it using ``_form_fields_order``. + + +Form with extra control fields +------------------------------ + +Imagine you want to notify the partner after its creation but only if you really need it. + +The form above can be extended with extra fields that are not part of the ``_form_model`` schema: + +.. code-block:: python + + class PartnerForm(models.AbstractModel): + + _name = 'cms.form.res.partner' + _inherit = 'cms.form' + _form_model = 'res.partner' + _form_model_fields = ('name', 'country_id', 'email') + _form_required_fields = ('name', 'country_id', 'email') + + notify_partner = fields.Boolean() + + def form_after_create_or_update(self, values, extra_values): + if extra_values.get('notify_partner'): + # do what you want here... + +``notify_partner`` will be included into the form but it will be discarded on create and write. +Nevertheless you can use it as a control flag before and after the record has been created or updated +using the hook ``form_after_create_or_update``, as you see in this example. + + +Search form +----------- + +Just inherit from ``cms.form.search`` to add a form for your model. Quick example for partner: + +.. code-block:: python + + class PartnerSearchForm(models.AbstractModel): + """Partner model search form.""" + + _name = 'cms.form.search.res.partner' + _inherit = 'cms.form.search' + _form_model = 'res.partner' + _form_model_fields = ('name', 'country_id', ) + _form_fields_order = ('country_id', 'name', ) + + +|preview_search| + +The form will be automatically available at: ``/cms/form/search/res.partner``. + +NOTE: default generic routes work if the form's name is ```cms.form.search`` + model name, like ``cms.form.search.res.partner``. +If you want you can easily define your own controller and give your form a different name, +and have more elegant routes like ``/partners``. +Take a look at `cms_form_example <../cms_form_example>`_. + + +Master / slave fields +--------------------- + +A typical use case nowadays: you want to show/hide fields based on other fields' values. +For the simplest cases you don't have to write a single line of JS. You can do it like this: + +.. code-block:: python + + class PartnerForm(models.AbstractModel): + + _name = 'cms.form.res.partner' + _inherit = 'cms.form' + _form_model = 'res.partner' + _form_model_fields = ('name', 'type', 'foo') + + def _form_master_slave_info(self): + info = self._super._form_master_slave_info() + info.update({ + # master field + 'type':{ + # actions + 'hide': { + # slave field: action values + 'foo': ('contact', ), + }, + 'show': { + 'foo': ('address', 'invoice', ), + } + }, + }) + return info + +Here we declared that: + +* when `type` field is equal to `contact` -> hide `foo` field +* when `type` field is equal to `address` or `invoice` -> show `foo` field + + +Known issues / Roadmap +====================== + +* add more tests, especially per each widget and type of field +* provide better widgets for image and file fields in general +* o2m fields: to be tested at all +* move widgets to abstract models too +* search form: generate default search domain in a clever way +* add easy way to switch from horizontal to vertical form +* provide more examples +* x2x fields: allow sub-items creation +* handle api onchanges +* support python expressions into master/slave rules + + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smashing it by providing a detailed and welcomed feedback. + +Credits +======= + +Sponsor +------- + +* `Fluxdock.io `_. + +Contributors +------------ + +* Simone Orsi + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. + +.. |preview_create| image:: ./images/cms_form_example_create_partner.png +.. |preview_edit| image:: ./images/cms_form_example_edit_partner.png +.. |preview_search| image:: ./images/cms_form_example_search.png diff --git a/cms_form/__init__.py b/cms_form/__init__.py new file mode 100644 index 00000000..f7209b17 --- /dev/null +++ b/cms_form/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/cms_form/__openerp__.py b/cms_form/__openerp__.py new file mode 100644 index 00000000..33efef52 --- /dev/null +++ b/cms_form/__openerp__.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +{ + 'name': 'CMS Form', + 'summary': """ + Basic content type form""", + 'version': '9.0.1.0.0', + 'license': 'LGPL-3', + 'author': 'Camptocamp SA, Odoo Community Association (OCA)', + 'depends': [ + 'website', + 'cms_status_message', + ], + 'data': [ + 'security/cms_form.xml', + 'templates/assets.xml', + 'templates/form.xml', + 'templates/widgets.xml', + ], + 'demo': [ + ], +} diff --git a/cms_form/controllers/__init__.py b/cms_form/controllers/__init__.py new file mode 100644 index 00000000..12a7e529 --- /dev/null +++ b/cms_form/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/cms_form/controllers/main.py b/cms_form/controllers/main.py new file mode 100644 index 00000000..354a5312 --- /dev/null +++ b/cms_form/controllers/main.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import werkzeug + +from openerp import http, _ +from openerp.http import request + + +class FormControllerMixin(object): + + # default template + template = 'cms_form.form_wrapper' + + def get_template(self, form, **kw): + """Retrieve rendering template. + + Defaults to `template` attribute. + Can be overridden straight in the form + using the attribute `form_wrapper_template`. + """ + template = self.template + + if getattr(form, 'form_wrapper_template', None): + template = form.form_wrapper_template + + if not template: + raise NotImplementedError("You must provide a template!") + return template + + def get_render_values(self, main_object, **kw): + """Retrieve rendering values. + + You can override this to inject more values. + """ + parent = None + if getattr(main_object, 'parent_id', None): + # get the parent if any + parent = main_object.parent_id + + kw.update({ + 'main_object': main_object, + 'parent': parent, + 'controller': self, + }) + return kw + + def _can_create(self, model, raise_exception=True): + """Check that current user can create instances of given model.""" + return request.env[model].check_access_rights( + 'create', raise_exception=raise_exception) + + def _can_edit(self, main_object, raise_exception=True): + """Check that current user can edit given main object.""" + try: + main_object.check_access_rights('write') + main_object.check_access_rule('write') + can = True + except Exception: + if raise_exception: + raise + can = False + return can + + def form_model_key(self, model): + """Return a valid form model.""" + return 'cms.form.' + model + + def get_form(self, model, main_object=None, **kw): + """Retrieve form for given model or object and initialize it.""" + form_model_key = self.form_model_key(model) + if form_model_key in request.env: + form = request.env[form_model_key].form_init( + request, main_object=main_object) + else: + # TODO: enable form by default? + # How? with a flag on ir.model.model? + # And which fields to include automatically? + raise NotImplementedError( + _('%s model has no CMS form registered.') % model + ) + return form + + def check_permission(self, model, main_object): + """Check permission on current model and main object.""" + if main_object: + self._can_edit(main_object) + else: + self._can_create(model) + + def make_response(self, model, model_id=None, page=0, **kw): + """Prepare and return form response. + + :param model: an odoo model's name + :param model_id: an odoo record's id + :param page: current page if any (mostly for search forms) + :param kw: extra parameters + + How it works: + * retrieve current main object if any + * check permission on model and/or main object + * retrieve the form + * make the form process current request + * if the form is successful and has `form_redirect` attribute + it redirects to it. + * otherwise it just renders the form + """ + main_object = None + if model_id: + main_object = request.env[model].browse(model_id) + self.check_permission(model, main_object) + form = self.get_form(model, main_object=main_object) + # pass only specific extra args, to not pollute form render values + form.form_process(extra_args={'page': page}) + # search forms do not need these attrs + if getattr(form, 'form_success', None) \ + and getattr(form, 'form_redirect', None): + # anything went fine, redirect to next url + return werkzeug.utils.redirect(form.form_next_url()) + # render form wrapper + values = self.get_render_values(main_object, **kw) + values['form'] = form + return request.render( + self.get_template(form, **kw), + values, + ) + + +class CMSFormController(http.Controller, FormControllerMixin): + """CMS form controller.""" + + @http.route([ + '/cms/form/create/', + '/cms/form/edit//', + ], type='http', auth='user', website=True) + def cms_form(self, model, model_id=None, **kw): + """Handle a `form` route. + """ + return self.make_response(model, model_id=model_id, **kw) + + +class SearchFormControllerMixin(FormControllerMixin): + + template = 'cms_form.search_form_wrapper' + + def form_model_key(self, model): + return 'cms.form.search.' + model + + def check_permission(self, model, main_object): + pass + + +class CMSSearchFormController(http.Controller, SearchFormControllerMixin): + """CMS form controller.""" + + @http.route([ + '/cms/form/search/', + '/cms/form/search//page/', + ], type='http', auth='public', website=True) + def cms_form(self, model, **kw): + """Handle a search `form` route. + """ + return self.make_response(model, **kw) diff --git a/cms_form/i18n/de.po b/cms_form/i18n/de.po new file mode 100644 index 00000000..fd0e3dd7 --- /dev/null +++ b/cms_form/i18n/de.po @@ -0,0 +1,150 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * cms_form +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 9.0c\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2017-03-30 10:43+0000\n" +"PO-Revision-Date: 2017-03-30 12:45+0200\n" +"Last-Translator: <>\n" +"Language-Team: \n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: \n" +"X-Generator: Poedit 1.8.9\n" + +#. module: cms_form +#: code:addons/cms_form/controllers/main.py:79 +#: code:addons/odoo/external-src/website-cms/cms_form/controllers/main.py:79 +#, python-format +msgid "%s model has no CMS form registered." +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form +#: model:ir.model,name:cms_form.model_cms_form_mixin +#: model:ir.model,name:cms_form.model_cms_form_search +msgid "CMS Form mixin" +msgstr "CMS Form mixin" + +#. module: cms_form +#: model:ir.model.fields,field_description:cms_form.field_website_published_mixin_cms_edit_url +msgid "CMS edit URL" +msgstr "CMS edit URL" + +#. module: cms_form +#: model:ir.ui.view,arch_db:cms_form.base_form_buttons +msgid "Cancel" +msgstr "Abbrechen" + +#. module: cms_form +#: code:addons/cms_form/models/cms_form.py:36 +#: code:addons/cms_form/models/cms_form.py:41 +#: code:addons/odoo/external-src/website-cms/cms_form/models/cms_form.py:36 +#: code:addons/odoo/external-src/website-cms/cms_form/models/cms_form.py:41 +#, python-format +msgid "Create %s" +msgstr "%s anlegen" + +#. module: cms_form +#: model:ir.model.fields,field_description:cms_form.field_cms_form_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_mixin_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_search_display_name +msgid "Display Name" +msgstr "Angezeigter Name" + +#. module: cms_form +#: code:addons/cms_form/models/cms_form.py:34 +#: code:addons/odoo/external-src/website-cms/cms_form/models/cms_form.py:34 +#, python-format +msgid "Edit \"%s\"" +msgstr "\"%s\" Bearbeiten" + +#. module: cms_form +#: model:ir.model.fields,field_description:cms_form.field_cms_form_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_mixin_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_search_id +msgid "ID" +msgstr "ID" + +#. module: cms_form +#: code:addons/cms_form/models/cms_form.py:58 +#: code:addons/odoo/external-src/website-cms/cms_form/models/cms_form.py:58 +#, python-format +msgid "Item created." +msgstr "Objekt wurde erstellt." + +#. module: cms_form +#: code:addons/cms_form/models/cms_form.py:63 +#: code:addons/odoo/external-src/website-cms/cms_form/models/cms_form.py:63 +#, python-format +msgid "Item updated." +msgstr "Objekt wurde aktualisiert." + +#. module: cms_form +#: model:ir.ui.view,arch_db:cms_form.field_widget_image +msgid "Keep current image" +msgstr "Aktuelles Bild beibehalten" + +#. module: cms_form +#: model:ir.model.fields,field_description:cms_form.field_cms_form___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_mixin___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_search___last_update +msgid "Last Modified on" +msgstr "Zuletzt geändert am" + +#. module: cms_form +#: model:ir.ui.view,arch_db:cms_form.form_horizontal_field_wrapper +msgid "Name" +msgstr "Bezeichnung" + +#. module: cms_form +#: model:ir.ui.view,arch_db:cms_form.field_widget_image +msgid "Replace current image" +msgstr "Aktuelles Bild ersetzen" + +#. module: cms_form +#: model:ir.ui.view,arch_db:cms_form.base_form +msgid "Required fields" +msgstr "Pflichtfelder" + +#. module: cms_form +#: code:addons/cms_form/models/cms_search_form.py:43 +#: code:addons/odoo/external-src/website-cms/cms_form/models/cms_search_form.py:43 +#: model:ir.ui.view,arch_db:cms_form.search_form_buttons +#, python-format +msgid "Search" +msgstr "Suche" + +#. module: cms_form +#: code:addons/cms_form/models/cms_search_form.py:48 +#: code:addons/odoo/external-src/website-cms/cms_form/models/cms_search_form.py:48 +#, python-format +msgid "Search %s" +msgstr "%s suchen" + +#. module: cms_form +#: code:addons/cms_form/models/cms_form.py:67 +#: code:addons/odoo/external-src/website-cms/cms_form/models/cms_form.py:67 +#, python-format +msgid "Some required fields are empty." +msgstr "Bitte füllen Sie alle Pflichtfelder aus." + +#. module: cms_form +#: model:ir.ui.view,arch_db:cms_form.base_form_buttons +msgid "Submit" +msgstr "Absenden" + +#. module: cms_form +#: model:ir.ui.view,arch_db:cms_form.field_widget_date +msgid "YYYY-MM-DD" +msgstr "JJJJ-MM-TT" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_website_published_mixin +msgid "website.published.mixin" +msgstr "website.published.mixin" diff --git a/cms_form/images/cms_form_example_create_partner.png b/cms_form/images/cms_form_example_create_partner.png new file mode 100644 index 00000000..6c5f33c0 Binary files /dev/null and b/cms_form/images/cms_form_example_create_partner.png differ diff --git a/cms_form/images/cms_form_example_edit_partner.png b/cms_form/images/cms_form_example_edit_partner.png new file mode 100644 index 00000000..73be6518 Binary files /dev/null and b/cms_form/images/cms_form_example_edit_partner.png differ diff --git a/cms_form/images/cms_form_example_search.png b/cms_form/images/cms_form_example_search.png new file mode 100644 index 00000000..45b4164a Binary files /dev/null and b/cms_form/images/cms_form_example_search.png differ diff --git a/cms_form/models/__init__.py b/cms_form/models/__init__.py new file mode 100644 index 00000000..eee28441 --- /dev/null +++ b/cms_form/models/__init__.py @@ -0,0 +1,6 @@ +from . import cms_form_mixin +from . import cms_form +from . import cms_search_form +from . import test_models +from . import website_mixin +from . import widgets diff --git a/cms_form/models/cms_form.py b/cms_form/models/cms_form.py new file mode 100644 index 00000000..77e18c78 --- /dev/null +++ b/cms_form/models/cms_form.py @@ -0,0 +1,240 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import werkzeug +from psycopg2 import IntegrityError + +from openerp import models, exceptions, _ + + +class CMSForm(models.AbstractModel): + _name = 'cms.form' + _inherit = 'cms.form.mixin' + + _form_validators = {} + + # internal flag for successful form + __form_success = False + + @property + def form_success(self): + return self.__form_success + + @form_success.setter + def form_success(self, value): + self.__form_success = value + + @property + def form_title(self): + if self.main_object: + rec_field = self.main_object[self.form_model._rec_name] + if hasattr(rec_field, 'id'): + rec_field = rec_field.name + title = _('Edit "%s"') % rec_field + else: + title = _('Create %s') + if self._form_model: + model = self.env['ir.model'].search( + [('model', '=', self._form_model)]) + name = model and model.name or '' + title = _('Create %s') % name + return title + + # internal flag for turning on redirection + __form_redirect = False + + @property + def form_redirect(self): + return self.__form_redirect + + @form_redirect.setter + def form_redirect(self, value): + self.__form_redirect = value + + @property + def form_msg_success_created(self): + # TODO: include form model name if any + msg = _('Item created.') + return msg + + @property + def form_msg_success_updated(self): + return _('Item updated.') + + @property + def form_msg_error_missing(self): + return _('Some required fields are empty.') + + def form_next_url(self, main_object=None): + """URL to redirect to after successful form submission.""" + if self.request.args.get('redirect'): + # redirect overridden + return self.request.args.get('redirect') + main_object = main_object or self.main_object + if main_object and 'website_url' in main_object: + return main_object.website_url + return '/' + + def form_cancel_url(self, main_object=None): + """URL to redirect to after click on "cancel" button.""" + if self.request.args.get('redirect'): + # redirect overridden + return self.request.args.get('redirect') + main_object = main_object or self.main_object + if main_object and 'website_url' in main_object: + return main_object.website_url + return self.request.referrer or '/' + + def form_check_empty_field(self, fname, field, value, **req_values): + """Return True if passed field value is really empty.""" + if isinstance(value, werkzeug.datastructures.FileStorage): + has_value = bool(value.filename) + if not has_value and req_values.get(fname + '_keepcheck') == 'yes': + # no value, but we want to preserve existing one + return False + # file field w/ no content + # TODO: this is not working sometime... + # return not bool(value.content_length) + return not has_value + return value in (False, '') + + def form_validate(self, request_values=None): + """Validate submitted values.""" + errors = {} + errors_message = {} + request_values = request_values or self.form_get_request_values() + + missing = False + for fname, field in self.form_fields().iteritems(): + value = request_values.get(fname) + error = False + if field['required'] \ + and self.form_check_empty_field( + fname, field, value, **request_values): + errors[fname] = 'missing' + missing = True + validator = self.form_get_validator(fname, field) + if validator: + error, error_msg = validator(value, **request_values) + if error: + errors[fname] = error + errors_message[fname] = error_msg + + # error message for empty required fields + if missing and self.o_request.website: + msg = self.form_msg_error_missing + self.o_request.website.add_status_message(msg, type_='danger') + return errors, errors_message + + def form_get_validator(self, fname, field): + """Retrieve field validator.""" + # 1nd lookup for a default type validator + validator = self._form_validators.get(field['type'], None) + # 2nd lookup for a specific type validator + validator = getattr( + self, '_form_validate_' + field['type'], validator) + # 3rd lookup and override by named validator if any + validator = getattr( + self, '_form_validate_' + fname, validator) + return validator + + def form_before_create_or_update(self, values, extra_values): + """Pre create/update hook.""" + pass + + def form_after_create_or_update(self, values, extra_values): + """Post create/update hook.""" + pass + + def _form_purge_non_model_fields(self, values): + """Purge fields that are not in `form_model` schema and return them.""" + extra_values = {} + _model_fields = self.form_model.fields_get( + self._form_model_fields, + attributes=self._form_fields_attributes).keys() + submitted_keys = values.keys() + for fname in submitted_keys: + if fname not in _model_fields: + extra_values[fname] = values.pop(fname) + return extra_values + + def _form_write(self, values): + """Just write on the main object.""" + self.main_object.write(values) + + def _form_create(self, values): + """Just create the main object.""" + self.main_object = self.form_model.create(values) + + def form_create_or_update(self): + """Prepare values and create or update main_object.""" + write_values = self.form_extract_values() + extra_values = self._form_purge_non_model_fields(write_values) + # pre hook + self.form_before_create_or_update(write_values, extra_values) + if self.main_object: + self._form_write(write_values) + msg = self.form_msg_success_updated + else: + self._form_create(write_values) + msg = self.form_msg_success_created + if msg and self.o_request.website: + self.o_request.website.add_status_message(msg) + # post hook + self.form_after_create_or_update(write_values, extra_values) + return self.main_object + + def form_process_POST(self, render_values): + """Process POST requests.""" + errors, errors_message = self.form_validate() + if not errors: + try: + self.form_create_or_update() + self.form_success = True + self.form_redirect = True + return render_values + except exceptions.ValidationError as err: + # sounds like there's no way to validate fields + # before calling write or create, + # hence we are forced to do it here. + errors['_validation'] = True + # err message can be something like + # u'Error while validating constraint\n + # \nEnd Date cannot be set before Start Date.\nNone' + errors_message['_validation'] = '
'.join([ + x for x in err.name.replace('None', '').split('\n') + if x.strip() + ]) + except IntegrityError as err: + errors['_integrity'] = True + errors_message['_integrity'] = '
'.join([ + x for x in err.message.replace('None', '').split('\n') + if x.strip() + ]) + + # TODO: how to handle validation error on create? + # If you use @api.constrains to validate fields' value + # the check happens only AFTER the record has been created. + # So, on the 1st submit the record is created + # and the user is not aware of it since it gets redirected to the form + # with errors highlighted. He corrects the error and submit again. + # If no errors, he gets redirected to the new record, ANOTHER record. + # In the end: we get 2 duplicated objects :( + # To avoid this beforehand we need validation on JS side + + # pre-validation (where possible) before create or write. + + self.form_success = False + # handle ORM validation error + orm_error = errors.get('_validation') or errors.get('_integrity') + if orm_error: + msg = errors_message.get('_validation') \ + or errors_message.get('_integrity') + if msg and self.o_request.website: + self.o_request.website.add_status_message( + msg, type_='danger', title=None) + render_values.update({ + 'errors': errors, + 'errors_message': errors_message, + }) + return render_values diff --git a/cms_form/models/cms_form_mixin.py b/cms_form/models/cms_form_mixin.py new file mode 100644 index 00000000..0c062905 --- /dev/null +++ b/cms_form/models/cms_form_mixin.py @@ -0,0 +1,475 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import inspect +import json +from collections import OrderedDict + +from openerp import models, tools + +from ..utils import data_merge + + +IGNORED_FORM_FIELDS = [ + 'display_name', + '__last_update', + # TODO: retrieve from inherited schema + 'message_ids', + 'message_follower_ids', + 'message_follower', + 'message_last_post', + 'message_unread', + 'message_unread_counter', + 'message_needaction_counter', + 'website_message_ids', + 'website_published', +] + models.MAGIC_COLUMNS + + +class CMSFormMixin(models.AbstractModel): + """Base abstract CMS form.""" + _name = 'cms.form.mixin' + _description = 'CMS Form mixin' + + # template to render the form + form_template = 'cms_form.base_form' + form_fields_template = 'cms_form.base_form_fields' + form_buttons_template = 'cms_form.base_form_buttons' + form_action = '' + form_method = 'POST' + _form_mode = '' + # extra css klasses for the whole form wrapper + _form_wrapper_extra_css_klass = '' + # extra css klasses for just the form element + _form_extra_css_klass = '' + # model tied to this form + _form_model = '' + # model's fields to load + _form_model_fields = [] + # force fields order + _form_fields_order = [] + # quickly force required fields + _form_required_fields = () + # mark some fields as "sub". + # For usability reasons you might want to move some fields + # inside the widget of another field. + # If you mark a field as "sub" this field + # won't be included into fields' list as usual + # but you can still find it in `form_fields` value. + _form_sub_fields = { + # 'mainfield': { + # # loaded for a specific value + # 'mainfield_value': ('subfield1', 'subfield2'), + # # loaded for all values + # '_all': ('subfield3', ), + # } + } + # fields' attributes to load + _form_fields_attributes = [ + 'type', 'string', 'domain', + 'required', 'readonly', 'relation', + 'store', 'help', 'selection', + ] + # include only these fields + _form_fields_whitelist = () + # exclude these fields + _form_fields_blacklist = () + # extract values mode + # This param can be used to alter value format + # when extracting values from request. + # eg: in write mode you can get on a m2m [(6, 0, [1,2,3])] + # while in read mode you can get just the ids [1,2,3] + _form_extract_value_mode = 'write' + # ignore this fields default + __form_fields_ignore = IGNORED_FORM_FIELDS + # current edit object if any + __form_main_object = None + + @property + def main_object(self): + """Current main object.""" + return self.__form_main_object + + @main_object.setter + def main_object(self, value): + """Current main object setter.""" + self.__form_main_object = value + + def form_init(self, request, main_object=None, **kw): + """Initalize a form instance. + + @param request: an odoo-wrapped werkeug request + @param main_object: current model instance if any + @param kw: pass any override for `_form_` attributes + ie: `fields_attributes` -> `_form_fields_attributes` + """ + form = self.new() + form.o_request = request # odoo wrapped request + form.request = request.httprequest # werkzeug request, the "real" one + form.main_object = main_object + # override `_form_` parameters + for k, v in kw.iteritems(): + if not inspect.ismethod(getattr(form, '_form_' + k)): + setattr(form, '_form_' + k, v) + return form + + @property + def form_title(self): + return '' + + @property + def form_description(self): + return '' + + @property + def form_mode(self): + if self._form_mode: + # forced mode + return self._form_mode + if self.main_object: + return 'edit' + return 'create' + + @property + def form_model(self): + return self.env[self._form_model] + + def form_fields(self): + _fields = self._form_fields() + # update fields attributes + self.form_update_fields_attributes(_fields) + return _fields + + @tools.cache('self') + def _form_fields(self): + """Retrieve form fields ready to be used. + + Fields lookup: + * model's fields + * form's fields + + Blacklisted fields are skipped. + Whitelisted fields are loaded only. + """ + _all_fields = OrderedDict() + # load model fields + _model_fields = {} + if self._form_model: + _model_fields = self.form_model.fields_get( + self._form_model_fields, + attributes=self._form_fields_attributes) + # inject defaults + defaults = self.form_model.default_get(self._form_model_fields) + for k, v in defaults.iteritems(): + _model_fields[k]['_default'] = v + # load form fields + _form_fields = self.fields_get(attributes=self._form_fields_attributes) + # inject defaults + for k, v in self.default_get(_form_fields.keys()).iteritems(): + _form_fields[k]['_default'] = v + _all_fields.update(_model_fields) + # form fields override model fields + _all_fields.update(_form_fields) + # exclude blacklisted + for fname in self._form_fields_blacklist: + # make it fail if passing wrong field name + _all_fields.pop(fname) + # include whitelisted + _all_whitelisted = {} + for fname in self._form_fields_whitelist: + _all_whitelisted[fname] = _all_fields[fname] + _all_fields = _all_whitelisted or _all_fields + # remove unwanted fields + self._form_remove_uwanted(_all_fields) + # remove non-stored fields to exclude computed + _all_fields = {k: v for k, v in _all_fields.iteritems() if v['store']} + # update fields order + if self._form_fields_order: + _sorted_all_fields = OrderedDict() + for fname in self._form_fields_order: + _sorted_all_fields[fname] = _all_fields[fname] + _all_fields = _sorted_all_fields + # compute subfields and remove them from all fields if any + self._form_prepare_subfields(_all_fields) + return _all_fields + + def _form_prepare_subfields(self, _all_fields): + """Add subfields to related main fields.""" + # TODO: test this + for mainfield, subfields in self._form_sub_fields.iteritems(): + if mainfield in _all_fields: + _subfields = {} + for val, subs in subfields.iteritems(): + _subfields[val] = {} + for sub in subs: + if sub in _all_fields: + _subfields[val][sub] = _all_fields[sub] + _all_fields[sub]['is_subfield'] = True + _all_fields[mainfield]['subfields'] = _subfields + + def _form_remove_uwanted(self, _all_fields): + """Remove fields from form fields.""" + for fname in self.__form_fields_ignore: + _all_fields.pop(fname, None) + + def form_update_fields_attributes(self, _fields): + """Manipulate fields attributes.""" + for fname, field in _fields.iteritems(): + if fname in self._form_required_fields: + _fields[fname]['required'] = True + _fields[fname]['widget'] = self.form_get_widget(fname, field) + + @property + def form_widgets(self): + """Return a mapping between field name and widget model.""" + return {} + + def form_get_widget_model(self, fname, field): + """Retrieve widget model name.""" + widget_model = 'cms.form.widget.char' + for key in (field['type'], fname): + model_key = 'cms.form.widget.' + key + if model_key in self.env: + widget_model = model_key + return self.form_widgets.get(fname, widget_model) + + def form_get_widget(self, fname, field): + """Retrieve and initialize widget.""" + return self.env[self.form_get_widget_model(fname, field)].widget_init( + self, fname, field, + ) + + @property + def form_file_fields(self): + """File fields.""" + return { + k: v for k, v in self.form_fields().iteritems() + if v['type'] == 'binary' + } + + def form_get_request_values(self): + """Retrieve fields values from current request.""" + # on POST requests values are in `form` attr + # on GET requests values are in `args` attr + _values = self.request.form + if not _values: + # make sure to give precedence to form attribute + # since you might get some extra params (like ?redirect) + # and this will make the form machinery miss all the fields + _values = self.request.args + # normal fields + values = { + k: v for k, v in _values.iteritems() + if k not in ('csrf_token', ) + } + # file fields + values.update( + {k: v for k, v in self.request.files.iteritems()} + ) + return values + + def form_load_defaults(self, main_object=None, request_values=None): + """Load default values. + + Values lookup order: + + 1. `main_object` fields' values (if an existing main_object is passed) + 2. request parameters (only parameters matching form fields names) + """ + main_object = main_object or self.main_object + request_values = request_values or self.form_get_request_values() + defaults = request_values.copy() + form_fields = self.form_fields() + for fname, field in form_fields.iteritems(): + value = field['widget'].w_load(**request_values) + # override via specific form loader when needed + loader = self.form_get_loader( + fname, field, + main_object=main_object, value=value, **request_values) + if loader: + value = loader(fname, field, value, **request_values) + defaults[fname] = value + return defaults + + def form_get_loader(self, fname, field, + main_object=None, value=None, **req_values): + """Retrieve form value loader. + + :param fname: field name + :param field: field description as `fields_get` + :param main_object: current main object if any + :param value: current field value if any + :param req_values: custom request valuess + """ + # lookup for a specific type loader method + loader = getattr( + self, '_form_load_' + field['type'], None) + # 3rd lookup and override by named loader if any + loader = getattr( + self, '_form_load_' + fname, loader) + return loader + + def form_extract_values(self, **request_values): + """Extract values from request form.""" + request_values = request_values or self.form_get_request_values() + values = {} + for fname, field in self.form_fields().iteritems(): + value = field['widget'].w_extract(**request_values) + # override via specific form extractor when needed + extractor = self.form_get_extractor( + fname, field, value=value, **request_values) + if extractor: + value = extractor(self, fname, value, **request_values) + if value is None: + # we assume we do not want to override the field value. + # a typical example is an image field. + # If you have an existing image + # you cannot set the default value on the file input + # for standard HTML security restrictions. + # If you want to flush a value on a field just return "False". + continue + values[fname] = value + return values + + def form_get_extractor(self, fname, field, value=None, **req_values): + """Retrieve form value extractor. + + :param fname: field name + :param field: field description as `fields_get` + :param value: current field value if any + :param req_values: custom request valuess + """ + # lookup for a specific type handler + extractor = getattr( + self, '_form_extract_' + field['type'], None) + # 3rd lookup and override by named handler if any + extractor = getattr( + self, '_form_extract_' + fname, extractor) + return extractor + + __form_render_values = {} + + @property + def form_render_values(self): + """Values used to render the form.""" + if not self.__form_render_values: + # default render values + self.__form_render_values = { + 'main_object': self.main_object, + 'form': self, + 'form_data': {}, + 'errors': {}, + 'errors_messages': {}, + } + return self.__form_render_values + + @form_render_values.setter + def form_render_values(self, value): + self.__form_render_values = value + + def form_render(self, **kw): + """Renders form template declared in `form_template`. + + To render the form simply do: + + + """ + values = self.form_render_values.copy() + values.update(kw) + return self.env.ref(self.form_template).render(values) + + def form_process(self, **kw): + """Process current request. + + :param kw: inject custom / extra rendering values. + + Lookup correct request handler by request method + and call it with rendering values. + The handler can perform any action (like creating objects) + and then return final rendering form values + and store them into `form_render_values`. + """ + render_values = self.form_render_values + render_values.update(kw) + render_values['form_data'] = self.form_load_defaults() + handler = getattr(self, 'form_process_' + self.request.method.upper()) + render_values.update(handler(render_values)) + self.form_render_values = render_values + + def form_process_GET(self, render_values): + """Process GET requests.""" + return render_values + + def form_process_POST(self, render_values): + """Process POST requests.""" + raise NotImplementedError() + + @property + def form_wrapper_css_klass(self): + """Return form wrapper css klass. + + By default the form markup is wrapped + into a `cms_form_wrapper` element. + You can use this set of klasses to customize form styles. + + Included by default: + * `cms_form_wrapper` marker + * form model name normalized (res.partner -> res_partner) + * `_form_wrapper_extra_css_klass` extra klasses from form attribute + * `mode_` + form mode (ie: 'mode_write') + """ + parts = [ + 'cms_form_wrapper', + self._name.replace('.', '_').lower(), + self._form_model.replace('.', '_').lower(), + self._form_wrapper_extra_css_klass, + 'mode_' + self.form_mode, + ] + return ' '.join([x.strip() for x in parts if x.strip()]) + + @property + def form_css_klass(self): + """Return `
` element css klasses. + + By default you can provide extra klasses via `_form_extra_css_klass`. + """ + return self._form_extra_css_klass + + def form_json_info(self): + info = {} + info.update({ + 'master_slave': self._form_master_slave_info() + }) + return json.dumps(info) + + def _form_master_slave_info(self): + """Return info about master/slave fields JSON compatible. + + # TODO: support pyeval expressions in JS + + Eg: { + 'field_master1': { + 'hide': { + # field to hide: values + # TODO: support pyeval expressions + 'field_slave1': (master_value1, ), + }, + 'show': { + # field to show: pyeval expr + 'field_slave1': (master_value2, ), + }, + } + } + """ + return {} + + def _form_info_merge(self, info, tomerge): + """Merge info dictionaries. + + Practical example: + when inheriting forms you can add extra rules for the same master field + so if you don't want to override info completely + you can use this method to merge them properly. + """ + return data_merge(info, tomerge) diff --git a/cms_form/models/cms_search_form.py b/cms_form/models/cms_search_form.py new file mode 100644 index 00000000..19f699b5 --- /dev/null +++ b/cms_form/models/cms_search_form.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from openerp import models, _ + + +class CMSFormSearch(models.AbstractModel): + _name = 'cms.form.search' + _inherit = 'cms.form.mixin' + + form_buttons_template = 'cms_form.search_form_buttons' + form_search_results_template = 'cms_form.search_results' + form_action = '' + form_method = 'GET' + # you might want to just list items based on a predefined query + # if this flag is false the search form won't be rendered + form_show_search_form = True + _form_mode = 'search' + _form_extract_value_mode = 'read' + # show results if no query has been submitted? + _form_show_results_no_submit = 1 + _form_results_per_page = 10 + # sort by this param, defaults to model's `_order` + _form_results_orderby = '' + + def form_update_fields_attributes(self, _fields): + """No field should be mandatory.""" + super(CMSFormSearch, self).form_update_fields_attributes(_fields) + for fname, field in _fields.iteritems(): + field['required'] = False + + __form_search_results = {} + + @property + def form_search_results(self): + """Return search results.""" + return self.__form_search_results + + @form_search_results.setter + def form_search_results(self, value): + self.__form_search_results = value + + @property + def form_title(self): + title = _('Search') + if self._form_model: + model = self.env['ir.model'].search( + [('model', '=', self._form_model)]) + name = model and model.name or '' + title = _('Search %s') % name + return title + + def form_process_GET(self, render_values): + self.form_search(render_values) + return render_values + + def form_search(self, render_values): + """Produce search results.""" + search_values = self.form_extract_values() + if not search_values and not self._form_show_results_no_submit: + return self.form_search_results + domain = self.form_search_domain(search_values) + count = self.form_model.search_count(domain) + page = render_values.get('extra_args', {}).get('page', 0) + url = render_values.get('extra_args', {}).get('pager_url', '') + if self._form_model: + url = getattr(self.form_model, 'cms_search_url', url) + pager = self._form_results_pager(count=count, page=page, url=url) + order = self._form_results_orderby or None + results = self.form_model.search( + domain, + limit=self._form_results_per_page, + offset=pager['offset'], + order=order + ) + self.form_search_results = { + 'results': results, + 'count': count, + 'pager': pager, + } + return self.form_search_results + + def pager(self, **kw): + return self.env['website'].pager(**kw) + + def _form_results_pager(self, count=None, page=0, url='', url_args=None): + """Prepare pager for current search.""" + url_args = url_args or self.request.args.to_dict() + count = count + return self.pager( + url=url, + total=count, + page=page, + step=self._form_results_per_page, + scope=self._form_results_per_page, + url_args=url_args + ) + + def form_search_domain(self, search_values): + """Build search domain.""" + domain = [] + for fname, field in self.form_fields().iteritems(): + value = search_values.get(fname) + if value is None: + continue + if field['type'] in ('many2one', ) and value < 1: + # we need an existing ID here ( > 0) + continue + # TODO: find the way to properly handle this. + # It would be nice to guess leafs in a clever way. + operator = '=' + if field['type'] in ('char', 'text'): + operator = 'ilike' + value = '%{}%'.format(value) + elif field['type'] in ('integer', 'float', 'many2one'): + operator = '=' + elif field['type'] in ('one2many', 'many2many'): + if not value: + continue + operator = 'in' + elif field['type'] in ('many2one', ) and not value: + # we need an existing ID here ( > 0) + continue + elif field['type'] in ('boolean', ): + value = value == 'on' and True + leaf = (fname, operator, value) + domain.append(leaf) + return domain diff --git a/cms_form/models/test_models.py b/cms_form/models/test_models.py new file mode 100644 index 00000000..65470c47 --- /dev/null +++ b/cms_form/models/test_models.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from openerp import fields, models, tools +import os + +testing = tools.config.get('test_enable') or os.environ.get('ODOO_TEST_ENABLE') + +if testing: + class TestPartnerForm(models.AbstractModel): + """A test model form.""" + + _name = 'cms.form.res.partner' + _inherit = 'cms.form' + _form_model = 'res.partner' + _form_model_fields = ('name', 'country_id') + _form_required_fields = ('name', 'country_id') + + custom = fields.Char() + + def _form_load_custom( + self, main_object, fname, value, **req_values): + return req_values.get('custom', 'oh yeah!') + + class TestSearchPartnerForm(models.AbstractModel): + """A test model search form.""" + + _name = 'cms.form.search.res.partner' + _inherit = 'cms.form.search' + _form_model = 'res.partner' + _form_model_fields = ('name', 'country_id') + + def form_search_domain(self, search_values): + """Force domain to include only test-created records.""" + domain = super( + TestSearchPartnerForm, self + ).form_search_domain(search_values) + # we use this attr in tests to limit search results + # to test records' scope + include_only_ids = getattr(self, 'test_record_ids', []) + if include_only_ids: + domain.append(('id', 'in', include_only_ids)) + return domain + + class TestFieldsForm(models.AbstractModel): + """A test model form.""" + + _name = 'cms.form.test_fields' + _inherit = 'cms.form' + + a_char = fields.Char() + a_number = fields.Integer() + a_float = fields.Float() + # fake relation fields + a_many2one = fields.Char() + a_one2many = fields.Char() + a_many2many = fields.Char() + + def _form_fields(self): + _fields = super(TestFieldsForm, self)._form_fields() + # fake fields' types + _fields['a_many2one']['type'] = 'many2one' + _fields['a_many2one']['relation'] = 'res.partner' + _fields['a_many2many']['type'] = 'many2many' + _fields['a_many2many']['relation'] = 'res.partner' + _fields['a_one2many']['type'] = 'one2many' + _fields['a_one2many']['relation'] = 'res.partner' + return _fields + + def _form_validate_a_float(self, value, **request_values): + """Specific validator for `a_float` field.""" + value = float(value or '0') + return not value > 5, 'Must be greater than 5!' + + def _form_validate_char(self, value, **request_values): + """Specific validator for all `char` fields.""" + return not len(value) > 8, 'Text lenght must be greater than 8!' diff --git a/cms_form/models/website_mixin.py b/cms_form/models/website_mixin.py new file mode 100644 index 00000000..b1a6d5e7 --- /dev/null +++ b/cms_form/models/website_mixin.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from openerp import api, fields, models + + +class WebsitePublishedMixin(models.AbstractModel): + _inherit = "website.published.mixin" + + @property + def cms_add_url(self): + return '/cms/form/create/{}'.format(self._name) + + @property + def cms_search_url(self): + return '/cms/form/search/{}'.format(self._name) + + cms_edit_url = fields.Char( + string='CMS edit URL', + compute='_compute_cms_edit_url', + readonly=True, + ) + + @api.multi + def _compute_cms_edit_url(self): + for item in self: + item.cms_edit_url = \ + '/cms/form/edit/{}/{}'.format(item._name, item.id) diff --git a/cms_form/models/widgets.py b/cms_form/models/widgets.py new file mode 100644 index 00000000..5363b764 --- /dev/null +++ b/cms_form/models/widgets.py @@ -0,0 +1,330 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import json +import werkzeug +import base64 + +from openerp.tools.mimetypes import guess_mimetype +from openerp import models + +from .. import utils + + +class Widget(models.AbstractModel): + _name = 'cms.form.widget.mixin' + + # use `w_` prefix as a namespace for all widget properties + _w_template = '' + _w_css_klass = '' + + def widget_init(self, form, fname, field, + data=None, subfields=None, template='', css_klass=''): + widget = self.new() + widget.w_form = form + widget.w_record = form.main_object + widget.w_form_values = form.form_render_values + widget.w_fname = fname + widget.w_field = field + widget.w_field_value = widget.w_form_values.get( + 'form_data', {}).get(fname) + widget.w_data = data or {} + widget.w_subfields = subfields or field.get('subfields', {}) + widget._w_template = template or self._w_template + widget._w_css_klass = css_klass or self._w_css_klass + return widget + + def render(self): + return self.env.ref(self.w_template).render({'widget': self}) + + @property + def w_template(self): + return self._w_template + + @property + def w_css_klass(self): + return self._w_css_klass + + def w_load(self, **req_values): + """Load value for current field in current request.""" + value = self.w_field.get('_default') + # we could have form-only fields (like `custom` in test form below) + if self.w_record and self.w_fname in self.w_record: + value = self.w_record[self.w_fname] or value + # maybe a POST request with new values: override item value + value = req_values.get(self.w_fname, value) + return value + + def w_extract(self, **req_values): + """Extract value from form submit.""" + return req_values.get(self.w_fname) + + def w_ids_from_input(self, value): + """Convert list of ids from form input.""" + return [int(rec_id) for rec_id in value.split(',') if rec_id.isdigit()] + + def w_subfields_by_value(self, value='_all'): + return self.w_subfields.get(value, {}) + + +class CharWidget(models.AbstractModel): + _name = 'cms.form.widget.char' + _inherit = 'cms.form.widget.mixin' + _w_template = 'cms_form.field_widget_char' + + +class IntegerWidget(models.AbstractModel): + _name = 'cms.form.widget.integer' + _inherit = 'cms.form.widget.char' + + def w_extract(self, **req_values): + value = super(IntegerWidget, self).w_extract(**req_values) + return utils.safe_to_integer(value) + + +class FloatWidget(models.AbstractModel): + _name = 'cms.form.widget.float' + _inherit = 'cms.form.widget.char' + + def w_extract(self, **req_values): + value = super(FloatWidget, self).w_extract(**req_values) + return utils.safe_to_float(value) + + +class M2OWidget(models.AbstractModel): + _name = 'cms.form.widget.many2one' + _inherit = 'cms.form.widget.mixin' + _w_template = 'cms_form.field_widget_m2o' + + def widget_init(self, form, fname, field, **kw): + widget = super(M2OWidget, self).widget_init(form, fname, field, **kw) + widget.w_comodel = self.env[widget.w_field['relation']] + widget.w_domain = widget.w_field.get('domain', []) + return widget + + @property + def w_option_items(self): + return self.w_comodel.search(self.w_domain) + + def w_load(self, **req_values): + value = super(M2OWidget, self).w_load(**req_values) + return self.m2o_to_form(value) + + def m2o_to_form(self, value): + # important: return False if no value + # otherwise you will compare an empty recordset with an id + # in a select input in form widget template. + if isinstance(value, basestring) and value.isdigit(): + # number as string + return int(value) > 0 and int(value) + elif isinstance(value, models.BaseModel): + return value and value.id or None + elif isinstance(value, int): + return value + return None + + def w_extract(self, **req_values): + value = super(M2OWidget, self).w_extract(**req_values) + return self.form_to_m2o(value, **req_values) + + def form_to_m2o(self, value, **req_values): + val = utils.safe_to_integer(value) + # we don't want m2o value do be < 1 + return val > 0 and val or None + + +class SelectionWidget(models.AbstractModel): + _name = 'cms.form.widget.selection' + _inherit = 'cms.form.widget.mixin' + _w_template = 'cms_form.field_widget_selection' + + @property + def w_option_items(self): + return [ + {'value': x[0], 'label': x[1]} + for x in self.w_field['selection'] + ] + + +class RadioSelectionWidget(SelectionWidget): + _name = 'cms.form.widget.radio' + _inherit = 'cms.form.widget.selection' + _w_template = 'cms_form.field_widget_radio_selection' + # you can define help message per each options + # opt value: help msg (can be html too) + w_options_help = {} + + def widget_init(self, form, fname, field, **kw): + widget = super( + RadioSelectionWidget, self).widget_init(form, fname, field, **kw) + widget.w_options_help = kw.get('options_help') or {} + return widget + + +class X2MWidget(models.AbstractModel): + _name = 'cms.form.widget.x2m.mixin' + _inherit = 'cms.form.widget.mixin' + _w_template = 'cms_form.field_widget_x2m' + w_diplay_field = 'display_name' + + def widget_init(self, form, fname, field, **kw): + widget = super(X2MWidget, self).widget_init(form, fname, field, **kw) + widget.w_comodel = self.env[widget.w_field['relation']] + widget.w_domain = widget.w_field.get('domain', []) + return widget + + def w_load(self, **req_values): + value = super(X2MWidget, self).w_load(**req_values) + return self.x2many_to_form(value, **req_values) + + def x2many_to_form(self, value, **req_values): + if not value: + return json.dumps([]) + # FIXME: this check can compare pears and apples + # because the value might come from the request + # and we compare objects to strings or list of strings + if self.w_record and value == self.w_record[self.w_fname]: + # value from record + value = [ + {'id': x.id, 'name': x[self.w_diplay_field]} + for x in value or [] + ] + elif (isinstance(value, basestring) and + value == req_values.get(self.w_fname)): + # value from request + # FIXME: the field could come from the form not the model! + value = self.w_form.form_model[self.w_fname].browse( + self.w_ids_from_input(value)).read(['name']) + value = json.dumps(value) + return value + + def w_extract(self, **req_values): + value = super(X2MWidget, self).w_extract(**req_values) + return self.form_to_x2many(value, **req_values) + + def form_to_x2many(self, value, **req_values): + _value = False + if self.w_form._form_extract_value_mode == 'write': + if value: + _value = [(6, False, self.w_ids_from_input(value))] + else: + # wipe them + _value = [(5, )] + else: + _value = value and self.w_ids_from_input(value) or [] + return _value + + +# TODO: handle advanced editing via table view and subform for such fields +class O2ManyWidget(models.AbstractModel): + _name = 'cms.form.widget.one2many' + _inherit = 'cms.form.widget.x2m.mixin' + + +class M2MWidget(models.AbstractModel): + _name = 'cms.form.widget.many2many' + _inherit = 'cms.form.widget.x2m.mixin' + + +# TODO: add datetime widget +class DateWidget(models.AbstractModel): + _name = 'cms.form.widget.date' + _inherit = 'cms.form.widget.mixin' + _w_template = 'cms_form.field_widget_date' + + def w_extract(self, **req_values): + value = super(DateWidget, self).w_extract(**req_values) + return self.form_to_date(value, **req_values) + + def form_to_date(self, value, **req_values): + return utils.safe_to_date(value) + + +class TextWidget(models.AbstractModel): + _name = 'cms.form.widget.text' + _inherit = 'cms.form.widget.mixin' + _w_template = 'cms_form.field_widget_text' + w_maxlength = None + + def widget_init(self, form, fname, field, **kw): + widget = super(TextWidget, self).widget_init( + form, fname, field, **kw + ) + widget.w_maxlength = kw.get('maxlength') + return widget + + +class BinaryWidget(models.AbstractModel): + _name = 'cms.form.widget.binary.mixin' + _inherit = 'cms.form.widget.mixin' + + def w_load(self, **req_values): + value = super(BinaryWidget, self).w_load(**req_values) + return self.binary_to_form(value, **req_values) + + def binary_to_form(self, value, **req_values): + _value = { + # 'value': '', + # 'raw_value': '', + # 'mimetype': '', + } + if value: + if isinstance(value, werkzeug.datastructures.FileStorage): + # value from request, we cannot set a value for input field + value = '' + mimetype = '' + else: + mimetype = guess_mimetype(value.decode('base64')) + _value = { + 'value': value, + 'raw_value': value, + 'mimetype': mimetype, + } + if mimetype.startswith('image/'): + _value['value'] = 'data:{};base64,{}'.format(mimetype, value) + return _value + + def w_extract(self, **req_values): + value = super(BinaryWidget, self).w_extract(**req_values) + return self.form_to_binary(value, **req_values) + + def form_to_binary(self, value, **req_values): + _value = False + if req_values.get(self.w_fname + '_keepcheck') == 'yes': + # prevent discarding image + req_values.pop(self.w_fname, None) + req_values.pop(self.w_fname + '_keepcheck') + return None + if value: + if hasattr(value, 'read'): + file_content = value.read() + _value = base64.encodestring(file_content) + else: + _value = value.split(',')[-1] + return _value + + +class ImageWidget(models.AbstractModel): + _name = 'cms.form.widget.image' + _inherit = 'cms.form.widget.binary.mixin' + _w_template = 'cms_form.field_widget_image' + + +class BooleanWidget(models.AbstractModel): + _name = 'cms.form.widget.boolean' + _inherit = 'cms.form.widget.mixin' + _w_template = 'cms_form.field_widget_boolean' + + w_true_values = utils.TRUE_VALUES + + def widget_init(self, form, fname, field, **kw): + widget = super(BooleanWidget, self).widget_init( + form, fname, field, **kw) + widget.w_true_values = kw.get('true_values', self.w_true_values) + widget.w_field_value = widget.w_field_value in self.w_true_values + return widget + + def w_extract(self, **req_values): + value = super(BooleanWidget, self).w_extract(**req_values) + return utils.string_to_bool(value, true_values=self.w_true_values) diff --git a/cms_form/security/cms_form.xml b/cms_form/security/cms_form.xml new file mode 100644 index 00000000..b1edc6f0 --- /dev/null +++ b/cms_form/security/cms_form.xml @@ -0,0 +1,22 @@ + + + + + + + + cms.form access name + + + + + + + + + + + diff --git a/cms_form/static/description/icon.png b/cms_form/static/description/icon.png new file mode 100644 index 00000000..3a0328b5 Binary files /dev/null and b/cms_form/static/description/icon.png differ diff --git a/cms_form/static/src/js/date_widget.js b/cms_form/static/src/js/date_widget.js new file mode 100644 index 00000000..4b87e153 --- /dev/null +++ b/cms_form/static/src/js/date_widget.js @@ -0,0 +1,27 @@ +odoo.define('cms_form.date_widget', function (require) { + 'use strict'; + + $(document).ready(function () { + $("input.js_datepicker").each(function(){ + var $input = $(this); + $input.datepicker({ + useSeconds: false, + icons : { + time: 'fa fa-clock-o', + date: 'fa fa-calendar', + up: 'fa fa-chevron-up', + down: 'fa fa-chevron-down' + }, + dateFormat : $input.data('format') + }).datepicker("setDate", new Date()); + $input + .closest('.input-group') + .find('.js_datepicker_trigger').click(function(){ + $input.datepicker('show'); + }) + }) + + + }); + +}); diff --git a/cms_form/static/src/js/master_slave.js b/cms_form/static/src/js/master_slave.js new file mode 100644 index 00000000..17575aed --- /dev/null +++ b/cms_form/static/src/js/master_slave.js @@ -0,0 +1,89 @@ +odoo.define('cms_form.master_slave', function (require) { + 'use strict'; + /* + Handle master / slave fields automatically. + TODO: explain behavior. + */ + + // TODO: this does not work ATM :( + // var pyeval = require('web.pyeval'); + var animation = require("web_editor.snippets.animation"); + var $ = require("$"); + + return animation.registry.CMSFormMasterSlave = animation.Class.extend({ + selector: ".cms_form_wrapper form", + start: function (editable_mode) { + this.data = this.$el.data('form'); + this.setup_handlers(); + this.load_master_slave(); + }, + setup_handlers: function(){ + this.handlers = { + 'hide': $.proxy(this.handle_hide, this), + 'show': $.proxy(this.handle_show, this), + 'readonly': $.proxy(this.handle_readonly, this), + 'no_readonly': $.proxy(this.handle_no_readonly, this), + 'required': $.proxy(this.handle_required, this), + 'no_required': $.proxy(this.handle_no_required, this) + }; + }, + load_master_slave: function(){ + var self = this; + $.each(this.data.master_slave, function(master, slaves){ + var $master_input = $('[name="' + master +'"]'); + $.each(slaves, function(action, mapping){ + var handler = self.handlers[action]; + if (handler) { + $master_input.on('change', function(){ + handler($(this), mapping) } + ).filter(':selected,:checked,[type=text]').trigger('change'); // trigger change only for specific inputs + } + }) + }) + }, + // TODO: merge these functions as they are pretty much equals + handle_hide: function($input, mapping){ + $.each(mapping, function(slave_fname, values){ + if ($.inArray($input.val(), values) >= 0) { + $('[name="' + slave_fname +'"]').closest('.form-group').hide(); + } + }); + }, + handle_show: function($input, mapping){ + $.each(mapping, function(slave_fname, values){ + if ($.inArray($input.val(), values) >= 0) { + $('[name="' + slave_fname +'"]').closest('.form-group').show(); + } + }); + }, + handle_readonly: function($input, mapping){ + $.each(mapping, function(slave_fname, values){ + if ($.inArray($input.val(), values) >= 0) { + $('[name="' + slave_fname +'"]').attr('disabled', 'disabled'); + } + }); + }, + handle_no_readonly: function($input, mapping){ + $.each(mapping, function(slave_fname, values){ + if ($.inArray($input.val(), values) >= 0) { + $('[name="' + slave_fname +'"]').attr('disabled', null); + } + }); + }, + handle_required: function($input, mapping){ + $.each(mapping, function(slave_fname, values){ + if ($.inArray($input.val(), values) >= 0) { + $('[name="' + slave_fname +'"]').attr('required', 'required'); + } + }); + }, + handle_no_required: function($input, mapping){ + $.each(mapping, function(slave_fname, values){ + if ($.inArray($input.val(), values) >= 0) { + $('[name="' + slave_fname +'"]').attr('required', null); + } + }); + } + }); + +}); diff --git a/cms_form/static/src/js/select2widgets.js b/cms_form/static/src/js/select2widgets.js new file mode 100644 index 00000000..a8562893 --- /dev/null +++ b/cms_form/static/src/js/select2widgets.js @@ -0,0 +1,86 @@ +odoo.define('cms_form.select2widgets', function (require) { + 'use strict'; + + var Model = require('web.Model'); + var ajax = require('web.ajax'); + var core = require('web.core'); + var base = require('web_editor.base'); + + var _t = core._t; + + + if(!$('.js_select2_m2m_widget').length) { + return $.Deferred().reject("DOM doesn't contain '.js_select2_m2m_widget'"); + } + + $(document).ready(function () { + + $('input.js_select2_m2m_widget').each(function(){ + var $input = $(this); + var lastsearch = []; + $input.select2({ + multiple: true, + tags: true, + tokenSeparators: [",", " ", "_"], + formatResult: function(term) { + if (term.isNew) { + return 'New ' + _.escape(term.text); + } else { + return _.escape(term.text); + } + }, + query: function(options) { + var domain = []; + if (options.term){ + domain.push([ + $input.data('search_field') || 'name', 'ilike', '%' + options.term + '%' + ]) + } + // TODO: use data.CompundDomain to build domain + // ATM it's just in backend assets + // and requires both data.js and pyeval.js + domain = _.union(domain, $input.data('domain')); + ajax.jsonRpc("/web/dataset/call_kw", 'call', { + model: $input.data('model'), + method: 'search_read', + args: [domain], + kwargs: { + fields: $input.data('fields'), + context: base.get_context() + } + }).then(function(data) { + var display_name = $input.data('display_name'); + data.sort(function(a, b) { + return a[display_name].localeCompare(b[display_name]); + }); + var res = { + results: [] + }; + _.each(data, function(x) { + res.results.push({ + id: x.id, + text: x[display_name], + isNew: false + }); + }); + options.callback(res); + }); + }, + // Default tags from the input value + initSelection: function(element, callback) { + var data = []; + _.each(element.data('init-value'), function(x) { + data.push({ + id: x.id, + text: x.name, + isNew: false + }); + }); + element.val(''); + callback(data); + }, + }); + }); + + }); +}); diff --git a/cms_form/static/src/js/textarea_widget.js b/cms_form/static/src/js/textarea_widget.js new file mode 100644 index 00000000..97e160b9 --- /dev/null +++ b/cms_form/static/src/js/textarea_widget.js @@ -0,0 +1,23 @@ +odoo.define('cms_form.textarea_widget', function (require) { + 'use strict'; + + var ajax = require('web.ajax'); + + $(document).ready(function () { + $('textarea[maxlength]').bind('input propertychange', function(){ + var $self = $(this), + maxlength = parseInt($self.attr('maxlength')), + length = $self.val().length, + left = maxlength - length, + $counter = $self.siblings('.text-counter'); + if ($self.data('counter')) { + $counter = $($self.data('counter')); + } + if (left < 0) { + left = 0; + } + $counter.val(left); + }).trigger('input'); + }); + +}); diff --git a/cms_form/static/src/less/cms_form.less b/cms_form/static/src/less/cms_form.less new file mode 100644 index 00000000..03227bc4 --- /dev/null +++ b/cms_form/static/src/less/cms_form.less @@ -0,0 +1,15 @@ +.cms_form_wrapper{ + form{ + .field-required{ + .control-label::after{ + content: '*'; + font-weight: bold; + } + } + .text-counter{ + width: auto; + float: right; + margin-top: 0.2em; + } + } +} diff --git a/cms_form/templates/assets.xml b/cms_form/templates/assets.xml new file mode 100644 index 00000000..5121bb20 --- /dev/null +++ b/cms_form/templates/assets.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/cms_form/templates/form.xml b/cms_form/templates/form.xml new file mode 100644 index 00000000..4ab8efb6 --- /dev/null +++ b/cms_form/templates/form.xml @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cms_form/templates/widgets.xml b/cms_form/templates/widgets.xml new file mode 100644 index 00000000..26740740 --- /dev/null +++ b/cms_form/templates/widgets.xml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + +