From e269d3692f68c7656ab0e5178be5e850fc95946c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 29 May 2017 18:30:50 +0200 Subject: [PATCH 001/238] [ADD] cms_form --- cms_form/README.rst | 222 ++++++++ cms_form/__init__.py | 2 + cms_form/__openerp__.py | 24 + cms_form/controllers/__init__.py | 1 + cms_form/controllers/main.py | 164 ++++++ cms_form/i18n/de.po | 150 ++++++ .../cms_form_example_create_partner.png | Bin 0 -> 12328 bytes .../images/cms_form_example_edit_partner.png | Bin 0 -> 13320 bytes cms_form/images/cms_form_example_search.png | Bin 0 -> 12899 bytes cms_form/models/__init__.py | 6 + cms_form/models/cms_form.py | 240 +++++++++ cms_form/models/cms_form_mixin.py | 475 ++++++++++++++++++ cms_form/models/cms_search_form.py | 129 +++++ cms_form/models/test_models.py | 78 +++ cms_form/models/website_mixin.py | 29 ++ cms_form/models/widgets.py | 330 ++++++++++++ cms_form/security/cms_form.xml | 22 + cms_form/static/description/icon.png | Bin 0 -> 9455 bytes cms_form/static/src/js/date_widget.js | 27 + cms_form/static/src/js/master_slave.js | 89 ++++ cms_form/static/src/js/select2widgets.js | 86 ++++ cms_form/static/src/js/textarea_widget.js | 23 + cms_form/static/src/less/cms_form.less | 15 + cms_form/templates/assets.xml | 20 + cms_form/templates/form.xml | 156 ++++++ cms_form/templates/widgets.xml | 167 ++++++ cms_form/tests/__init__.py | 6 + cms_form/tests/common.py | 77 +++ cms_form/tests/test_controllers.py | 94 ++++ cms_form/tests/test_extractors.py | 5 + cms_form/tests/test_form_base.py | 226 +++++++++ cms_form/tests/test_form_cms.py | 95 ++++ cms_form/tests/test_form_render.py | 42 ++ cms_form/tests/test_form_search.py | 67 +++ cms_form/tests/test_loaders.py | 5 + cms_form/tests/test_widgets.py | 5 + cms_form/utils.py | 98 ++++ 37 files changed, 3175 insertions(+) create mode 100644 cms_form/README.rst create mode 100644 cms_form/__init__.py create mode 100644 cms_form/__openerp__.py create mode 100644 cms_form/controllers/__init__.py create mode 100644 cms_form/controllers/main.py create mode 100644 cms_form/i18n/de.po create mode 100644 cms_form/images/cms_form_example_create_partner.png create mode 100644 cms_form/images/cms_form_example_edit_partner.png create mode 100644 cms_form/images/cms_form_example_search.png create mode 100644 cms_form/models/__init__.py create mode 100644 cms_form/models/cms_form.py create mode 100644 cms_form/models/cms_form_mixin.py create mode 100644 cms_form/models/cms_search_form.py create mode 100644 cms_form/models/test_models.py create mode 100644 cms_form/models/website_mixin.py create mode 100644 cms_form/models/widgets.py create mode 100644 cms_form/security/cms_form.xml create mode 100644 cms_form/static/description/icon.png create mode 100644 cms_form/static/src/js/date_widget.js create mode 100644 cms_form/static/src/js/master_slave.js create mode 100644 cms_form/static/src/js/select2widgets.js create mode 100644 cms_form/static/src/js/textarea_widget.js create mode 100644 cms_form/static/src/less/cms_form.less create mode 100644 cms_form/templates/assets.xml create mode 100644 cms_form/templates/form.xml create mode 100644 cms_form/templates/widgets.xml create mode 100644 cms_form/tests/__init__.py create mode 100644 cms_form/tests/common.py create mode 100644 cms_form/tests/test_controllers.py create mode 100644 cms_form/tests/test_extractors.py create mode 100644 cms_form/tests/test_form_base.py create mode 100644 cms_form/tests/test_form_cms.py create mode 100644 cms_form/tests/test_form_render.py create mode 100644 cms_form/tests/test_form_search.py create mode 100644 cms_form/tests/test_loaders.py create mode 100644 cms_form/tests/test_widgets.py create mode 100644 cms_form/utils.py diff --git a/cms_form/README.rst b/cms_form/README.rst new file mode 100644 index 000000000..6b1f2f903 --- /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 000000000..f7209b171 --- /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 000000000..33efef527 --- /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 000000000..12a7e529b --- /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 000000000..354a53123 --- /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 000000000..fd0e3dd70 --- /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 0000000000000000000000000000000000000000..6c5f33c0181d9199b7cf469b484ae6e675bbd9c7 GIT binary patch literal 12328 zcmb`tcUY5Iw?2&HD5HpoR6)?8O3l!bI!fqOdhfkU3lKt7h9+&0CVlAAMS2OKp;saF z(0fZFE!2eH!#L+X$8){sdcQwDE=Zoe+0QO(uf5i~@3kYfG!)5iGu$R2At6^*0&A0y z{2WX|a_#3|egbMn^ldeOuUnosw*GSSb=z&U7A7yoU(xsp7-4eUk=z}RD zA$d%q41T8TKZBWr>!KmS8|U?I7c_P8*I7FeJ?X}oY8_n)>AQU^Tq?L)uWBB9V_{b#vwQObS&Od9WUJRHr*c7gu>aUMbH$N7%nwQ-ho+FF!y3Fs)8rP8P9B z8PjF*B4RWA?Q=XgtgE~zsbt}How%wIpUHk*nN z(THRo+6Q6Ds+?4LHY3V|Jw+OIH*75%kamHO`>uqwcvrsixuA~#BuP)q$FDxn)u1rU zIJ=FtD6CoHj4I8MO8WDkF!4>{IWczSqIahvlJBi6{Uj>gj=ck%VT0tzaO2qGfK=w4 zs1syN9cfMr&?QOh`+~dSp|&6P7JF(U5*zPQv`iTKt|%ovAQ$#y-)B)dTB4#$2%RxQ zdudbkA6#Eyie2^8bY#owV@M*u9{<Nqgx`*Vh#)9$>6)Dx_k6HnjsK9Aq2dB; zULS7~-Wh_Z%o%bs@eOVnTl&~U0DWm?^lMZLXSCSVj8yVHH?dlvndV7(_$Vs6^IfrZ zJaxfh)W14!eQLYr#49*x>8 zjW|(KcG8G{(#GDWG=V`K0@+r+`Oj9U7C#r@Vqpnw$%?JH5nJ4ee;>5u71xk4GfDd} ztz}9p@5x&$$7x+_-X!{{XyMSn>jtsRJZj{RBz)8m)?ZWOCkw|ToqSpDJ8d%k(vKQY#J6Gt&ofMA#@Z={c^(@>R@*Ve(J({7-Ghy1D)swa9 zi3Tqct<4_HjCmULce+&R!V*SuQfwl}+V|R%$dgkIAtoEWy2KI&6*Wx`jl1Iyu^4xs zv`UFu8ju(ulvgf!Da>&k`z$Q99y)V_l0DN>uNU>A_+@ZAd!B|O+-Ae)c!N}RZKD7I z#ZZbHVcKC=M=4~`S42-gLFreqB>FAf0~N;vx!0ooo(&_xT)gb(=-A@42<^tUTIogK z>2eNA>4a!<{2yJ~r(;S6aXaApZeAAcHHx_Nqmg!$*)2*{v&|AGPOqwpOzu3kIm z{O(Pbz9Oj{VPxyV7OvP6V12syTLJz1n!V2>iMulkTDZD&`Im`$PYt+noi^K%#Mr6C zL1;s5?@moRjht%dO=w`3c33>at(enfk?t*L$X}S%nuz5OWOZi<{^n^m?H;x~)|{_B z`N`t6o5I<@jGGt)d{-*5z=APlIdJ~K8o?U%oW9rb%YKkW1JYMMwl|uIQSAXB*W*3+ z;Q~_sgxWr;&1w^4hPdLcRm%l`*oV3Xvu82U_VQqE*j!GRDr{;%)B)yD84t@*6sa*} zD}-))q@~<;hHL6ly(lpuy?K$scsL-MQtADdHUmYBUA0Y)X9&~?*8y@=;k%2}-i__+ z@8!9V0)tCTpprYFLgs{Ntov@AqYi0UAveQ2} z@;1#3H*db7GbvTO8oxd{Fh`bMfxB>920aSWS;8iuSU&BXw!XGA7_#-AASK=H`y3)y zd;x~HZDU%tu??TsZUUO*856^j>+q>F7>3^l&EWV!I( z-%mqQL_`H0Yn-(xe5A;+bE~m%#atsZgO;+`N1_s)I)1nrUtZlSX$6J?6-{t%mZ}x zmS0JP_Xo_Cdiaftvk7pW=BLp@#?`-{VMcJJ>e5maZpgaL58JYfRH)YN}9<0w+uCYaIo`_v?gxO5BH5K!EAwmh$l%Rt?OYDDJXXVkjOK$V9F`qVKeMD zv=nqx*x=ma&a81fFeu5a_Tl~Tek0L$gHbuLonEy^VsIk==2HO^gVbLJ?5mZ`!Tn`* zW=_tIzs1v0cKZ5TE~dFd6>>gqFPl9?*suDUVcq{QYW%XilmnOy-t)MJ6ywH~#lv3^ z64k2eSxs4Oq0A{)f_xp z*SlzKa`mkX=+!t_`@{PQ&5fUPA|lk{d=5MG)s#|4hGG+pSY(7XUBA7H z*08Ope{SU*BrKEEOufP{>XOR0?s5#@A{dsgcuMwz!8IKXfpZz}BjaLAQ=6G2ZX56Y zVrSp)tMO}~pw(=(WIMKme@LuSVP&#BEJH$uS(BGF0>*+W*zU!y%$fB)Jwd-C}j$NAH@#bQ&{t!Y~ncOwd#6wzUeK*_1D8f*k?6gtx zl$0Yys74p>g{nVZJj}{xoZKli=&`68(_J~xr;VavNaZ$7XN5{MnY#znwX*sdG+!9) zcJ>q$RJt2;tC2&F*psZ|_v>XQyYyMfU4y!e5LMEs!Xft%C-7yx< z4@|k8F2>xtWd9g1W8r$%#}QjAWwJxz7tXp;^MrnHff8SG&J^a8qZ)>- zLovT%yd`u_TS(p2Nc%SvUON_+0FnF3_Fj!@5(zvJ5xwIN4~@YcN=NlIK5-l=#tnyR zUQ%OTA9>>GUX-M#OGA7gzaL+FNxh;DAKvUdEGV);GR5$9k78M$X;!&u#Bdlq$amMB zM{BL&{9Cobwr93~6$steq(hqiDnvs@O5qo6C!c86Y7e^p`D&Zcbr%S8eMtA7l>vOX zDx%~;nCR1&US7+q~n0sL1PL@M&uxVoe_K| z_-Ul#`bgF%L4p0S7iv}46`lD;1lZGlvkhEbMCIA}EwpRw@5*MH4IRSy3dM?`o@=BF$N>KTX~wl~V6D^zpH__5#3(bATKWs+u}^ z>oz*K_?l~uyVV98&VByoIOlI`%Ue=7nr2U*n`p$18RxGY#x-w?7;->YexDK*7r(EQ zU^LjnnB2F$opz=G&+NCq|FYgs%xR9&R|535(;dXKP6~nItZtEaa)`)Ls}>-wge+8o zTfau8olGgQF=S`-IS1sO+~qGSEgYKE+A)n?-4tXyo^_WKIUI zgFtb!9AV~qE3|}D2cw*PLEPp!E%l%E(GOmpDkv&+-R~{-@hcAFF3rG7SR_o^u|uu` zWQ(4#&Q5vfzri22Bu4k|haaHYe?oL5KLE(@!0!i~`MtM)ni*24eE7+d??G66|C+tM z{rbj6F*%@ZM-??SHMzN8oNKw?e%eoCq@|-P23ZbzO)5Tu)%fmAl$+G2sc~@sOhRIb z;Gw>~F_b9@!OrHz$uxPLu>@Xo;Y{ewkc9_#$1<_Avongi{`DMK+eC9`XJ=?=sF1@@ zVq)TyYaPu*KJwR(e!#eX*jXpRR8e=^_w2x?U$;gTs-x>YE>{yY^<&MYF0Z*b1P2GB z>)cj>^Z$ypw=0IR-VN=CiocNh=_i-B!C_aD8*-5Pj%;Ur|FZ~Q)fUFp?annraPwWQ z#j5?wOj!shteKL!sFThYEWDN=0 z@x+l?d9>;o4#r|b*>yCTz#Jc>>kwtgUX0_win|(~0$*_E) z=k|z2`Ap!rCRw$t^2IkqoW=(E3+9<6PuZ1V+tP@O-EgF=_(qvP0jE%PQ_d88Ewy`N ztRp;42J-phL}=kGY>@5KUquU11b8v(lq@&#GKv%4xpHxX3IcfkcH_Us$&OipK_Of- zhHy5R-Zuu^orh6{yz`4AewFMrn5Sb!v|unbWelv6-7KLLU z4J{4+dMP6sub&l!txrpbm%7*I6Su=N5eG+Wr%zhD{XM>-n+353f)tMIS*0M zpq&x#1YCg+WiR5GU|4s-*jl(i#4Mamtk7u?9^(_dXWf8p0)v8_9)xwGXFjTGFMUly z+@&d!752RF_xNhC<5J-r;h4}{7V*A=%{no_7$AA@g$(jwAt+ihsP*bGx+aZwWF_8F zpLhQEuO=%_j>pa5D23%FW(wb!2flMKa#4{nnnsTQ?LMJ{;WL!hak6QpLAhP- z{FDMlkY;8*;?tv-uhGDHwdDJ{bnlp}bBn^idnPv|q@>&1saRRy9TJw8uvO+BVHKhD ztSym=x*5gK!cjWSn`cAeZxVwo7pJXutTfCoQfSpmi(4Y*(2EtHOnmsDE>Q0&8cH*<=}e6xloh2Mb@gem#~xP|#v6U4 zBda#Z$M=5Fna)o#?OoNPY+aM!+m*d_h~{2>o?{}$wDV`Jk$>luj;KSKvw znbHEcefD-Hyb^#gb^}an58uzznJHB7G7Yx!T{?KFnCizZBhvg>`+a7b|3Mq^FL4;m zeLK900zO6g+CVX#5|C^dyI zd*e5rR5!yGdk*-OV-FASXrwtCp9eBxc5?35=@Tj|_Q@2g0{VA96!TwBTM&AWTgCPb zE={-a*lG`z%W-&2rE}oD9kJk}RK*w_kGte|iM{DXx8nk{U#7|1{+g`;OXP2@2sqvF zo<3B3y;n>X!Y`CyfMi{SV@Vd)E-#*mJKi9+nAq_k&)rrE?fK8jIEfA|0XyW zD_FmipZ|%p1U%u6vXj%+#f8)nu>?w>A z@v1hV*LEmdw#}wH_T9U8*4#ooIHx4tjkxmLb9@LZfH?2fW2-Hv0dqk0UUJmmQpbD zwbvr@vcQRW1=UeMsZG{O{O{w=ViAAnM#-tY?Ny4w%ku@d zp@ZUhT<~M*OR`b0KZk+>gAdljnS)&yyonrfW{s$Dfl*vn45xIg+iVsf1u#g8zL94h z<01LfP07fvw%>_6Kf$@iLN7sa;g@k^+-}ZjWXB<&$mNAFP27=YG(F4cLUN5b!z{Ub z%9i=W1g8fxYz^ZE3f@>tVTSr2oK7D?;dsT$N7Btl<2Y$9RJ4{oO z_>1-ic*)+hO>r7h5o04wxhC6U3t!oMZ|-f|QaR7da_0XL|0w%3NwvF46eiM5wWUkRVc}~Z9`ds2S;M_GD%N4O&$f0u3X|@ zbM{xxUfkr#P)D39R<^;oz1_V}Z8Sv)*!p8T;x}}qT2P>Q@MU{5h`3>ph3<6&vOJzf z4h|dPkAnTTcCd5Jl9G}hzMRoVUyRe=c2=__H??2U!_}p}i{+5{2Y9qRoAm6&s8BLB z1i6GHA6MA7HBjw#bT5$Rew!O%tMHt&ptW&a$0PgZ*$7?Ja~Yh>xhUmR0-mRmxVF9_LAv0-;=d7gnkqFsRbN#@!ZKbsqxdW z&C6+Q0PHD)#aU)GoSsMsSqg_pR2H*mgrFrtOTd zkbaVmnzv}K*&^5X-`3qldJVs*gr)b?6?>06ZCtRsm&zepxcm3T?AU|ilzg_3AOQ@{?Y(@jl3$_NaicK>K@=X=z7%??2R zlRa8eg|t%14JFEJ2S*iyESI@WD&YLSwH6S!Yx>;zoI%S1aW#ZO<|O=hA&vqcvZNb1F$PGh?C_g zGcz-hHYD=T*Bry zN|_~+BZ_tEI%;%9$0TuRXLOzSaBC(MACR3e4Y4@IJ|gdwQiQy9C7!`0h@(0NN#eD; z2S@r!gqR5WjYb+k2lOkazUf_fLZOyrbQ5WFE4~qqHn0$7m{WX3*c-R-yAL;`>B?fw zl30wLOQr_&l!~3?I?UbZ1d>w0chrX?BUZJGcS|S&R%+}ICYq_;SOlq6`}sZQVDDRG z{13)uqk}&MDK`?T!q;|pjRK~KAA{bt&NBP^`QiKC&0B^t)rGGO9CqqgOjG1~w5G4~ zSpxP?>|i+xH1C%{H$ryI1{J9hdrEENwP^`Mw6T9(uB`NU`qa%3|H$1py<>V4!u5{9 zb>|lfieq1&Si8>UBd8loa|WO{OuM(@6rO{^bxQrK^j`iHyESDge!nd^MSh1Uk&>$p z=8Q4RIx9KZ+2KHQoHel)dlVHR*9oAWarNxLGnjqE|nQa#E+J{nbI zj=ON*$Sz3nfQHcY9S8Vj!yqSvW4qJU*)8W?kH`^F`)ZcH^+Cb&-qh*pu!xLcT+HMG zaj~pt^kF1!ux~sY5O}LAes|g%{r65&aX467o;ae2&ed7kRL*@L8@BIKmVx%2xDj`I3Z|?($dGOlsC`H@U-u|`bh~1Og6p1V} zt>jG5qS##`aVJ~6l`tA*ki2x0ClT6uKI)COt_<2({G1X#EOpuplCIauzfc}dxQ`4&R4L)neOY2fQBxVgFQ9UO{*4quTD6lDhk;motcpRjcLr6SqG4uYwTjZd9{ zcNVYxCMK5BYwKfHZ(Pn(*?6_Ov!5oSt>_y~-RT~-eK$fl#4QR4dJHXZ~ zVk01dcMH-NZi~%SoIS&oxjjJf!wqa06eF)WAT*ba6g~) zI#s4Yx!?JKU(vx(a3+wD%0*CX?^&KM>rcZC;4z zH3ZUhx>oU5=`}K<)6D{T(|C!6oUBa5z&JDpfAUj~;rg<<%^cCV9*x<%fn2d_L(67R z_R1U2#w;CLJK+NpE1t0@<=|k zx6XN0If3zmZWEZ0%7kq`Z)c0Y=2w8S$@7|nBx7_q7VmB?FP zUxF-Gy;dNsD)rq<_>`9L0z5kMVN^nD-oyd1HPlUdaX+Rorc`xI{F^?scG;0BR`E@L z8mjPYMnVtz?L>0##L)w0^`YGR;_WxRh(|m0BQV&?-qbfMsPH>ptT@%ymh#hP&5ldF zN4LcR&qxK)|1}yoXu!dmWszpyW)RwLI%jZo{=oCjC;;=EV}ee9&^=BE>=l`COI5BN zV2}ii<_5`!e0U|;Z-^A8Nd;%mv03|yh&50f5!!!4zSw}E2kcSgeifwx3pT3EK2>@vz;Sl9RP%h8c9jqqg2twtAA2 zsA*T35xlO?w4kD&5ky`(Z!1{^-FeM5$uIpr9c8ieFa9=}F|~ObDK2f38|CHJo4_?7 z;OsS!b2RsJ0jcdKtSjBy(xVf~I3sY4p@HRxvSP%DQ|pDzxykib9;A{)Q0L^EU>BxC z>PA&b1K#1|7)V2faa!xNy}qV{ZOhXKQSzL*1X5B(W701%db1*cQF^TqF1Ki|HBiUm zuoC~uOnO47RTu>>K=N;`9u|Dd+wi(US*2?XR<&@|8m-rqOMSiL|D)=VcyRgOSu;&JCE*kY8&b+oyCFcYlfJLTL4LkScPdFnlUY zN?Ld2PNJgh?NqRyrjbLbL-<28B*b$WN+0g5l^y7FAiEV%Hz=j0=e@s$*71=&+k7ApGk%lmgqSV>q=c4Qq@@T{D7JPx*++aAu_>!!)RROL!iZaYA zn=<=mFA15fyOYkykfHsgtYoQ_`|BiT1W7EK!_Fi>n?u9KAf4@^)kO%D!`r%`gRPkm zf-z#DAR^+u3%VJhaH55oM?kY>==+-D9_~#y01R0Bb$8_QGD|He zoM-DDkN)-u-)(Ri+(#VXph#Xd$LjyjYgYe>CIA0u)&E_yAGrJPy!3w?e_$&A?rzsV z{d}JlGxh&w19tW`ZxVg*pOLLzX5PZ&1-XSZGh(~dk%IB``ndX_d50c^M9U#$3 z?EKzWeXJe;wH(~8xDDD@BK>BUCvlezuyZ`Xk?@(+m36ZK9LV6nKoEY~!Gf@DkWy5M z%Fco=Q4pWYf*4g3$T7;AHSWxd12nPq-jyY4!Z_i(u%qp-aga2<^?eI_0z69 z!jQSYrv$oH|!FecFqRPSUy?l^m3pkrcXOx?@=_E0j79_%&-#R z{(+!?`PCa;UEP6!0bYy!%dE4a81}yF`wms$fQ{})-p#-U=F)vM9~>U8T)wwrjE&AA5Lqg&sZH@a?jS7%!N2F=eeyHnOpGL#8E^AA=x(AZh z2MZ52yc?%CJ1%!MvqZ2L@iLmN=fUXpmS0&u6?D0cFP!^(y*2^51SV)YV79_UY94E{ zorXR)^{aN&8S#XN!v*P?zfDgDj8Ip4czjzjeX@TXxtsB#3DJf5<&0P>z=*p%Igkwg zc%cc;dJ@cCeRg@=Hx(zFHQ;@PDFM}{q~z*|V~R==x$}@hBC6cf51LfkH$kaVYAKd|M|^S~{dkcjBWi z6u79jgW-sO9wVHm?>atTa_u^#;h(emZYDf5Tj=#4$Qf!*>wgedSI`gYIvM)1u_T z)Gzj{dTiAp{^Ei%vurjwIr$Nrds%WhWku&5lDG^2s+S8JyWoqJ$l15*=IJ!UZ2~cbl*l6JJmXl~=-G~i zcSO&Z4Hh(B)=%^hez}=mNgN(ST40V~?3AY?)$n_dGO9#z_2$gWB9|}GUuErvE~UoK zQ?3$QfSPLMJ5-0+r62=CvfX?U9+{gJ_383}-<{{S;ZEfuT0g-z-K;kPcdYvu*Z34Z zz9Dj-N;6@>NhH+`+a8adV5p|PAOFEWoxoqJ+_CNIxW7r2w>^{S&VOGyHuEx+we9#& z(yi@c#)H_;u_2dK>IlH;6n9fq9n{M*rtIwyI^DJHpHJw=Z;yJp=+?|?LT?7oI1t{Rm+DX2tR1zvdsGmN zo?JjU9XwhF16X19PQiC<3jgXg*rj1)=V2k6xzN6e8dfz2(X+5*_kX!TX=M#CjDW~K z{#1$>Eh{P%T>fOXVIV#6fPO3}N6g&YNIf_1rZckM`<|Tv%9G8q&3VDapeG2sF)}-x zhl@-ME+EKa$)u`nc)fVcm{UL817M99;2yA&>b%p}2}2p| z@;nLE6-4ak@6x85_2!SN78m{|!py9`O`EPhGOCk;uiWr*NopddD8>q7@PH*0yfpG! zf1}-GWc25kH%h(h1#5Qcpkw;kGTmelp-P)ujg~9_Xt+@m@B$-b?R3^&^>t9YvcUyP zW`m;9EiT_0(AWDadSU^k9okOuy3YZ(Kf02$l$g6Jg6YD@a~NcHQ#0rYKXuq19b@(p_LH>SIL$oChaRy z0A7{xN=|b6n#X%9@kHX1&F5!)AiL&_tgu!w1~YxiVhT}C35jgqsLmxP%&uR$vBV6+ z;Pv>X9bDh8JDhK7eEjGOD$TG2fkBjOBt-B`O)_ua5|Np+kLbiYZMz)5pUg)tU2Ijz z%8Z-39AB=3Ag)seFBN*rz-%*H^7+z`85i@ZE=F_w7*6-y@;c*%op^D?rd zh;z@8IH@_4suA%7GD6DMmWi~xC}ThN@N$T_4Ox&pA!w4j3ji$n`qdR>ZL;6%VeK47HT_tm}_SizQo$1OEJU*KTcIdxB<==(>34H%IkZczO;IFs0H^uMI z*4NinR8>NgH>gF?(ek3}J^#z8>HY2Yq)2Y0rluBu_+Z$&x*&5Jl?|e& z=(0Nt`s{yZA^imdP^AF=4z#N^mObCCR^zA+Mz;bz3#JY@WW(%hjZ;7#6?1O$ymYrW zCVbJ8%b}rUq26nS0muL9;RlTSgY*42Va|U<-rsxs ep&1Da%cbm^&_LLoh~~Vj5tZdNz!lFe-~2xbV@t*W literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..73be6518d1a4161a0f0f51a06c134cbd7db797b4 GIT binary patch literal 13320 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 `_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 000000000..19f699b5d --- /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 000000000..65470c475 --- /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 000000000..b1a6d5e76 --- /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 000000000..5363b764a --- /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 000000000..b1edc6f0c --- /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 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 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 000000000..4b87e1532 --- /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 000000000..17575aede --- /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 000000000..a85628937 --- /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 000000000..97e160b94 --- /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 000000000..03227bc40 --- /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 000000000..5121bb20a --- /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 000000000..4ab8efb6a --- /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 000000000..267407407 --- /dev/null +++ b/cms_form/templates/widgets.xml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -71,6 +124,7 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + From 5f0d92c12a2051c283eee9fc16b8a4b23ecbc3f5 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 1 Mar 2018 09:10:47 +0100 Subject: [PATCH 022/238] cms_form: ease override of JSON info --- cms_form/models/cms_form_mixin.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cms_form/models/cms_form_mixin.py b/cms_form/models/cms_form_mixin.py index 68d5dba90..e16579e5c 100644 --- a/cms_form/models/cms_form_mixin.py +++ b/cms_form/models/cms_form_mixin.py @@ -439,12 +439,15 @@ def form_css_klass(self): """ return self._form_extra_css_klass - def form_json_info(self): + def _form_json_info(self): info = {} info.update({ 'master_slave': self._form_master_slave_info() }) - return json.dumps(info) + return info + + def form_json_info(self): + return json.dumps(self._form_json_info()) def _form_master_slave_info(self): """Return info about master/slave fields JSON compatible. From 6cd8d36d7897114920b47809c8adc473e182a43e Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 23 Mar 2018 15:09:06 +0100 Subject: [PATCH 023/238] cms_form: bump 11.0.1.0.4 (and add changelog) --- cms_form/CHANGES.rst | 19 +++++++++++++++++++ cms_form/__manifest__.py | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 cms_form/CHANGES.rst diff --git a/cms_form/CHANGES.rst b/cms_form/CHANGES.rst new file mode 100644 index 000000000..691529925 --- /dev/null +++ b/cms_form/CHANGES.rst @@ -0,0 +1,19 @@ +========= +CHANGELOG +========= + +11.0.1.0.4 (2018-03-23) +----------------------- + +* cms_form: ease override of JSON info +* cms_form_example: add fieldsets forms +* cms_form: add fieldsets support + + +11.0.1.0.3 (2018-03-21) +----------------------- + +* Form controller: main_object defaults to empty recordset +* cms_form: fix x2m widget value comparison +* cms_form: fix x2m widget load default value empty + diff --git a/cms_form/__manifest__.py b/cms_form/__manifest__.py index cd2194989..5daa80f52 100644 --- a/cms_form/__manifest__.py +++ b/cms_form/__manifest__.py @@ -5,7 +5,7 @@ 'name': 'CMS Form', 'summary': """ Basic content type form""", - 'version': '11.0.1.0.3', + 'version': '11.0.1.0.4', 'license': 'LGPL-3', 'author': 'Camptocamp, Odoo Community Association (OCA)', 'depends': [ From 3efad641900e5c7410721ad0b7510109f034124f Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 22 Mar 2018 16:03:47 +0100 Subject: [PATCH 024/238] cms_form widget: fix json params rendering --- cms_form/models/widgets.py | 3 +++ cms_form/templates/widgets.xml | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/cms_form/models/widgets.py b/cms_form/models/widgets.py index 8032040cc..9478293c6 100644 --- a/cms_form/models/widgets.py +++ b/cms_form/models/widgets.py @@ -66,6 +66,9 @@ def w_ids_from_input(self, value): def w_subfields_by_value(self, value='_all'): return self.w_subfields.get(value, {}) + def w_data_json(self): + return json.dumps(self.w_data) + class CharWidget(models.AbstractModel): _name = 'cms.form.widget.char' diff --git a/cms_form/templates/widgets.xml b/cms_form/templates/widgets.xml index 572ee661f..76af51d28 100644 --- a/cms_form/templates/widgets.xml +++ b/cms_form/templates/widgets.xml @@ -13,7 +13,7 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). t-att-name="widget.w_fname" t-att-value="widget.w_field_value" t-att-required="widget.w_field['required'] and '1' or None" - t-att-data-params="widget.w_data" + t-att-data-params='widget.w_data_json()' t-att-placeholder="widget.w_field['string'] + '...'" /> @@ -25,7 +25,7 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). From 609ff59cc124eb941842ef4c0af70725e95d3223 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 22 Mar 2018 16:12:52 +0100 Subject: [PATCH 026/238] cms_form search: fix date search w/ empty value --- cms_form/models/cms_search_form.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cms_form/models/cms_search_form.py b/cms_form/models/cms_search_form.py index b0ac1af9e..6aa79ac8c 100644 --- a/cms_form/models/cms_search_form.py +++ b/cms_form/models/cms_search_form.py @@ -123,6 +123,10 @@ def form_search_domain(self, search_values): continue elif field['type'] in ('boolean', ): value = value == 'on' and True + elif field['type'] in ('date', 'datetime'): + if not value: + # searching for an empty string breaks search + continue leaf = (fname, operator, value) domain.append(leaf) return domain From b41da92791fa6967306f676038c375d072691420 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 22 Mar 2018 16:15:20 +0100 Subject: [PATCH 027/238] cms_form: add multi value widget for search forms --- cms_form/models/cms_search_form.py | 8 ++++++- cms_form/models/widgets.py | 24 ++++++++++++++++++-- cms_form/templates/widgets.xml | 9 ++++++++ cms_form/tests/fake_models.py | 18 ++++++++++++++- cms_form/tests/test_form_search.py | 36 ++++++++++++++++++++++++------ 5 files changed, 84 insertions(+), 11 deletions(-) diff --git a/cms_form/models/cms_search_form.py b/cms_form/models/cms_search_form.py index 6aa79ac8c..e933c0908 100644 --- a/cms_form/models/cms_search_form.py +++ b/cms_form/models/cms_search_form.py @@ -18,10 +18,12 @@ class CMSFormSearch(models.AbstractModel): _form_mode = 'search' _form_extract_value_mode = 'read' # show results if no query has been submitted? - _form_show_results_no_submit = 1 + _form_show_results_no_submit = True _form_results_per_page = 10 # sort by this param, defaults to model's `_order` _form_results_orderby = '' + # declare fields that must be searched w/ multiple values + _form_search_fields_multi = () def form_update_fields_attributes(self, _fields): """No field should be mandatory.""" @@ -103,6 +105,10 @@ def form_search_domain(self, search_values): value = search_values.get(fname) if value is None: continue + if fname in self._form_search_fields_multi: + leaf = (fname, 'in', value) + domain.append(leaf) + continue if field['type'] in ('many2one', ) and value < 1: # we need an existing ID here ( > 0) continue diff --git a/cms_form/models/widgets.py b/cms_form/models/widgets.py index 3ad231970..cb9e73e9f 100644 --- a/cms_form/models/widgets.py +++ b/cms_form/models/widgets.py @@ -111,9 +111,9 @@ def w_option_items(self): def w_load(self, **req_values): value = super().w_load(**req_values) - return self.m2o_to_form(value) + return self.m2o_to_form(value, **req_values) - def m2o_to_form(self, value): + def m2o_to_form(self, value, **req_values): # important: return False if no value # otherwise you will compare an empty recordset with an id # in a select input in form widget template. @@ -136,6 +136,26 @@ def form_to_m2o(self, value, **req_values): return val > 0 and val or None +class M2OMultiWidget(models.AbstractModel): + _name = 'cms.form.widget.many2one.multi' + _inherit = 'cms.form.widget.many2one' + _w_template = 'cms_form.field_widget_m2o_multi' + w_diplay_field = 'display_name' + + def m2o_to_form(self, value, **req_values): + if not value: + return json.dumps([]) + if (isinstance(value, str) and + value == req_values.get(self.w_fname)): + value = self.w_comodel.browse( + self.w_ids_from_input(value)).read(['name']) + value = json.dumps(value) + return value + + def form_to_m2o(self, value, **req_values): + return self.w_ids_from_input(value) if value else None + + class SelectionWidget(models.AbstractModel): _name = 'cms.form.widget.selection' _inherit = 'cms.form.widget.mixin' diff --git a/cms_form/templates/widgets.xml b/cms_form/templates/widgets.xml index bb5692d9b..343a33a6e 100644 --- a/cms_form/templates/widgets.xml +++ b/cms_form/templates/widgets.xml @@ -94,6 +94,15 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + + + - + + + @@ -69,7 +80,10 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). @@ -58,6 +64,14 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + + + + From 05f396f2f24a0a418d20983388a751b60fd7007c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 9 Apr 2018 10:28:44 +0200 Subject: [PATCH 044/238] Bump `cms_form` 11.0.1.2.0 --- cms_form/CHANGES.rst | 83 +++++++++++++++++++++++++++++++++++++--- cms_form/__manifest__.py | 2 +- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/cms_form/CHANGES.rst b/cms_form/CHANGES.rst index 39f2829d8..cedebf5e4 100644 --- a/cms_form/CHANGES.rst +++ b/cms_form/CHANGES.rst @@ -3,27 +3,93 @@ CHANGELOG ========= +11.0.1.2.0 (2018-04-09) +======================= + +Improve +------- + +* Add error msg block for validation errors right below field +* Support multiple values for same field + + In the input markup you can set the field name as `$fname:list`. + + This will make the form transform submitted values as a list. + + Example:: + + + + + + Will be translated to: `{'foo': [1, 2, 3]}` + + +* Add `lock copy paste` option + + You can now pass `lock_copy_paste` to widget init via `css_klass` arg + to set an input/text w/ copy/paste disabled. + + Example:: + + def form_get_widget(self, fname, field, **kw): + """Disable copy paste on `foo`.""" + if fname == 'foo': + kw['css_klass'] = 'lock_copy_paste' + return super().form_get_widget(fname, field, **kw) + + +* `form_get_widget` pass keyword args to ease customization +* Form controller: better HTTP status for redirect (303) and no cache +* Improve custom attributes override +* Move `check_permission` to form + + You can now customize permission check on each form. + Before this change you had to override the controller to gain control on it. + + +Fix +--- + +* Fix required attr on boolean widget (was not considered) +* `_form_create` + `_form_write` use a copy of values to avoid pollution by Odoo +* Fix handling of forms w/ no form_model + (some code blocks were relying on `form_model` to be there) + + 11.0.1.1.1 (2018-03-26) ------------------------ +======================= + +Fix +--- * Fix date widget: default today only if empty 11.0.1.1.0 (2018-03-26) ------------------------ +======================= + +Improve +------- * Delegate field wrapper class computation to form * Add vertical fields option * Add multi value widget for search forms * Improve date widget: allow custom default today +Fix +--- + * Fix fieldset support for search forms * Fix date search w/ empty value * Fix json params rendering on widgets 11.0.1.0.4 (2018-03-23) ------------------------ +======================= + +Improve +------- * Ease override of JSON info * Add fieldsets support @@ -31,10 +97,15 @@ CHANGELOG 11.0.1.0.3 (2018-03-21) ------------------------ +======================= + +Improve +------- * Form controller: main_object defaults to empty recordset -* Fix x2m widget value comparison -* Fix x2m widget load default value empty +Fix +--- +* Fix x2m widget value comparison +* Fix x2m widget load default value empt^^ diff --git a/cms_form/__manifest__.py b/cms_form/__manifest__.py index 236e0727d..5dbddfa5c 100644 --- a/cms_form/__manifest__.py +++ b/cms_form/__manifest__.py @@ -5,7 +5,7 @@ 'name': 'CMS Form', 'summary': """ Basic content type form""", - 'version': '11.0.1.1.1', + 'version': '11.0.1.2.0', 'license': 'LGPL-3', 'author': 'Camptocamp, Odoo Community Association (OCA)', 'depends': [ From eec75de28529e951c4fbecfa8c6a2bf564c45787 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 9 Apr 2018 18:25:16 +0200 Subject: [PATCH 045/238] cms_form: add fake_session helper for tests --- cms_form/tests/common.py | 9 +++++++-- cms_form/tests/utils.py | 25 ++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/cms_form/tests/common.py b/cms_form/tests/common.py index ea6c62b2d..568aeec8f 100644 --- a/cms_form/tests/common.py +++ b/cms_form/tests/common.py @@ -4,7 +4,10 @@ from lxml import html from odoo.tests.common import SavepointCase, HttpCase -from .utils import fake_request, setup_test_model, teardown_test_model +from .utils import ( + fake_request, fake_session, + setup_test_model, teardown_test_model +) class FormTestMixin(object): @@ -24,7 +27,9 @@ def get_form(self, form_model, req=None, ctx=None, sudo_uid=None, **kw): model = model.sudo(sudo_uid) if ctx: model = model.with_context(**ctx) - request = req or fake_request() + + session = session if session is not None else fake_session(self.env) + request = req or fake_request(session=session) return model.form_init(request, **kw) # override this in your test case to inject new models on the fly diff --git a/cms_form/tests/utils.py b/cms_form/tests/utils.py index 59286a1f9..8b21e54e6 100644 --- a/cms_form/tests/utils.py +++ b/cms_form/tests/utils.py @@ -7,11 +7,12 @@ import mock import urllib.parse -from odoo import http +from odoo import http, api +from odoo.tests.common import get_db_name def fake_request(form_data=None, query_string=None, - method='GET', content_type=None): + method='GET', content_type=None, session=None): data = urllib.parse.urlencode(form_data or {}) query_string = query_string or '' content_type = content_type or 'application/x-www-form-urlencoded' @@ -22,7 +23,7 @@ def fake_request(form_data=None, query_string=None, input_stream=StringIO(data), content_type=content_type, method=method) - w_req.session = mock.MagicMock() + w_req.session = session if session is not None else mock.MagicMock() # odoo request o_req = http.HttpRequest(w_req) o_req.website = mock.MagicMock() @@ -32,6 +33,24 @@ def fake_request(form_data=None, query_string=None, return o_req +def fake_session(env, **kw): + db = get_db_name() + env = api.Environment(env.cr, env.uid, {}) + session = http.root.session_store.new() + session.db = db + session.uid = env.uid + session.login = 'admin' + session.password = '' + session.context = env['res.users'].context_get() or {} + session.context['uid'] = env.uid + session._fix_lang(session.context) + for k, v in kw.items(): + if hasattr(session, k): + setattr(session, k, v) + session.__fake_session = True + return session + + def setup_test_model(env, model_cls): """Pass a test model class and initialize it. From 577c6cc24cbf497faff231c9d1b1bf87168734d5 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 10 Apr 2018 10:56:38 +0200 Subject: [PATCH 046/238] cms_form: make test on validation error defensive --- cms_form/tests/test_form_cms.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cms_form/tests/test_form_cms.py b/cms_form/tests/test_form_cms.py index 6d28cacf4..11d023629 100644 --- a/cms_form/tests/test_form_cms.py +++ b/cms_form/tests/test_form_cms.py @@ -80,7 +80,11 @@ def test_create_or_update_with_errors(self): req=request) with mute_logger('odoo.sql_db'): values = form.form_process_POST({}) - self.assertTrue('_integrity' in values['errors']) + self.assertTrue( + # custom modules can provide different errors for constraints + '_integrity' in values['errors'] or + '_validation' in values['errors'] + ) def test_purge_non_model_fields(self): data = { From 83e298c2798a5e56c469b31de17ded0ac31c2ebe Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 13 Apr 2018 11:34:43 +0200 Subject: [PATCH 047/238] cms_form: fix search form regression on permission check In 85ccc35 I've moved permission check from controller to form but I missed the bypass for search forms. --- cms_form/models/cms_form_mixin.py | 1 + cms_form/models/cms_search_form.py | 4 ++++ cms_form/tests/test_form_search.py | 12 +++++++++--- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/cms_form/models/cms_form_mixin.py b/cms_form/models/cms_form_mixin.py index 5981c1d56..0051ee1c7 100644 --- a/cms_form/models/cms_form_mixin.py +++ b/cms_form/models/cms_form_mixin.py @@ -143,6 +143,7 @@ def form_check_permission(self): self._can_edit() else: self._can_create() + return True def _can_create(self, raise_exception=True): """Check that current user can create instances of given model.""" diff --git a/cms_form/models/cms_search_form.py b/cms_form/models/cms_search_form.py index e933c0908..6f2ab0879 100644 --- a/cms_form/models/cms_search_form.py +++ b/cms_form/models/cms_search_form.py @@ -25,6 +25,10 @@ class CMSFormSearch(models.AbstractModel): # declare fields that must be searched w/ multiple values _form_search_fields_multi = () + def form_check_permission(self): + """Just searching, nothing to check here.""" + return True + def form_update_fields_attributes(self, _fields): """No field should be mandatory.""" super().form_update_fields_attributes(_fields) diff --git a/cms_form/tests/test_form_search.py b/cms_form/tests/test_form_search.py index 53931509d..83f1330e8 100644 --- a/cms_form/tests/test_form_search.py +++ b/cms_form/tests/test_form_search.py @@ -61,9 +61,10 @@ def assert_results(self, form, count, expected): sorted(expected.mapped('id')), ) - def get_search_form(self, data, form_model='cms.form.search.res.partner'): + def get_search_form( + self, data, form_model='cms.form.search.res.partner', **kw): request = fake_request(form_data=data) - form = self.get_form(form_model, req=request) + form = self.get_form(form_model, req=request, **kw) # restrict search results to these ids form.test_record_ids = self.expected_partners_ids return form @@ -94,10 +95,15 @@ def test_search_multi(self): self.env.ref('base.it').id, self.env.ref('base.fr').id, ] - data = {'country_id': ','.join(map(str, countries))} + data = {'country_id': ','.join(map(str, countries))} form = self.get_search_form( data, form_model='cms.form.search.res.partner.multicountry') form.form_process() expected = self.expected_partners.filtered( lambda x: x.country_id.id in countries) self.assert_results(form, 3, expected) + + def test_search_form_bypass_security_check(self): + form = self.get_search_form( + {}, sudo_uid=self.env.ref('base.public_user').id) + self.assertTrue(form.form_check_permission()) From 8251c64d90d8e699ad0b1963ba815b9dbb816ecd Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 13 Apr 2018 15:33:22 +0200 Subject: [PATCH 048/238] Bump `cms_form` 11.0.1.2.1 --- cms_form/CHANGES.rst | 12 ++++++++++++ cms_form/__manifest__.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/cms_form/CHANGES.rst b/cms_form/CHANGES.rst index cedebf5e4..6382decb7 100644 --- a/cms_form/CHANGES.rst +++ b/cms_form/CHANGES.rst @@ -3,6 +3,18 @@ CHANGELOG ========= +11.0.1.2.1 (2018-04-13) +======================= + +Fix +--- + +* Fix search form regression on permission check + + In 32a662e I've moved permission check from controller to form + but I missed the bypass for search forms. + + 11.0.1.2.0 (2018-04-09) ======================= diff --git a/cms_form/__manifest__.py b/cms_form/__manifest__.py index 5dbddfa5c..6cab6e025 100644 --- a/cms_form/__manifest__.py +++ b/cms_form/__manifest__.py @@ -5,7 +5,7 @@ 'name': 'CMS Form', 'summary': """ Basic content type form""", - 'version': '11.0.1.2.0', + 'version': '11.0.1.2.1', 'license': 'LGPL-3', 'author': 'Camptocamp, Odoo Community Association (OCA)', 'depends': [ From 39583ffeaa1589993b5e7a5ae33f3792f7927bf0 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Sat, 24 Feb 2018 17:44:35 +0100 Subject: [PATCH 049/238] cms_form: add wizard support --- cms_form/README.rst | 62 ++++++++- cms_form/controllers/main.py | 44 +++++- cms_form/models/__init__.py | 1 + cms_form/models/cms_form_wizard.py | 157 ++++++++++++++++++++++ cms_form/static/src/less/progressbar.less | 91 +++++++++++++ cms_form/templates/assets.xml | 6 +- cms_form/templates/form.xml | 45 +++++++ cms_form/tests/__init__.py | 1 + cms_form/tests/common.py | 18 ++- cms_form/tests/fake_models.py | 58 +++++++- cms_form/tests/test_controllers.py | 68 +++++++--- cms_form/tests/test_form_wizard.py | 108 +++++++++++++++ cms_form/tests/utils.py | 15 ++- 13 files changed, 640 insertions(+), 34 deletions(-) create mode 100644 cms_form/models/cms_form_wizard.py create mode 100644 cms_form/static/src/less/progressbar.less create mode 100644 cms_form/tests/test_form_wizard.py diff --git a/cms_form/README.rst b/cms_form/README.rst index 248368b5e..76f22e21f 100644 --- a/cms_form/README.rst +++ b/cms_form/README.rst @@ -13,8 +13,8 @@ this is the module you are looking for. Features ======== -* automatic form generation (create, write, search) -* automatic route generation (create, write, search) +* automatic form generation (create, write, search, wizards) +* automatic route generation (create, write, search, wizards) * automatic machinery based on fields' type: * widget rendering * field value load (from existing instance or from request) @@ -182,6 +182,64 @@ and have more elegant routes like ``/partners``. Take a look at `cms_form_example`. +Wizards +------- + +Just inherit from ``cms.form.wizard`` and describe your steps. Quick example: + +.. code-block:: python + + class FakeWiz(models.AbstractModel): + """A wizard form.""" + + _name = 'fake.wiz' + _inherit = 'cms.form.wizard' + _wiz_name = _name + + def wiz_configure_steps(self): + return { + 1: {'form_model': 'fake.wiz.step1.country'}, + 2: {'form_model': 'fake.wiz.step2.partner'}, + 3: {'form_model': 'fake.wiz.step3.partner'}, + } + + + class FakeWizStep1Country(models.AbstractModel): + + _name = 'fake.wiz.step1.country' + _inherit = 'fake.wiz' + _form_model = 'res.country' + _form_model_fields = ('name', ) + + + class FakeWizStep2Partner(models.AbstractModel): + + _name = 'fake.wiz.step2.partner' + _inherit = 'fake.wiz' + _form_model = 'res.partner' + _form_model_fields = ('name', ) + + + class FakeWizStep3Partner(models.AbstractModel): + + _name = 'fake.wiz.step3.partner' + _inherit = 'fake.wiz' + _form_model = 'res.partner' + _form_model_fields = ('category_id', ) + + + +The form will be automatically available at: ``/cms/wiz/fake.wiz/page/1``. + +As you see each step can use its own form. +In this case on the 1st page the user will deal with countries, +then on the 2nd step with the name of the partner +and on the last step only with partner category. + +The wizard machinery will handle session storage and navigation +between steps automatically. + + Master / slave fields --------------------- diff --git a/cms_form/controllers/main.py b/cms_form/controllers/main.py index 903c07646..d0413e577 100644 --- a/cms_form/controllers/main.py +++ b/cms_form/controllers/main.py @@ -49,7 +49,7 @@ def form_model_key(self, model, **kw): """Return a valid form model.""" return 'cms.form.' + model - def get_form(self, model, model_id=None, page=0, **kw): + def get_form(self, model, model_id=None, **kw): """Retrieve form for given model and initialize it.""" form_model_key = kw.pop('form_model_key', None) if not form_model_key: @@ -65,7 +65,7 @@ def get_form(self, model, model_id=None, page=0, **kw): # So here we mock main_object to the form model recordset main_object = request.env[form_model_key] form = request.env[form_model_key].form_init( - request, main_object=main_object, page=page) + request, main_object=main_object, **kw) else: # TODO: enable form by default? # How? with a flag on ir.model.model? @@ -75,7 +75,7 @@ def get_form(self, model, model_id=None, page=0, **kw): ) return form - def make_response(self, model, model_id=None, page=0, **kw): + def make_response(self, model, model_id=None, **kw): """Prepare and return form response. :param model: an odoo model's name @@ -92,10 +92,10 @@ def make_response(self, model, model_id=None, page=0, **kw): it redirects to it. * otherwise it just renders the form """ - form = self.get_form(model, model_id=model_id, page=page, **kw) + form = self.get_form(model, model_id=model_id, **kw) form.form_check_permission() # pass only specific extra args, to not pollute form render values - form.form_process(extra_args={'page': page}) + form.form_process(extra_args={'page': kw.get('page')}) # search forms do not need these attrs if getattr(form, 'form_success', None) \ and getattr(form, 'form_redirect', None): @@ -125,11 +125,43 @@ def cms_form(self, model, model_id=None, **kw): return self.make_response(model, model_id=model_id, **kw) +class WizardFormControllerMixin(FormControllerMixin): + + template = 'cms_form.wizard_form_wrapper' + + def make_response(self, wiz_model, model_id=None, page=1, **kw): + """Custom response. + + The main difference w/ the base form controller is that + we retrieve the form model via wizard step. + """ + # init wizard 1st + wiz = request.env[wiz_model].form_init(request, page=page, **kw) + step_info = wiz.wiz_get_step_info(page) + # retrieve form model for current step + form_model = step_info['form_model'] + model = request.env[form_model]._form_model + kw['form_model_key'] = form_model + return super().make_response(model, model_id=model_id, page=page, **kw) + + +class CMSWizardFormController(http.Controller, WizardFormControllerMixin): + """CMS wizard controller.""" + + @http.route([ + '/cms/wiz//page/', + ], type='http', auth='user', website=True) + def cms_wiz(self, wiz_model, model_id=None, **kw): + """Handle a wizard route. + """ + return self.make_response(wiz_model, model_id=model_id, **kw) + + class SearchFormControllerMixin(FormControllerMixin): template = 'cms_form.search_form_wrapper' - def form_model_key(self, model): + def form_model_key(self, model, **kw): return 'cms.form.search.' + model diff --git a/cms_form/models/__init__.py b/cms_form/models/__init__.py index 712515fd3..cbe45df7f 100644 --- a/cms_form/models/__init__.py +++ b/cms_form/models/__init__.py @@ -1,5 +1,6 @@ from . import cms_form_mixin from . import cms_form +from . import cms_form_wizard from . import cms_search_form from . import website_mixin from . import widgets diff --git a/cms_form/models/cms_form_wizard.py b/cms_form/models/cms_form_wizard.py new file mode 100644 index 000000000..dfc98fd0e --- /dev/null +++ b/cms_form/models/cms_form_wizard.py @@ -0,0 +1,157 @@ +# Copyright 2017-2018 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo import models +from copy import deepcopy + + +class CMSFormWizard(models.AbstractModel): + """Base class for wizards. + + Every wizard is composed by steps. + Each step can be handled by a different form (see `wiz_configure_steps`). + Each form must inherit from the main wizard class. + See also `tests.fake_models.FakeWiz`. + """ + _name = 'cms.form.wizard' + _inherit = 'cms.form' + _form_mode = 'wizard' + _wiz_name = _name + form_buttons_template = 'cms_form.wizard_form_buttons' + # display wizard progress bar? + wiz_show_progress_bar = True + # fields declared here will be automatically stored + # into wizard storage + # Use `_wiz_step_stored_fields = 'all'` to store them all. + # You can pass a list of fields if you don't want to store them all. + _wiz_step_stored_fields = 'all' + + @property + def _wiz_storage_key(self): + """Main storage key.""" + return self._wiz_name + + @property + def _wiz_storage(self): + return self.request.session + + def wiz_storage_get(self): + if not self._wiz_storage.get(self._wiz_storage_key): + # use `deepcopy` to not reference steps' dict + self._wiz_storage[self._wiz_storage_key] = \ + deepcopy(self.DEFAULT_STORAGE_KEYS) + return self._wiz_storage[self._wiz_storage_key] + + DEFAULT_STORAGE_KEYS = { + 'steps': {}, + 'current': 1, + 'next': None, + 'prev': None, + } + + def form_init(self, request, main_object=None, page=1, wizard=None, **kw): + form = super().form_init(request, main_object=main_object, **kw) + form.wiz_init(page=page, **kw) + return form + + def wiz_init(self, page=1, **kw): + steps = self.wiz_configure_steps() + storage = self.wiz_storage_get() + for k in steps.keys(): + if k not in storage['steps']: + # init missing step + storage['steps'][k] = {} + current = page + storage['current'] = current + _next = None + if (current + 1) in steps: + _next = current + 1 + _prev = None + if (current - 1) in steps: + _prev = current - 1 + storage['next'] = _next + storage['prev'] = _prev + + @property + def wiz_steps(self): + return list(self.wiz_configure_steps().keys()) + + def wiz_configure_steps(self): + """Configure wizard steps. + + Each step can use a different form, for instance: + + return { + 1: { + 'form_model': 'form.a', + 'title': 'Step 1', + 'description': 'Preliminary info', + }, + 2: { + 'form_model': 'form.b', + 'title': 'Step 2', + }, + 3: { + 'form_model': 'form.c', + 'title': 'Step 3', + 'description': 'Foo', + }, + } + """ + return {} + + def wiz_get_step_info(self, step): + step = int(step) + try: + return self.wiz_configure_steps()[step] + except KeyError: + raise ValueError('Step `%d` does not exists.' % step) + + def wiz_current_step(self): + return self.wiz_storage_get().get('current') or 1 + + def wiz_next_step(self): + return self.wiz_storage_get().get('next') + + def wiz_prev_step(self): + return self.wiz_storage_get().get('prev') + + def form_next_url(self, main_object=None): + direction = self.request.form.get('wiz_submit', 'next') + if direction == 'next': + step = self.wiz_next_step() + else: + step = self.wiz_prev_step() + if not step: + # fallback to page 1 + step = 1 + return self._wiz_url_for_step(step, main_object=main_object) + + def _wiz_url_for_step(self, step, main_object=None): + return '{}/page/{}'.format(self._wiz_base_url(), step) + + def _wiz_base_url(self): + return '/cms/wiz/{}'.format(self._wiz_name) + + def wiz_save_step(self, values, step=None): + step = step or self.wiz_current_step() + storage = self.wiz_storage_get() + if step not in storage['steps']: + storage['steps'][step] = {} + storage['steps'][step].update(values) + + def wiz_load_step(self, step=None): + step = step or self.wiz_current_step() + return self.wiz_storage_get()['steps'].get(step) or {} + + def form_after_create_or_update(self, values, extra_values): + values = values.copy() + values.update(extra_values) + step_values = {} + stored_fields = self._wiz_step_stored_fields + if stored_fields == 'all': + stored_fields = values.keys() + for fname in stored_fields: + if fname in values: + step_values[fname] = values[fname] + self.wiz_save_step(step_values) diff --git a/cms_form/static/src/less/progressbar.less b/cms_form/static/src/less/progressbar.less new file mode 100644 index 000000000..016a8cb29 --- /dev/null +++ b/cms_form/static/src/less/progressbar.less @@ -0,0 +1,91 @@ +@cms-prog-bar-bg: @brand-primary; +@cms-prog-bar-bg-active: darken(@cms-prog-bar-bg, 10%); +@cms-prog-bar-bg-hover: lighten(@cms-prog-bar-bg, 10%); + +// not using `progress-bar` as it's already styled +// for percentage bar by bootstrap +.cms_form_wrapper .status-bar{ + float: right; + overflow: hidden; + list-style:none; + display: inline-block; + margin: 1em 0 !important; + + .icon{ + font-size: 14px; + } + + li{ + float:left; + a, span{ + color:#FFF; + display:block; + background-color: @cms-prog-bar-bg; + text-decoration: none; + position:relative; + height: 40px; + line-height:40px; + padding: 0 20px 0 10px; + text-align: center; + margin-right: 23px; + } + &:first-child{ + a, span{ + padding-left:15px; + &:before{ + border:none; + } + } + } + &:last-child{ + a, span{ + padding-right:15px; + &:after{ + border:none; + } + } + } + + a, span{ + &:before, + &:after{ + content: ""; + position:absolute; + top: 0; + border:0 solid @cms-prog-bar-bg; + border-width:20px 10px; + width: 0; + height: 0; + } + &:before{ + left:-20px; + border-left-color:transparent; + } + &:after{ + left:100%; + border-color:transparent; + border-left-color: @cms-prog-bar-bg; + } + &:hover{ + background-color: @cms-prog-bar-bg-hover; + &:before{ + border-color: @cms-prog-bar-bg-hover; + border-left-color:transparent; + } + &:after{ + border-left-color: @cms-prog-bar-bg-hover; + } + } + &.active{ + background-color: @cms-prog-bar-bg-active; + &:before{ + border-color:@cms-prog-bar-bg-active; + border-left-color:transparent; + } + &:after{ + border-left-color:@cms-prog-bar-bg-active; + } + } + } + } +} diff --git a/cms_form/templates/assets.xml b/cms_form/templates/assets.xml index 2cd1b254e..bd11f1ac8 100644 --- a/cms_form/templates/assets.xml +++ b/cms_form/templates/assets.xml @@ -7,8 +7,12 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + + + + + + + + diff --git a/cms_form/tests/__init__.py b/cms_form/tests/__init__.py index beacc5fd3..e8db63852 100644 --- a/cms_form/tests/__init__.py +++ b/cms_form/tests/__init__.py @@ -3,4 +3,5 @@ from . import test_form_cms from . import test_form_render from . import test_form_search +from . import test_form_wizard from . import test_loaders diff --git a/cms_form/tests/common.py b/cms_form/tests/common.py index 568aeec8f..7863ff0e8 100644 --- a/cms_form/tests/common.py +++ b/cms_form/tests/common.py @@ -5,7 +5,7 @@ from lxml import html from odoo.tests.common import SavepointCase, HttpCase from .utils import ( - fake_request, fake_session, + fake_request, fake_session, session_store, setup_test_model, teardown_test_model ) @@ -15,7 +15,8 @@ class FormTestMixin(object): at_install = False post_install = True - def get_form(self, form_model, req=None, ctx=None, sudo_uid=None, **kw): + def get_form(self, form_model, req=None, session=None, + ctx=None, sudo_uid=None, **kw): """Retrieve and initialize a form. :param form_model: model dotted name @@ -70,6 +71,19 @@ class FormTestCase(SavepointCase, FormTestMixin): """Base class for transaction cases.""" +class FormSessionTestCase(SavepointCase, FormTestMixin): + """Base class for transaction cases.""" + + def setUp(self): + super().setUp() + self.session = fake_session(self.env) + + def tearDown(self): + # self.session.clear() + session_store.delete(self.session) + super().tearDown() + + class FormRenderTestCase(SavepointCase, FormRenderMixin): """Base class for http cases.""" diff --git a/cms_form/tests/fake_models.py b/cms_form/tests/fake_models.py index 7b0cd60de..eb1297f69 100644 --- a/cms_form/tests/fake_models.py +++ b/cms_form/tests/fake_models.py @@ -15,8 +15,7 @@ class FakePartnerForm(models.AbstractModel): custom = fields.Char() - def _form_load_custom( - self, main_object, fname, value, **req_values): + def _form_load_custom(self, fname, field, value, **req_values): return req_values.get('custom', 'oh yeah!') @@ -90,3 +89,58 @@ def _form_validate_a_float(self, value, **request_values): def _form_validate_char(self, value, **request_values): """Specific validator for all `char` fields.""" return not len(value) > 8, 'Text length must be greater than 8!' + + +FAKE_STORAGE = {} + + +class FakeWiz(models.AbstractModel): + """A wizard form.""" + + _name = 'fake.wiz' + _inherit = 'cms.form.wizard' + _wiz_name = _name + + @property + def _wiz_storage(self): + return FAKE_STORAGE + + def wiz_configure_steps(self): + return { + 1: {'form_model': 'fake.wiz.step1.country'}, + 2: {'form_model': 'fake.wiz.step2.partner'}, + 3: {'form_model': 'fake.wiz.step3.partner'}, + } + + +class FakeWizStep1Country(models.AbstractModel): + + _name = 'fake.wiz.step1.country' + _inherit = 'fake.wiz' + _form_model = 'res.country' + _form_model_fields = ('name', ) + + +class FakeWizStep2Partner(models.AbstractModel): + + _name = 'fake.wiz.step2.partner' + _inherit = 'fake.wiz' + _form_model = 'res.partner' + _form_model_fields = ('name', 'to_be_stored', ) + _wiz_step_stored_fields = ('to_be_stored', ) + + to_be_stored = fields.Char() + + +class FakeWizStep3Partner(models.AbstractModel): + + _name = 'fake.wiz.step3.partner' + _inherit = 'fake.wiz' + _form_model = 'res.partner' + _form_model_fields = ('name', ) + + +WIZ_KLASSES = [ + FakeWiz, FakeWizStep1Country, + FakeWizStep2Partner, FakeWizStep3Partner +] diff --git a/cms_form/tests/test_controllers.py b/cms_form/tests/test_controllers.py index f62fe92c2..be2040c55 100644 --- a/cms_form/tests/test_controllers.py +++ b/cms_form/tests/test_controllers.py @@ -7,7 +7,7 @@ from .common import FormHttpTestCase from ..controllers import main -from .fake_models import FakePartnerForm, FakeSearchPartnerForm +from .fake_models import FakePartnerForm, FakeSearchPartnerForm, WIZ_KLASSES from .utils import fake_request @@ -16,12 +16,15 @@ class TestControllers(FormHttpTestCase): - TEST_MODELS_KLASSES = [FakePartnerForm, FakeSearchPartnerForm] + TEST_MODELS_KLASSES = [ + FakePartnerForm, FakeSearchPartnerForm, + ] + WIZ_KLASSES def setUp(self): super().setUp() self.form_controller = main.CMSFormController() self.form_search_controller = main.CMSSearchFormController() + self.form_wiz_controller = main.CMSWizardFormController() self.authenticate('admin', 'admin') @contextmanager @@ -36,22 +39,25 @@ def mock_assets(self): 'request': request, } - def test_get_form(self): + def test_get_no_form(self): with self.mock_assets(): # we do not have a specific form for res.groups # and cms form is not enabled on partner model with self.assertRaises(NotImplementedError): - form = self.form_controller.get_form('res.groups') + self.form_controller.get_form('res.groups') - # but we have one for res.partner + def test_get_default_form(self): + with self.mock_assets(): + # we have one for res.partner form = self.form_controller.get_form('res.partner') self.assertTrue( isinstance(form, self.env['cms.form.res.partner'].__class__) ) self.assertEqual(form._form_model, 'res.partner') self.assertEqual(form.form_mode, 'create') - self.assertTrue(form) + def test_get_specific_form(self): + with self.mock_assets(): # we have a specific form here form = self.form_search_controller.get_form('res.partner') self.assertTrue( @@ -61,16 +67,25 @@ def test_get_form(self): self.assertEqual(form._form_model, 'res.partner') self.assertEqual(form.form_mode, 'search') - def _check_route(self, url, model, mode): + def test_get_wizard_form(self): + with self.mock_assets(): + # we have a specific form here + form = self.form_wiz_controller.get_form('res.partner') + self.assertTrue( + isinstance(form, + self.env['cms.form.res.partner'].__class__) + ) + self.assertEqual(form._form_model, 'res.partner') + self.assertEqual(form.form_mode, 'create') + + def _check_rendering(self, dom, form_model, model, mode): """Check default markup for form and form wrapper.""" - # with self.mock_assets(): - dom = self.html_get(url) # test wrapper klass wrapper_node = dom.find_class('cms_form_wrapper')[0] expected_attrs = { 'class': 'cms_form_wrapper {form_model} {model} mode_{mode}'.format( - form_model='cms_form_res_partner', + form_model=form_model.replace('.', '_'), model=model.replace('.', '_'), mode=mode ) @@ -85,13 +100,28 @@ def _check_route(self, url, model, mode): } self.assert_match_attrs(form_node.attrib, expected_attrs) - def test_default_routes(self): - self._check_route( - '/cms/form/create/res.partner', - 'res.partner', - 'create') + def test_default_create_rendering(self): + dom = self.html_get('/cms/form/create/res.partner') + self._check_rendering( + dom, 'cms.form.res.partner', 'res.partner', 'create') + + def test_default_edit_rendering(self): partner = self.env.ref('base.res_partner_1') - self._check_route( - '/cms/form/edit/res.partner/{}'.format(partner.id), - 'res.partner', - 'edit') + dom = self.html_get('/cms/form/edit/res.partner/{}'.format(partner.id)) + self._check_rendering( + dom, 'cms.form.res.partner', 'res.partner', 'edit') + + def _check_wiz_rendering(self, dom, form_model, model, mode): + self._check_rendering(dom, form_model, model, mode) + # TODO: check more (paging etc) + + def test_default_wiz_rendering(self): + dom = self.html_get('/cms/wiz/fake.wiz/page/1') + self._check_wiz_rendering( + dom, 'fake.wiz.step1.country', 'res.country', 'wizard') + dom = self.html_get('/cms/wiz/fake.wiz/page/2') + self._check_wiz_rendering( + dom, 'fake.wiz.step2.partner', 'res.partner', 'wizard') + dom = self.html_get('/cms/wiz/fake.wiz/page/3') + self._check_wiz_rendering( + dom, 'fake.wiz.step3.partner', 'res.partner', 'wizard') diff --git a/cms_form/tests/test_form_wizard.py b/cms_form/tests/test_form_wizard.py new file mode 100644 index 000000000..94d517d0c --- /dev/null +++ b/cms_form/tests/test_form_wizard.py @@ -0,0 +1,108 @@ +# Copyright 2017-2018 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from .common import FormSessionTestCase +from .utils import fake_request +from .fake_models import ( + FakeWiz, FakeWizStep1Country, + FakeWizStep2Partner, FakeWizStep3Partner, +) + + +class TestCMSFormWizard(FormSessionTestCase): + + TEST_MODELS_KLASSES = ( + FakeWiz, FakeWizStep1Country, + FakeWizStep2Partner, FakeWizStep3Partner, + ) + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_models() + + @classmethod + def tearDownClass(cls): + cls._teardown_models() + super().tearDownClass() + + def test_wiz_init(self): + form = self.get_form(FakeWizStep1Country._name) + self.assertEqual(form.wiz_storage_get()['current'], 1) + self.assertEqual(form.wiz_storage_get()['next'], 2) + self.assertEqual(form.wiz_storage_get()['prev'], None) + self.assertEqual(len(form.wiz_storage_get()['steps']), 3) + + def test_wiz_next_prev1(self): + form = self.get_form(FakeWizStep1Country._name) + self.assertEqual(form.wiz_prev_step(), None) + self.assertEqual(form.wiz_current_step(), 1) + self.assertEqual(form.wiz_next_step(), 2) + + def test_wiz_next_prev2(self): + form = self.get_form(FakeWizStep2Partner._name, page=2) + self.assertEqual(form.wiz_prev_step(), 1) + self.assertEqual(form.wiz_current_step(), 2) + self.assertEqual(form.wiz_next_step(), 3) + + def test_wiz_next_prev3(self): + form = self.get_form(FakeWizStep3Partner._name, page=3) + self.assertEqual(form.wiz_prev_step(), 2) + self.assertEqual(form.wiz_current_step(), 3) + self.assertEqual(form.wiz_next_step(), None) + + def test_wiz_next_prev_url1(self): + form = self.get_form(FakeWizStep1Country._name) + self.assertEqual( + form.form_next_url(), '/cms/wiz/fake.wiz/page/2') + # simulate click on prev button + req = fake_request(form_data={'wiz_submit': 'prev'}, method='POST') + form = self.get_form(FakeWizStep1Country._name, req=req) + # when step is none we default to initial one + self.assertEqual(form.form_next_url(), '/cms/wiz/fake.wiz/page/1') + + def test_wiz_next_prev_url2(self): + form = self.get_form(FakeWizStep2Partner._name, page=2) + self.assertEqual( + form.form_next_url(), '/cms/wiz/fake.wiz/page/3') + req = fake_request(form_data={'wiz_submit': 'prev'}, method='POST') + form = self.get_form(FakeWizStep2Partner._name, page=2, req=req) + self.assertEqual( + form.form_next_url(), '/cms/wiz/fake.wiz/page/1') + + def test_wiz_next_prev_url3(self): + form = self.get_form(FakeWizStep3Partner._name, page=3) + # 3 it's the last step so next url defaults to initial one + self.assertEqual(form.form_next_url(), '/cms/wiz/fake.wiz/page/1') + req = fake_request(form_data={'wiz_submit': 'prev'}, method='POST') + form = self.get_form(FakeWizStep2Partner._name, page=3, req=req) + self.assertEqual( + form.form_next_url(), '/cms/wiz/fake.wiz/page/2') + + def test_wiz_stored_fields(self): + data = { + 'name': 'John Doe', + 'to_be_stored': 'Whatever', + } + req = fake_request(form_data=data, method='POST') + form = self.get_form(FakeWizStep2Partner._name, req=req) + main_object = form.form_create_or_update() + self.assertEqual(main_object.name, 'John Doe') + step_values = form.wiz_load_step() + self.assertDictEqual(step_values, {'to_be_stored': 'Whatever'}) + + def test_wiz_stored_fields_all(self): + data = { + 'name': 'John Doe', + 'to_be_stored': 'Whatever', + } + req = fake_request(form_data=data, method='POST') + form = self.get_form(FakeWizStep2Partner._name, req=req) + form._wiz_step_stored_fields = 'all' + main_object = form.form_create_or_update() + self.assertEqual(main_object.name, 'John Doe') + step_values = form.wiz_load_step() + self.assertDictEqual(step_values, { + 'name': 'John Doe', + 'to_be_stored': 'Whatever', + }) diff --git a/cms_form/tests/utils.py b/cms_form/tests/utils.py index 8b21e54e6..67c28fba3 100644 --- a/cms_form/tests/utils.py +++ b/cms_form/tests/utils.py @@ -4,6 +4,7 @@ from io import StringIO from werkzeug.wrappers import Request +from werkzeug.contrib.sessions import SessionStore import mock import urllib.parse @@ -33,10 +34,20 @@ def fake_request(form_data=None, query_string=None, return o_req +class FakeSessionStore(SessionStore): + + def delete(self, session): + session.clear() + del session + + +session_store = FakeSessionStore(session_class=http.OpenERPSession) + + def fake_session(env, **kw): db = get_db_name() env = api.Environment(env.cr, env.uid, {}) - session = http.root.session_store.new() + session = session_store.new() session.db = db session.uid = env.uid session.login = 'admin' @@ -47,7 +58,7 @@ def fake_session(env, **kw): for k, v in kw.items(): if hasattr(session, k): setattr(session, k, v) - session.__fake_session = True + session.__testing__ = True return session From 8ff28a122c79bc15c24e4a05e27f260bb5e5ccc8 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 17 Apr 2018 19:57:18 +0200 Subject: [PATCH 050/238] Bump `cms_form` 11.0.1.3.0 --- cms_form/CHANGES.rst | 9 +++++++++ cms_form/__manifest__.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/cms_form/CHANGES.rst b/cms_form/CHANGES.rst index 6382decb7..def4c3892 100644 --- a/cms_form/CHANGES.rst +++ b/cms_form/CHANGES.rst @@ -3,6 +3,15 @@ CHANGELOG ========= +11.0.1.3.0 (2018-04-17) +======================= + +Improve +------- + +* Add wizard support to easily create custom wizards + + 11.0.1.2.1 (2018-04-13) ======================= diff --git a/cms_form/__manifest__.py b/cms_form/__manifest__.py index 6cab6e025..fb5cbdb17 100644 --- a/cms_form/__manifest__.py +++ b/cms_form/__manifest__.py @@ -5,7 +5,7 @@ 'name': 'CMS Form', 'summary': """ Basic content type form""", - 'version': '11.0.1.2.1', + 'version': '11.0.1.3.0', 'license': 'LGPL-3', 'author': 'Camptocamp, Odoo Community Association (OCA)', 'depends': [ From bd4d050323f424cd6f7a35be8f36125c20e94498 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 19 Apr 2018 09:37:14 +0200 Subject: [PATCH 051/238] Wizard: ease customization of stored values --- cms_form/models/cms_form_wizard.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cms_form/models/cms_form_wizard.py b/cms_form/models/cms_form_wizard.py index dfc98fd0e..3571cdba1 100644 --- a/cms_form/models/cms_form_wizard.py +++ b/cms_form/models/cms_form_wizard.py @@ -145,6 +145,10 @@ def wiz_load_step(self, step=None): return self.wiz_storage_get()['steps'].get(step) or {} def form_after_create_or_update(self, values, extra_values): + step_values = self._prepare_step_values_to_store(values, extra_values) + self.wiz_save_step(step_values) + + def _prepare_step_values_to_store(self, values, extra_values): values = values.copy() values.update(extra_values) step_values = {} @@ -154,4 +158,4 @@ def form_after_create_or_update(self, values, extra_values): for fname in stored_fields: if fname in values: step_values[fname] = values[fname] - self.wiz_save_step(step_values) + return step_values From d7a55fa1cb46620d7bd0bd2dd3dd91bc4282a8e3 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Sun, 22 Apr 2018 10:18:48 +0200 Subject: [PATCH 052/238] Bump cms_form 11.0.1.3.1 --- cms_form/CHANGES.rst | 11 +++++++++++ cms_form/__manifest__.py | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/cms_form/CHANGES.rst b/cms_form/CHANGES.rst index def4c3892..1911ffd5d 100644 --- a/cms_form/CHANGES.rst +++ b/cms_form/CHANGES.rst @@ -3,6 +3,17 @@ CHANGELOG ========= +11.0.1.3.1 (2018-04-22) +======================= + +Improve +------- + +* Wizard: ease customization of stored values + + To customize stored values you can override `_prepare_step_values_to_store` + + 11.0.1.3.0 (2018-04-17) ======================= diff --git a/cms_form/__manifest__.py b/cms_form/__manifest__.py index fb5cbdb17..6db32e9c0 100644 --- a/cms_form/__manifest__.py +++ b/cms_form/__manifest__.py @@ -5,7 +5,7 @@ 'name': 'CMS Form', 'summary': """ Basic content type form""", - 'version': '11.0.1.3.0', + 'version': '11.0.1.3.1', 'license': 'LGPL-3', 'author': 'Camptocamp, Odoo Community Association (OCA)', 'depends': [ From e9ff2665479d6fb1bdab016330f641a718ba68ea Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Sun, 22 Apr 2018 13:39:29 +0200 Subject: [PATCH 053/238] Fix `fake_session` helper in form tests common --- cms_form/tests/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cms_form/tests/utils.py b/cms_form/tests/utils.py index 67c28fba3..c72d339f2 100644 --- a/cms_form/tests/utils.py +++ b/cms_form/tests/utils.py @@ -50,9 +50,9 @@ def fake_session(env, **kw): session = session_store.new() session.db = db session.uid = env.uid - session.login = 'admin' + session.login = env.user.login session.password = '' - session.context = env['res.users'].context_get() or {} + session.context = dict(env.context) session.context['uid'] = env.uid session._fix_lang(session.context) for k, v in kw.items(): From 1b81bdbdec8a2289f690e0d9c7369c7df73de82e Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Sun, 4 Mar 2018 12:07:58 +0100 Subject: [PATCH 054/238] Add `cms_info` --- cms_form/README.rst | 10 +++------- cms_form/__manifest__.py | 1 + cms_form/controllers/main.py | 8 ++++---- cms_form/models/__init__.py | 1 - cms_form/models/website_mixin.py | 28 ---------------------------- cms_form/tests/test_controllers.py | 11 +++++++++++ 6 files changed, 19 insertions(+), 40 deletions(-) delete mode 100644 cms_form/models/website_mixin.py diff --git a/cms_form/README.rst b/cms_form/README.rst index 76f22e21f..b3a5d2982 100644 --- a/cms_form/README.rst +++ b/cms_form/README.rst @@ -25,10 +25,6 @@ Features * 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 ===== @@ -62,8 +58,8 @@ Here's the result: 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) +* ``/cms/create/res.partner`` to create new partners +* ``/cms/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, @@ -174,7 +170,7 @@ Just inherit from ``cms.form.search`` to add a form for your model. Quick exampl |preview_search| -The form will be automatically available at: ``/cms/form/search/res.partner``. +The form will be automatically available at: ``/cms/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, diff --git a/cms_form/__manifest__.py b/cms_form/__manifest__.py index 6db32e9c0..71f098ab7 100644 --- a/cms_form/__manifest__.py +++ b/cms_form/__manifest__.py @@ -10,6 +10,7 @@ 'author': 'Camptocamp, Odoo Community Association (OCA)', 'depends': [ 'website', + 'cms_info', 'cms_status_message', ], 'data': [ diff --git a/cms_form/controllers/main.py b/cms_form/controllers/main.py index d0413e577..aab169a10 100644 --- a/cms_form/controllers/main.py +++ b/cms_form/controllers/main.py @@ -116,8 +116,8 @@ class CMSFormController(http.Controller, FormControllerMixin): """CMS form controller.""" @http.route([ - '/cms/form/create/', - '/cms/form/edit//', + '/cms/create/', + '/cms/edit//', ], type='http', auth='user', website=True) def cms_form(self, model, model_id=None, **kw): """Handle a `form` route. @@ -169,8 +169,8 @@ class CMSSearchFormController(http.Controller, SearchFormControllerMixin): """CMS form controller.""" @http.route([ - '/cms/form/search/', - '/cms/form/search//page/', + '/cms/search/', + '/cms/search//page/', ], type='http', auth='public', website=True) def cms_form(self, model, **kw): """Handle a search `form` route. diff --git a/cms_form/models/__init__.py b/cms_form/models/__init__.py index cbe45df7f..86c3e5ab7 100644 --- a/cms_form/models/__init__.py +++ b/cms_form/models/__init__.py @@ -2,5 +2,4 @@ from . import cms_form from . import cms_form_wizard from . import cms_search_form -from . import website_mixin from . import widgets diff --git a/cms_form/models/website_mixin.py b/cms_form/models/website_mixin.py deleted file mode 100644 index 1dc34b50f..000000000 --- a/cms_form/models/website_mixin.py +++ /dev/null @@ -1,28 +0,0 @@ -# Copyright 2017-2018 Simone Orsi -# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). - -from odoo 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/tests/test_controllers.py b/cms_form/tests/test_controllers.py index be2040c55..3aafb24ee 100644 --- a/cms_form/tests/test_controllers.py +++ b/cms_form/tests/test_controllers.py @@ -100,6 +100,17 @@ def _check_rendering(self, dom, form_model, model, mode): } self.assert_match_attrs(form_node.attrib, expected_attrs) + def test_default_routes(self): + self._check_route( + '/cms/create/res.partner', + 'res.partner', + 'create') + partner = self.env.ref('base.res_partner_1') + self._check_route( + '/cms/edit/res.partner/{}'.format(partner.id), + 'res.partner', + 'edit') + def test_default_create_rendering(self): dom = self.html_get('/cms/form/create/res.partner') self._check_rendering( From 705ba405cddb6030bfdf5f820073125665ba72a0 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Sun, 22 Apr 2018 13:38:58 +0200 Subject: [PATCH 055/238] Form mixin: use permission checks from `cms_info` --- cms_form/models/cms_form_mixin.py | 35 ++++++++++-- cms_form/tests/__init__.py | 1 + cms_form/tests/fake_models.py | 24 +++++++++ cms_form/tests/test_controllers.py | 21 ++++---- cms_form/tests/test_form_permission.py | 74 ++++++++++++++++++++++++++ 5 files changed, 139 insertions(+), 16 deletions(-) create mode 100644 cms_form/tests/test_form_permission.py diff --git a/cms_form/models/cms_form_mixin.py b/cms_form/models/cms_form_mixin.py index 0051ee1c7..6da51fe06 100644 --- a/cms_form/models/cms_form_mixin.py +++ b/cms_form/models/cms_form_mixin.py @@ -5,7 +5,7 @@ import json from collections import OrderedDict -from odoo import models, tools, exceptions +from odoo import models, tools, exceptions, _ from ..utils import data_merge @@ -137,13 +137,38 @@ def form_init(self, request, main_object=None, **kw): setattr(form, '_form_' + k, v) return form - def form_check_permission(self): + def form_check_permission(self, raise_exception=True): """Check permission on current model and main object if any.""" + res = True + msg = '' if self.main_object: - self._can_edit() + if hasattr(self.main_object, 'cms_can_edit'): + res = self.main_object.cms_can_edit() + else: + # not `website.published.mixin` model + # TODO: probably is better to move such methods + # defined in `cms_info` to `base` model instead. + # You might want to use a form on an a non-website model. + # This should be considered if we move away from `website` + # as a base and rely only on `portal` features. + res = self._can_edit(raise_exception=False) + msg = _( + 'You cannot edit this record. Model: %s, ID: %s.' + ) % (self.main_object._name, self.main_object.id) else: - self._can_create() - return True + if self._form_model: + if hasattr(self.form_model, 'cms_can_create'): + res = self.form_model.cms_can_create() + else: + # not `website.published.mixin` model + res = self._can_create(raise_exception=False) + msg = _( + 'You are not allowed to create any record ' + 'for the model `%s`.' + ) % self._form_model + if raise_exception and not res: + raise exceptions.AccessError(msg) + return res def _can_create(self, raise_exception=True): """Check that current user can create instances of given model.""" diff --git a/cms_form/tests/__init__.py b/cms_form/tests/__init__.py index e8db63852..8fc0b2427 100644 --- a/cms_form/tests/__init__.py +++ b/cms_form/tests/__init__.py @@ -1,6 +1,7 @@ from . import test_controllers from . import test_form_base from . import test_form_cms +from . import test_form_permission from . import test_form_render from . import test_form_search from . import test_form_wizard diff --git a/cms_form/tests/fake_models.py b/cms_form/tests/fake_models.py index eb1297f69..90aca4b1f 100644 --- a/cms_form/tests/fake_models.py +++ b/cms_form/tests/fake_models.py @@ -13,6 +13,10 @@ class FakePartnerForm(models.AbstractModel): _form_model_fields = ('name', 'country_id') _form_required_fields = ('name', 'country_id') + def form_check_permission(self, raise_exception=True): + # no need for this + pass + custom = fields.Char() def _form_load_custom(self, fname, field, value, **req_values): @@ -101,6 +105,10 @@ class FakeWiz(models.AbstractModel): _inherit = 'cms.form.wizard' _wiz_name = _name + def form_check_permission(self, raise_exception=True): + # no need for this + pass + @property def _wiz_storage(self): return FAKE_STORAGE @@ -144,3 +152,19 @@ class FakeWizStep3Partner(models.AbstractModel): FakeWiz, FakeWizStep1Country, FakeWizStep2Partner, FakeWizStep3Partner ] + + +# `AbstractModel` or `TransientModel` needed to make ACL check happy` +class FakePublishModel(models.TransientModel): + _name = 'fake.publishable' + _inherit = [ + 'website.published.mixin', + ] + name = fields.Char() + + +class FakePublishModelForm(models.AbstractModel): + _name = 'cms.form.fake.publishable' + _inherit = 'cms.form' + _form_model = 'fake.publishable' + _form_model_fields = ('name', ) diff --git a/cms_form/tests/test_controllers.py b/cms_form/tests/test_controllers.py index 3aafb24ee..400f7ca8e 100644 --- a/cms_form/tests/test_controllers.py +++ b/cms_form/tests/test_controllers.py @@ -100,25 +100,24 @@ def _check_rendering(self, dom, form_model, model, mode): } self.assert_match_attrs(form_node.attrib, expected_attrs) + def _check_route(self, url): + resp = self.url_open(url, timeout=30) + self.assertTrue(resp.ok) + self.assertEqual(resp.status_code, 200) + def test_default_routes(self): - self._check_route( - '/cms/create/res.partner', - 'res.partner', - 'create') - partner = self.env.ref('base.res_partner_1') - self._check_route( - '/cms/edit/res.partner/{}'.format(partner.id), - 'res.partner', - 'edit') + self._check_route('/cms/create/res.partner') + self._check_route('/cms/edit/res.partner/1') + self._check_route('/cms/search/res.partner') def test_default_create_rendering(self): - dom = self.html_get('/cms/form/create/res.partner') + dom = self.html_get('/cms/create/res.partner') self._check_rendering( dom, 'cms.form.res.partner', 'res.partner', 'create') def test_default_edit_rendering(self): partner = self.env.ref('base.res_partner_1') - dom = self.html_get('/cms/form/edit/res.partner/{}'.format(partner.id)) + dom = self.html_get('/cms/edit/res.partner/{}'.format(partner.id)) self._check_rendering( dom, 'cms.form.res.partner', 'res.partner', 'edit') diff --git a/cms_form/tests/test_form_permission.py b/cms_form/tests/test_form_permission.py new file mode 100644 index 000000000..b2b026673 --- /dev/null +++ b/cms_form/tests/test_form_permission.py @@ -0,0 +1,74 @@ +# Copyright 2017-2018 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo import exceptions +import mock + +from .common import FormTestCase +from .fake_models import FakePublishModel, FakePublishModelForm + + +class TestFormPermCheck(FormTestCase): + + TEST_MODELS_KLASSES = [ + FakePublishModel, + FakePublishModelForm, + ] + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_models() + cls.record = cls.env[FakePublishModel._name].create({'name': 'Foo'}) + + @classmethod + def tearDownClass(cls): + cls.record.unlink() + cls._teardown_models() + super().tearDownClass() + + mixin_path = 'odoo.addons.cms_info.models.website_mixin.WebsiteMixin' + + def test_form_check_permission_can_create(self): + form = self.get_form( + FakePublishModelForm._name, main_object=None) + with mock.patch(self.mixin_path + '.cms_can_create') as patched: + patched.return_value = True + self.assertTrue(form.form_check_permission()) + patched.assert_called() + + def test_form_check_permission_cannot_create(self): + form = self.get_form( + FakePublishModelForm._name, main_object=None) + with mock.patch(self.mixin_path + '.cms_can_create') as patched: + patched.return_value = False + try: + form.form_check_permission() + except exceptions.AccessError as err: + patched.assert_called() + msg = ('You are not allowed to create any record ' + 'for the model `%s`.') % FakePublishModel._name + self.assertEqual(err.name, msg) + + def test_form_check_permission_can_edit(self): + form = self.get_form( + FakePublishModelForm._name, + main_object=self.record) + with mock.patch(self.mixin_path + '.cms_can_edit') as patched: + patched.return_value = True + self.assertTrue(form.form_check_permission()) + patched.assert_called() + + def test_form_check_permission_cannot_edit(self): + form = self.get_form( + FakePublishModelForm._name, main_object=self.record) + with mock.patch(self.mixin_path + '.cms_can_edit') as patched: + patched.return_value = False + try: + form.form_check_permission() + except exceptions.AccessError as err: + patched.assert_called() + msg = ( + 'You cannot edit this record. Model: %s, ID: %d.' + ) % (self.record._name, self.record.id) + self.assertEqual(err.name, msg) From 27a05ca594d4904b96f83ab600797fa05dcaa67c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 23 Apr 2018 17:01:47 +0200 Subject: [PATCH 056/238] Search form: pass `pager` as render value This change is to facilitate templates that need a pager to generate page metadata (like links prev/next). A good use case is the SEO friendly `website_canonical_url`. --- cms_form/controllers/main.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/cms_form/controllers/main.py b/cms_form/controllers/main.py index aab169a10..d13daf481 100644 --- a/cms_form/controllers/main.py +++ b/cms_form/controllers/main.py @@ -28,17 +28,19 @@ def get_template(self, form, **kw): raise NotImplementedError("You must provide a template!") return template - def get_render_values(self, main_object, **kw): + def get_render_values(self, form, **kw): """Retrieve rendering values. You can override this to inject more values. """ + main_object = form.main_object parent = None if getattr(main_object, 'parent_id', None): # get the parent if any parent = main_object.parent_id kw.update({ + 'form': form, 'main_object': main_object, 'parent': parent, 'controller': self, @@ -103,8 +105,7 @@ def make_response(self, model, model_id=None, **kw): # https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303 return werkzeug.utils.redirect(form.form_next_url(), code=303) # render form wrapper - values = self.get_render_values(form.main_object, **kw) - values['form'] = form + values = self.get_render_values(form, **kw) return request.render( self.get_template(form, **kw), values, @@ -164,6 +165,13 @@ class SearchFormControllerMixin(FormControllerMixin): def form_model_key(self, model, **kw): return 'cms.form.search.' + model + def get_render_values(self, form, **kw): + values = super().get_render_values(form, **kw) + values.update({ + 'pager': form.form_search_results['pager'], + }) + return values + class CMSSearchFormController(http.Controller, SearchFormControllerMixin): """CMS form controller.""" From 35065bb1dea53a99463c10dc5d69a1663561c317 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 27 Apr 2018 13:21:42 +0200 Subject: [PATCH 057/238] cms_form: add request marshallers and tests --- cms_form/marshallers.py | 88 ++++++++++++++++++++++++++++++ cms_form/models/cms_form_mixin.py | 18 ++---- cms_form/tests/__init__.py | 1 + cms_form/tests/test_marshallers.py | 59 ++++++++++++++++++++ 4 files changed, 152 insertions(+), 14 deletions(-) create mode 100644 cms_form/marshallers.py create mode 100644 cms_form/tests/test_marshallers.py diff --git a/cms_form/marshallers.py b/cms_form/marshallers.py new file mode 100644 index 000000000..56d8e04f2 --- /dev/null +++ b/cms_form/marshallers.py @@ -0,0 +1,88 @@ +# Copyright 2018 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + + +def marshal_request_values(values): + """Transform given request values using marshallers. + + Available marshallers: + + * `:int` transform to integer + * `:list` transform to list of values + * `:dict` transform to dictionary of values + """ + # TODO: add docs + # TODO: support combinations like `:list:int` or `:dict:int` + res = {} + for k, v in values.items(): + if k in ('csrf_token', ): + continue + # fields w/ multiple values + if k.endswith(':list'): + k, v = marshal_list(values, k, v) + res[k] = v + continue + if k.endswith(':dict'): + k, v = marshal_dict(values, k, v) + res[k] = v + continue + if k.endswith(':int'): + k, v = marshal_int(values, k, v) + res[k] = v + continue + res[k] = v + return res + + +def marshal_list(values, orig_key, orig_value): + """Transform `foo:list` inputs to list of values.""" + k = orig_key[:-len(':list')] + v = values.getlist(orig_key) + return k, v + + +def marshal_int(values, orig_key, orig_value): + """Transform `foo:int` inputs to integer values.""" + k = orig_key[:-len(':int')] + v = int(orig_value) if orig_value and orig_value.isdigit() else orig_value + return k, v + + +def marshal_dict(values, orig_key, orig_value): + """Transform `foo:dict` inputs to dictionary values. + + `orig_key` must be formatted like: + + `$fname.$dict_key:dict` + + Every request key matching `$fname` prefix + will be merge into a dict wheres keys will match all `$dict_key`. + + Example: + + values = [ + ('foo.a:dict', '1'), + ('foo.b:dict', '2'), + ('foo.c:dict', '3'), + ] + + will be translated to: + + values['foo'] = { + 'a': '1', + 'b': '2', + 'c': '3', + } + + """ + res = {} + key = orig_key.split('.')[0] + for _k, _v in values.items(): + # get all the keys matching fname + if not _k.startswith(key): + continue + # TODO: `__` will be to support extra marshallers, like: + # foo.1:record:int -> get a dictionary w/ integer values + full_key, _, __ = _k.partition(':dict') + res[full_key.split('.')[-1]] = _v + return key, res diff --git a/cms_form/models/cms_form_mixin.py b/cms_form/models/cms_form_mixin.py index 6da51fe06..56b6872a6 100644 --- a/cms_form/models/cms_form_mixin.py +++ b/cms_form/models/cms_form_mixin.py @@ -7,7 +7,8 @@ from odoo import models, tools, exceptions, _ -from ..utils import data_merge +from .. import utils +from .. import marshallers IGNORED_FORM_FIELDS = [ @@ -350,18 +351,7 @@ def form_get_request_values(self): # and this will make the form machinery miss all the fields _values = self.request.args # normal fields - res = {} - for k, v in _values.items(): - if k in ('csrf_token', ): - continue - if k.endswith(':list'): - # fields w/ multiple values - # TODO - # 1. add test and docs - # 2. support more "transformers" (`:int` for instance) - v = _values.getlist(k) - k = k[:len(':list') + 1] - res[k] = v + res = marshallers.marshal_request_values(_values) # file fields res.update( {k: v for k, v in self.request.files.items()} @@ -596,4 +586,4 @@ def _form_info_merge(self, info, tomerge): so if you don't want to override info completely you can use this method to merge them properly. """ - return data_merge(info, tomerge) + return utils.data_merge(info, tomerge) diff --git a/cms_form/tests/__init__.py b/cms_form/tests/__init__.py index 8fc0b2427..3b1ee9078 100644 --- a/cms_form/tests/__init__.py +++ b/cms_form/tests/__init__.py @@ -6,3 +6,4 @@ from . import test_form_search from . import test_form_wizard from . import test_loaders +from . import test_marshallers diff --git a/cms_form/tests/test_marshallers.py b/cms_form/tests/test_marshallers.py new file mode 100644 index 000000000..829cafc7e --- /dev/null +++ b/cms_form/tests/test_marshallers.py @@ -0,0 +1,59 @@ +# Copyright 2018 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import unittest +from werkzeug.datastructures import MultiDict +from .. import marshallers + + +class TestMarshallers(unittest.TestCase): + + def test_plain_values(self): + data = MultiDict([ + ('a', '1'), + ('b', '2'), + ('c', '3'), + ]) + marshalled = marshallers.marshal_request_values(data) + self.assertEqual(marshalled['a'], '1') + self.assertEqual(marshalled['b'], '2') + self.assertEqual(marshalled['c'], '3') + + def test_mashal_list(self): + data = MultiDict([ + ('a', '1'), + ('b:list', '1'), + ('b:list', '2'), + ('b:list', '3'), + ('c', '3'), + ]) + marshalled = marshallers.marshal_request_values(data) + self.assertEqual(marshalled['a'], '1') + self.assertListEqual(marshalled['b'], ['1', '2', '3']) + self.assertEqual(marshalled['c'], '3') + + def test_mashal_int(self): + data = MultiDict([ + ('a', '1'), + ('b:int', '2'), + ('c:int', '3'), + ('d:int', 'bad'), + ]) + marshalled = marshallers.marshal_request_values(data) + self.assertEqual(marshalled['a'], '1') + self.assertEqual(marshalled['b'], 2) + self.assertEqual(marshalled['c'], 3) + self.assertEqual(marshalled['d'], 'bad') + + def test_mashal_dict(self): + data = MultiDict([ + ('a', '1'), + ('b.x:dict', '1'), + ('b.y:dict', '2'), + ('b.z:dict', '3'), + ('c', '3'), + ]) + marshalled = marshallers.marshal_request_values(data) + self.assertEqual(marshalled['a'], '1') + self.assertDictEqual(marshalled['b'], {'x': '1', 'y': '2', 'z': '3'}) + self.assertEqual(marshalled['c'], '3') From 5f1951d50f7932e4bd7d54f940e829d89fcaca75 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 27 Apr 2018 13:22:05 +0200 Subject: [PATCH 058/238] cms_form: include wizard name in form wrapper klass --- cms_form/models/cms_form_wizard.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cms_form/models/cms_form_wizard.py b/cms_form/models/cms_form_wizard.py index 3571cdba1..c28461ca3 100644 --- a/cms_form/models/cms_form_wizard.py +++ b/cms_form/models/cms_form_wizard.py @@ -26,6 +26,11 @@ class CMSFormWizard(models.AbstractModel): # You can pass a list of fields if you don't want to store them all. _wiz_step_stored_fields = 'all' + @property + def form_wrapper_css_klass(self): + klass = super().form_wrapper_css_klass + return klass + ' ' + self._wiz_name.replace('.', '_').lower() + @property def _wiz_storage_key(self): """Main storage key.""" From 8882091e429621c3029ace974e3ad9370628bba9 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 27 Apr 2018 14:04:54 +0200 Subject: [PATCH 059/238] Fix wizard rendering tests --- cms_form/tests/test_controllers.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/cms_form/tests/test_controllers.py b/cms_form/tests/test_controllers.py index 400f7ca8e..4250c9cc4 100644 --- a/cms_form/tests/test_controllers.py +++ b/cms_form/tests/test_controllers.py @@ -78,7 +78,7 @@ def test_get_wizard_form(self): self.assertEqual(form._form_model, 'res.partner') self.assertEqual(form.form_mode, 'create') - def _check_rendering(self, dom, form_model, model, mode): + def _check_rendering(self, dom, form_model, model, mode, extra_klass=''): """Check default markup for form and form wrapper.""" # test wrapper klass wrapper_node = dom.find_class('cms_form_wrapper')[0] @@ -88,7 +88,7 @@ def _check_rendering(self, dom, form_model, model, mode): form_model=form_model.replace('.', '_'), model=model.replace('.', '_'), mode=mode - ) + ) + (' ' + extra_klass if extra_klass else '') } self.assert_match_attrs(wrapper_node.attrib, expected_attrs) # test form is there and has correct klass @@ -121,17 +121,22 @@ def test_default_edit_rendering(self): self._check_rendering( dom, 'cms.form.res.partner', 'res.partner', 'edit') - def _check_wiz_rendering(self, dom, form_model, model, mode): - self._check_rendering(dom, form_model, model, mode) + def _check_wiz_rendering( + self, dom, form_model, model, mode, extra_klass=''): + self._check_rendering( + dom, form_model, model, mode, extra_klass=extra_klass) # TODO: check more (paging etc) def test_default_wiz_rendering(self): dom = self.html_get('/cms/wiz/fake.wiz/page/1') self._check_wiz_rendering( - dom, 'fake.wiz.step1.country', 'res.country', 'wizard') + dom, 'fake.wiz.step1.country', + 'res.country', 'wizard', extra_klass='fake_wiz') dom = self.html_get('/cms/wiz/fake.wiz/page/2') self._check_wiz_rendering( - dom, 'fake.wiz.step2.partner', 'res.partner', 'wizard') + dom, 'fake.wiz.step2.partner', + 'res.partner', 'wizard', extra_klass='fake_wiz') dom = self.html_get('/cms/wiz/fake.wiz/page/3') self._check_wiz_rendering( - dom, 'fake.wiz.step3.partner', 'res.partner', 'wizard') + dom, 'fake.wiz.step3.partner', + 'res.partner', 'wizard', extra_klass='fake_wiz') From 4009125ced267c4b95be0741d71d49f968f355af Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 27 Apr 2018 16:29:00 +0200 Subject: [PATCH 060/238] Bump cms_form 11.0.1.4.0 --- cms_form/CHANGES.rst | 68 +++++++++++++++++++++++++++------------- cms_form/__manifest__.py | 2 +- 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/cms_form/CHANGES.rst b/cms_form/CHANGES.rst index 1911ffd5d..ed0dd2f83 100644 --- a/cms_form/CHANGES.rst +++ b/cms_form/CHANGES.rst @@ -3,11 +3,35 @@ CHANGELOG ========= +11.0.1.4.0 (2018-04-27) +======================= + +Improvements +------------ + +* Include wizard name in form wrapper klass +* Add request marshallers and tests +* Search form: pass `pager` as render value + + This change is to facilitate templates that need a pager + to generate page metadata (like links prev/next). + + A good use case is the SEO friendly `website_canonical_url`. + +* Rely on `cms_info` for permission and URLs + + +Fixes +----- + +* Fix `fake_session` helper in form tests common + + 11.0.1.3.1 (2018-04-22) ======================= -Improve -------- +Improvements +------------ * Wizard: ease customization of stored values @@ -17,8 +41,8 @@ Improve 11.0.1.3.0 (2018-04-17) ======================= -Improve -------- +Improvements +------------ * Add wizard support to easily create custom wizards @@ -26,8 +50,8 @@ Improve 11.0.1.2.1 (2018-04-13) ======================= -Fix ---- +Fixes +----- * Fix search form regression on permission check @@ -38,8 +62,8 @@ Fix 11.0.1.2.0 (2018-04-09) ======================= -Improve -------- +Improvements +------------ * Add error msg block for validation errors right below field * Support multiple values for same field @@ -80,8 +104,8 @@ Improve Before this change you had to override the controller to gain control on it. -Fix ---- +Fixes +----- * Fix required attr on boolean widget (was not considered) * `_form_create` + `_form_write` use a copy of values to avoid pollution by Odoo @@ -92,8 +116,8 @@ Fix 11.0.1.1.1 (2018-03-26) ======================= -Fix ---- +Fixes +----- * Fix date widget: default today only if empty @@ -101,16 +125,16 @@ Fix 11.0.1.1.0 (2018-03-26) ======================= -Improve -------- +Improvements +------------ * Delegate field wrapper class computation to form * Add vertical fields option * Add multi value widget for search forms * Improve date widget: allow custom default today -Fix ---- +Fixes +----- * Fix fieldset support for search forms * Fix date search w/ empty value @@ -120,8 +144,8 @@ Fix 11.0.1.0.4 (2018-03-23) ======================= -Improve -------- +Improvements +------------ * Ease override of JSON info * Add fieldsets support @@ -131,13 +155,13 @@ Improve 11.0.1.0.3 (2018-03-21) ======================= -Improve -------- +Improvements +------------ * Form controller: main_object defaults to empty recordset -Fix ---- +Fixes +----- * Fix x2m widget value comparison * Fix x2m widget load default value empt^^ diff --git a/cms_form/__manifest__.py b/cms_form/__manifest__.py index 71f098ab7..9b4a2ec2c 100644 --- a/cms_form/__manifest__.py +++ b/cms_form/__manifest__.py @@ -5,7 +5,7 @@ 'name': 'CMS Form', 'summary': """ Basic content type form""", - 'version': '11.0.1.3.1', + 'version': '11.0.1.4.0', 'license': 'LGPL-3', 'author': 'Camptocamp, Odoo Community Association (OCA)', 'depends': [ From b8e7dbd68026c61d45eeab66373de3dea3db2b7e Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Sun, 29 Apr 2018 16:52:27 +0200 Subject: [PATCH 061/238] cms_form: add sphinx doc --- cms_form/README.rst | 250 +----------------- cms_form/doc/Makefile | 20 ++ .../cms_form_example_create_partner.png | Bin .../images/cms_form_example_edit_partner.png | Bin .../images/cms_form_example_fieldsets.png | Bin .../images/cms_form_example_search.png | Bin .../images/cms_form_example_tabbed.png | Bin cms_form/doc/source/basics.rst | 188 +++++++++++++ cms_form/doc/source/conf.py | 167 ++++++++++++ cms_form/doc/source/index.rst | 22 ++ cms_form/doc/source/wizard.rst | 59 +++++ 11 files changed, 457 insertions(+), 249 deletions(-) create mode 100644 cms_form/doc/Makefile rename cms_form/{ => doc/source/_static}/images/cms_form_example_create_partner.png (100%) rename cms_form/{ => doc/source/_static}/images/cms_form_example_edit_partner.png (100%) rename cms_form/{ => doc/source/_static}/images/cms_form_example_fieldsets.png (100%) rename cms_form/{ => doc/source/_static}/images/cms_form_example_search.png (100%) rename cms_form/{ => doc/source/_static}/images/cms_form_example_tabbed.png (100%) create mode 100644 cms_form/doc/source/basics.rst create mode 100644 cms_form/doc/source/conf.py create mode 100644 cms_form/doc/source/index.rst create mode 100644 cms_form/doc/source/wizard.rst diff --git a/cms_form/README.rst b/cms_form/README.rst index b3a5d2982..e10d5a146 100644 --- a/cms_form/README.rst +++ b/cms_form/README.rst @@ -29,249 +29,7 @@ Features 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/create/res.partner`` to create new partners -* ``/cms/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. - - -Form with fieldsets and tabs ----------------------------- - -You want to group fields into meaningful groups. You can use fieldsets: - -.. 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') - _form_fieldsets = [ - { - 'id': 'main', - 'title': 'Main', - 'fields': [ - 'name', - 'email', - ], - }, - { - 'id': 'secondary', - 'title': 'Secondary', - 'fields': [ - 'country_id', - 'notify_partner', - ], - }, - ] - - notify_partner = fields.Boolean() - -|preview_fieldsets| - - -If you want fieldsets to be displayed as tabs, just override this option: - -.. code-block:: python - - class PartnerForm(models.AbstractModel): - - _name = 'cms.form.res.partner' - _inherit = 'cms.form' - _form_fieldsets = [...] - _form_fieldsets_display = 'tabs' - - -|preview_tabbed| - - -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/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`. - - -Wizards -------- - -Just inherit from ``cms.form.wizard`` and describe your steps. Quick example: - -.. code-block:: python - - class FakeWiz(models.AbstractModel): - """A wizard form.""" - - _name = 'fake.wiz' - _inherit = 'cms.form.wizard' - _wiz_name = _name - - def wiz_configure_steps(self): - return { - 1: {'form_model': 'fake.wiz.step1.country'}, - 2: {'form_model': 'fake.wiz.step2.partner'}, - 3: {'form_model': 'fake.wiz.step3.partner'}, - } - - - class FakeWizStep1Country(models.AbstractModel): - - _name = 'fake.wiz.step1.country' - _inherit = 'fake.wiz' - _form_model = 'res.country' - _form_model_fields = ('name', ) - - - class FakeWizStep2Partner(models.AbstractModel): - - _name = 'fake.wiz.step2.partner' - _inherit = 'fake.wiz' - _form_model = 'res.partner' - _form_model_fields = ('name', ) - - - class FakeWizStep3Partner(models.AbstractModel): - - _name = 'fake.wiz.step3.partner' - _inherit = 'fake.wiz' - _form_model = 'res.partner' - _form_model_fields = ('category_id', ) - - - -The form will be automatically available at: ``/cms/wiz/fake.wiz/page/1``. - -As you see each step can use its own form. -In this case on the 1st page the user will deal with countries, -then on the 2nd step with the name of the partner -and on the last step only with partner category. - -The wizard machinery will handle session storage and navigation -between steps automatically. - - -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 +Check full documentation inside `doc` folder. Known issues / Roadmap @@ -324,9 +82,3 @@ 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 -.. |preview_fieldsets| image:: ./images/cms_form_example_fieldsets.png -.. |preview_tabbed| image:: ./images/cms_form_example_tabbed.png diff --git a/cms_form/doc/Makefile b/cms_form/doc/Makefile new file mode 100644 index 000000000..5ee04a0ba --- /dev/null +++ b/cms_form/doc/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SPHINXPROJ = CMSForm +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/cms_form/images/cms_form_example_create_partner.png b/cms_form/doc/source/_static/images/cms_form_example_create_partner.png similarity index 100% rename from cms_form/images/cms_form_example_create_partner.png rename to cms_form/doc/source/_static/images/cms_form_example_create_partner.png diff --git a/cms_form/images/cms_form_example_edit_partner.png b/cms_form/doc/source/_static/images/cms_form_example_edit_partner.png similarity index 100% rename from cms_form/images/cms_form_example_edit_partner.png rename to cms_form/doc/source/_static/images/cms_form_example_edit_partner.png diff --git a/cms_form/images/cms_form_example_fieldsets.png b/cms_form/doc/source/_static/images/cms_form_example_fieldsets.png similarity index 100% rename from cms_form/images/cms_form_example_fieldsets.png rename to cms_form/doc/source/_static/images/cms_form_example_fieldsets.png diff --git a/cms_form/images/cms_form_example_search.png b/cms_form/doc/source/_static/images/cms_form_example_search.png similarity index 100% rename from cms_form/images/cms_form_example_search.png rename to cms_form/doc/source/_static/images/cms_form_example_search.png diff --git a/cms_form/images/cms_form_example_tabbed.png b/cms_form/doc/source/_static/images/cms_form_example_tabbed.png similarity index 100% rename from cms_form/images/cms_form_example_tabbed.png rename to cms_form/doc/source/_static/images/cms_form_example_tabbed.png diff --git a/cms_form/doc/source/basics.rst b/cms_form/doc/source/basics.rst new file mode 100644 index 000000000..cc65fe4bb --- /dev/null +++ b/cms_form/doc/source/basics.rst @@ -0,0 +1,188 @@ +Form basics +=========== + +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: + +.. image:: ./_static/images/cms_form_example_create_partner.png +.. image:: ./_static/images/cms_form_example_edit_partner.png + +The form will be automatically available on these routes: + +* ``/cms/create/res.partner`` to create new partners +* ``/cms/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` module. + +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. + + +Form with fieldsets and tabs +---------------------------- + +You want to group fields into meaningful groups. You can use fieldsets: + +.. 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') + _form_fieldsets = [ + { + 'id': 'main', + 'title': 'Main', + 'fields': [ + 'name', + 'email', + ], + }, + { + 'id': 'secondary', + 'title': 'Secondary', + 'fields': [ + 'country_id', + 'notify_partner', + ], + }, + ] + + notify_partner = fields.Boolean() + +.. image:: ./_static/images/cms_form_example_fieldsets.png + + +If you want fieldsets to be displayed as tabs, just override this option: + +.. code-block:: python + + class PartnerForm(models.AbstractModel): + + _name = 'cms.form.res.partner' + _inherit = 'cms.form' + _form_fieldsets = [...] + _form_fieldsets_display = 'tabs' + + +.. image:: ./_static/images/cms_form_example_tabbed.png + + +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', ) + + +.. image:: ./_static/images/cms_form_example_search.png + +The form will be automatically available at: ``/cms/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`. + + +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 \ No newline at end of file diff --git a/cms_form/doc/source/conf.py b/cms_form/doc/source/conf.py new file mode 100644 index 000000000..8ce257f36 --- /dev/null +++ b/cms_form/doc/source/conf.py @@ -0,0 +1,167 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/master/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- + +project = 'CMS Form' +copyright = '2018, Simone Orsi' +author = 'Simone Orsi' + +# The short X.Y version +version = '' +# The full version, including alpha/beta/rc tags +release = '11.0.1.4.0' + + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.todo', + 'sphinx.ext.coverage', + 'sphinx.ext.githubpages', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path . +exclude_patterns = [] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'alabaster' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + + +# -- Options for HTMLHelp output --------------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = 'CMSFormdoc' + + +# -- Options for LaTeX output ------------------------------------------------ + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'CMSForm.tex', 'CMS Form Documentation', + 'Simone Orsi', 'manual'), +] + + +# -- Options for manual page output ------------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'cmsform', 'CMS Form Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ---------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'CMSForm', 'CMS Form Documentation', + author, 'CMSForm', 'One line description of project.', + 'Miscellaneous'), +] + + +# -- Extension configuration ------------------------------------------------- + +# -- Options for todo extension ---------------------------------------------- + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = True diff --git a/cms_form/doc/source/index.rst b/cms_form/doc/source/index.rst new file mode 100644 index 000000000..e028c4c8c --- /dev/null +++ b/cms_form/doc/source/index.rst @@ -0,0 +1,22 @@ +.. CMS Form documentation master file, created by + sphinx-quickstart on Sun Apr 29 15:56:53 2018. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to CMS Form's documentation! +==================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + basics.rst + wizard.rst + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/cms_form/doc/source/wizard.rst b/cms_form/doc/source/wizard.rst new file mode 100644 index 000000000..321bb8800 --- /dev/null +++ b/cms_form/doc/source/wizard.rst @@ -0,0 +1,59 @@ +Wizards +======= + +Basics +------ + +Just inherit from ``cms.form.wizard`` and describe your steps. Quick example: + +.. code-block:: python + + class FakeWiz(models.AbstractModel): + """A wizard form.""" + + _name = 'fake.wiz' + _inherit = 'cms.form.wizard' + _wiz_name = _name + + def wiz_configure_steps(self): + return { + 1: {'form_model': 'fake.wiz.step1.country'}, + 2: {'form_model': 'fake.wiz.step2.partner'}, + 3: {'form_model': 'fake.wiz.step3.partner'}, + } + + + class FakeWizStep1Country(models.AbstractModel): + + _name = 'fake.wiz.step1.country' + _inherit = 'fake.wiz' + _form_model = 'res.country' + _form_model_fields = ('name', ) + + + class FakeWizStep2Partner(models.AbstractModel): + + _name = 'fake.wiz.step2.partner' + _inherit = 'fake.wiz' + _form_model = 'res.partner' + _form_model_fields = ('name', ) + + + class FakeWizStep3Partner(models.AbstractModel): + + _name = 'fake.wiz.step3.partner' + _inherit = 'fake.wiz' + _form_model = 'res.partner' + _form_model_fields = ('category_id', ) + + + +The form will be automatically available at: ``/cms/wiz/fake.wiz/page/1``. + +As you see each step can use its own form. +In this case on the 1st page the user will deal with countries, +then on the 2nd step with the name of the partner +and on the last step only with partner category. + +The wizard machinery will handle session storage and navigation +between steps automatically. From 349f9dbd98a819cf048420e346db8c31419d5152 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Sun, 29 Apr 2018 20:42:09 +0200 Subject: [PATCH 062/238] Bump cms_form 11.0.1.4.1 --- cms_form/CHANGES.rst | 8 ++++++++ cms_form/__manifest__.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/cms_form/CHANGES.rst b/cms_form/CHANGES.rst index ed0dd2f83..4231e760d 100644 --- a/cms_form/CHANGES.rst +++ b/cms_form/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +11.0.1.4.1 (2018-04-29) +======================= + +Docs +---- + +* Move documentation from README to `doc` folder + 11.0.1.4.0 (2018-04-27) ======================= diff --git a/cms_form/__manifest__.py b/cms_form/__manifest__.py index 9b4a2ec2c..2812ae844 100644 --- a/cms_form/__manifest__.py +++ b/cms_form/__manifest__.py @@ -5,7 +5,7 @@ 'name': 'CMS Form', 'summary': """ Basic content type form""", - 'version': '11.0.1.4.0', + 'version': '11.0.1.4.1', 'license': 'LGPL-3', 'author': 'Camptocamp, Odoo Community Association (OCA)', 'depends': [ From 7605ad9d1f320b37986d13569aec3be574308e67 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Sun, 29 Apr 2018 20:57:00 +0200 Subject: [PATCH 063/238] cms_form: update docs [ci skip] --- cms_form/doc/source/conf.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cms_form/doc/source/conf.py b/cms_form/doc/source/conf.py index 8ce257f36..40cac7016 100644 --- a/cms_form/doc/source/conf.py +++ b/cms_form/doc/source/conf.py @@ -19,14 +19,14 @@ # -- Project information ----------------------------------------------------- -project = 'CMS Form' +project = 'Odoo CMS Form' copyright = '2018, Simone Orsi' author = 'Simone Orsi' # The short X.Y version version = '' # The full version, including alpha/beta/rc tags -release = '11.0.1.4.0' +release = '11.0.1.4.1' # -- General configuration --------------------------------------------------- @@ -132,7 +132,7 @@ # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'CMSForm.tex', 'CMS Form Documentation', + (master_doc, 'CMSForm.tex', 'Odoo CMS Form Documentation', 'Simone Orsi', 'manual'), ] @@ -142,7 +142,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - (master_doc, 'cmsform', 'CMS Form Documentation', + (master_doc, 'cmsform', 'Odoo CMS Form Documentation', [author], 1) ] @@ -153,7 +153,7 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'CMSForm', 'CMS Form Documentation', + (master_doc, 'CMSForm', 'Odoo CMS Form Documentation', author, 'CMSForm', 'One line description of project.', 'Miscellaneous'), ] From 160063577d373d80add33575dfcc8cc770bbbbaf Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 11 May 2018 11:26:03 +0200 Subject: [PATCH 064/238] cms_form: search form support quick domain rules You can now set custom domain rules via `_form_search_domain_rules`. The mapping: `field name: (leaf field name, operator, format value),`. Example: `product_id: (product_id.name, ilike, %{}%),`. In this way the value of `product_id` field in the form will be search on the name with ilike operator and everywhere in the string. --- cms_form/models/cms_search_form.py | 30 ++++++++++++++++++++++++------ cms_form/tests/test_form_search.py | 14 ++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/cms_form/models/cms_search_form.py b/cms_form/models/cms_search_form.py index 6f2ab0879..ed388c90f 100644 --- a/cms_form/models/cms_search_form.py +++ b/cms_form/models/cms_search_form.py @@ -24,6 +24,11 @@ class CMSFormSearch(models.AbstractModel): _form_results_orderby = '' # declare fields that must be searched w/ multiple values _form_search_fields_multi = () + # declare custom domain computation rules + _form_search_domain_rules = { + # field name: (leaf field name, operator, format value), + # 'product_id': ('product_id.name', 'ilike', '{}'), + } def form_check_permission(self): """Just searching, nothing to check here.""" @@ -35,6 +40,13 @@ def form_update_fields_attributes(self, _fields): for fname, field in _fields.items(): field['required'] = False + def form_get_widget_model(self, fname, field): + """Search via related field needs a simple char widget.""" + res = super(CMSFormSearch, self).form_get_widget_model(fname, field) + if fname in self._form_search_domain_rules: + res = 'cms.form.widget.char' + return res + __form_search_results = {} @property @@ -113,9 +125,6 @@ def form_search_domain(self, search_values): leaf = (fname, 'in', value) domain.append(leaf) 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 = '=' @@ -128,15 +137,24 @@ def form_search_domain(self, search_values): 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 ('many2one', ): + value = int(value) if value.isdigit() else 0 + if not value or value < 1: + # we need an existing ID here ( > 0) + continue elif field['type'] in ('boolean', ): value = value == 'on' and True elif field['type'] in ('date', 'datetime'): if not value: # searching for an empty string breaks search continue + if fname in self._form_search_domain_rules: + fname, operator, fmt_value = \ + self._form_search_domain_rules[fname] + if hasattr(fmt_value, '__call__'): + value = fmt_value(field, value, search_values) + else: + value = fmt_value.format(value) if fmt_value else value leaf = (fname, operator, value) domain.append(leaf) return domain diff --git a/cms_form/tests/test_form_search.py b/cms_form/tests/test_form_search.py index 83f1330e8..2993d3210 100644 --- a/cms_form/tests/test_form_search.py +++ b/cms_form/tests/test_form_search.py @@ -107,3 +107,17 @@ def test_search_form_bypass_security_check(self): form = self.get_search_form( {}, sudo_uid=self.env.ref('base.public_user').id) self.assertTrue(form.form_check_permission()) + + def test_search_custom_rules(self): + data = {'country_id': 'Italy', } + form = self.get_search_form(data) + form.form_process() + # we find them all since domain is mocked to include all test partners + self.assert_results(form, 5, self.expected_partners) + # apply custom rules + data = {'country_id': 'Italy', } + form = self.get_search_form(data, search_domain_rules={ + 'country_id': ('country_id.name', 'ilike', '') + }) + form.form_process() + self.assert_results(form, 2, self.expected_partners[:2]) From 52a71fede952d141333224730d1e82e82d431997 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 11 May 2018 11:26:57 +0200 Subject: [PATCH 065/238] cms_form: use safe default for pager url --- cms_form/models/cms_search_form.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cms_form/models/cms_search_form.py b/cms_form/models/cms_search_form.py index ed388c90f..c59bc654a 100644 --- a/cms_form/models/cms_search_form.py +++ b/cms_form/models/cms_search_form.py @@ -83,6 +83,9 @@ def form_search(self, render_values): url = render_values.get('extra_args', {}).get('pager_url', '') if self._form_model: url = getattr(self.form_model, 'cms_search_url', url) + if not url: + # default to current path w/out paging + url = self.request.path.decode('utf-8').split('/page')[0] pager = self._form_results_pager(count=count, page=page, url=url) order = self._form_results_orderby or None results = self.form_model.search( From ab3181f41f5cc0f133b5c11ec41a20e247e4f742 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 31 May 2018 09:48:57 +0200 Subject: [PATCH 066/238] Bump cms_form 11.0.1.4.2 --- cms_form/CHANGES.rst | 10 ++++++++++ cms_form/__manifest__.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/cms_form/CHANGES.rst b/cms_form/CHANGES.rst index 4231e760d..24bce3ce6 100644 --- a/cms_form/CHANGES.rst +++ b/cms_form/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +11.0.1.4.2 (2018-05-31) +======================= + +Improvements +------------ + +* Search form: use safe default for pager url +* Search form: support quick domain rules via `_form_search_domain_rules` + + 11.0.1.4.1 (2018-04-29) ======================= diff --git a/cms_form/__manifest__.py b/cms_form/__manifest__.py index 2812ae844..08e725d54 100644 --- a/cms_form/__manifest__.py +++ b/cms_form/__manifest__.py @@ -5,7 +5,7 @@ 'name': 'CMS Form', 'summary': """ Basic content type form""", - 'version': '11.0.1.4.1', + 'version': '11.0.1.4.2', 'license': 'LGPL-3', 'author': 'Camptocamp, Odoo Community Association (OCA)', 'depends': [ From 4d7490c74fd66754d3159d09fee278eee41f02a7 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 12 Jun 2018 17:06:48 +0200 Subject: [PATCH 067/238] cms_form widgets: fix missing `required` attribute --- cms_form/templates/widgets.xml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/cms_form/templates/widgets.xml b/cms_form/templates/widgets.xml index 7e233d7db..f8bcbf54b 100644 --- a/cms_form/templates/widgets.xml +++ b/cms_form/templates/widgets.xml @@ -62,7 +62,9 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). t-att-name="widget.w_fname" t-attf-id="#{widget.w_fname}_#{opt_item_index}" t-att-value="opt_item['value']" - t-att-checked="opt_item['value'] == widget.w_field_value" /> + t-att-checked="opt_item['value'] == widget.w_field_value" + t-att-required="widget.w_field['required'] and '1' or None" + /> @@ -89,7 +91,8 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). data-display_name="name" data-fields='["name"]' t-att-placeholder="widget.w_field['string'] + '...'" t-att-data-domain="widget.w_field.get('domain') or []" - /> + t-att-required="widget.w_field['required'] and '1' or None" + /> @@ -111,7 +114,9 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). t-attf-class="form-control js_datepicker #{widget.w_css_klass}" type="text" data-format="yy-mm-dd" placeholder="YYYY-MM-DD" t-att-data-params='widget.w_data_json()' - t-att-value="widget.w_field_value"/> + t-att-value="widget.w_field_value" + t-att-required="widget.w_field['required'] and '1' or None" + /> @@ -127,6 +132,7 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). --> Date: Tue, 12 Jun 2018 17:16:18 +0200 Subject: [PATCH 068/238] cms_form: cleanup controller render values Cleanup render values and remove form fields' values. When you submit a form and there's an error odoo will give you back all submitted values into `kw` but: 1. we don't need them since all values are encapsulated into form.form_render_values and are already accessible on each widget 2. this can break website rendering because you might have fields w/ a name that overrides a rendering value not related to a form. Most common example: field named `website` will override odoo record for current website. --- cms_form/controllers/main.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/cms_form/controllers/main.py b/cms_form/controllers/main.py index d13daf481..e98bb615e 100644 --- a/cms_form/controllers/main.py +++ b/cms_form/controllers/main.py @@ -38,14 +38,24 @@ def get_render_values(self, form, **kw): if getattr(main_object, 'parent_id', None): # get the parent if any parent = main_object.parent_id - - kw.update({ + # Cleanup render values and remove form fields' values. + # When you submit a form and there's an error odoo will give you back + # all submitted values into `kw` but: + # 1. we don't need them since all values are encapsulated + # into form.form_render_values + # and are already accessible on each widget + # 2. this can break website rendering because you might have fields + # w/ a name that overrides a rendering value not related to a form. + # Most common example: field named `website` will override + # odoo record for current website. + vals = {k: v for k, v in kw.items() if k not in form.form_fields()} + vals.update({ 'form': form, 'main_object': main_object, 'parent': parent, 'controller': self, }) - return kw + return vals def form_model_key(self, model, **kw): """Return a valid form model.""" From ffbd33eea1d1ffef25031c3c0c08133012f3983b Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 12 Jun 2018 17:18:34 +0200 Subject: [PATCH 069/238] cms_form: be defensive on error block render --- cms_form/templates/form.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cms_form/templates/form.xml b/cms_form/templates/form.xml index 67a581166..97cf900a5 100644 --- a/cms_form/templates/form.xml +++ b/cms_form/templates/form.xml @@ -66,9 +66,11 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). From 1d4777f8580abcfe7ae93252999aa199c58a56a1 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 28 Jun 2018 15:16:40 +0200 Subject: [PATCH 070/238] Fix RST linting --- cms_form/CHANGES.rst | 45 ++++++++++++---------------------- cms_form/doc/source/basics.rst | 4 +-- 2 files changed, 17 insertions(+), 32 deletions(-) diff --git a/cms_form/CHANGES.rst b/cms_form/CHANGES.rst index 24bce3ce6..fae75e8e0 100644 --- a/cms_form/CHANGES.rst +++ b/cms_form/CHANGES.rst @@ -5,8 +5,7 @@ CHANGELOG 11.0.1.4.2 (2018-05-31) ======================= -Improvements ------------- +**Improvements** * Search form: use safe default for pager url * Search form: support quick domain rules via `_form_search_domain_rules` @@ -15,8 +14,7 @@ Improvements 11.0.1.4.1 (2018-04-29) ======================= -Docs ----- +**Docs** * Move documentation from README to `doc` folder @@ -24,8 +22,7 @@ Docs 11.0.1.4.0 (2018-04-27) ======================= -Improvements ------------- +**Improvements** * Include wizard name in form wrapper klass * Add request marshallers and tests @@ -39,8 +36,7 @@ Improvements * Rely on `cms_info` for permission and URLs -Fixes ------ +**Fixes** * Fix `fake_session` helper in form tests common @@ -48,8 +44,7 @@ Fixes 11.0.1.3.1 (2018-04-22) ======================= -Improvements ------------- +**Improvements** * Wizard: ease customization of stored values @@ -59,8 +54,7 @@ Improvements 11.0.1.3.0 (2018-04-17) ======================= -Improvements ------------- +**Improvements** * Add wizard support to easily create custom wizards @@ -68,8 +62,7 @@ Improvements 11.0.1.2.1 (2018-04-13) ======================= -Fixes ------ +**Fixes** * Fix search form regression on permission check @@ -80,8 +73,7 @@ Fixes 11.0.1.2.0 (2018-04-09) ======================= -Improvements ------------- +**Improvements** * Add error msg block for validation errors right below field * Support multiple values for same field @@ -122,8 +114,7 @@ Improvements Before this change you had to override the controller to gain control on it. -Fixes ------ +**Fixes** * Fix required attr on boolean widget (was not considered) * `_form_create` + `_form_write` use a copy of values to avoid pollution by Odoo @@ -134,8 +125,7 @@ Fixes 11.0.1.1.1 (2018-03-26) ======================= -Fixes ------ +**Fixes** * Fix date widget: default today only if empty @@ -143,16 +133,14 @@ Fixes 11.0.1.1.0 (2018-03-26) ======================= -Improvements ------------- +**Improvements** * Delegate field wrapper class computation to form * Add vertical fields option * Add multi value widget for search forms * Improve date widget: allow custom default today -Fixes ------ +**Fixes** * Fix fieldset support for search forms * Fix date search w/ empty value @@ -162,8 +150,7 @@ Fixes 11.0.1.0.4 (2018-03-23) ======================= -Improvements ------------- +**Improvements** * Ease override of JSON info * Add fieldsets support @@ -173,13 +160,11 @@ Improvements 11.0.1.0.3 (2018-03-21) ======================= -Improvements ------------- +**Improvements** * Form controller: main_object defaults to empty recordset -Fixes ------ +**Fixes** * Fix x2m widget value comparison * Fix x2m widget load default value empt^^ diff --git a/cms_form/doc/source/basics.rst b/cms_form/doc/source/basics.rst index cc65fe4bb..849808485 100644 --- a/cms_form/doc/source/basics.rst +++ b/cms_form/doc/source/basics.rst @@ -63,7 +63,7 @@ The form above can be extended with extra fields that are not part of the ``_for def form_after_create_or_update(self, values, extra_values): if extra_values.get('notify_partner'): - # do what you want here... + self.something_to_do_in_this_case() ``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 @@ -185,4 +185,4 @@ For the simplest cases you don't have to write a single line of JS. You can do i 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 \ No newline at end of file +* when `type` field is equal to `address` or `invoice` -> show `foo` field From 5452f338c4f502eeaceeddec83849466b0069236 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Thu, 28 Jun 2018 13:27:42 +0000 Subject: [PATCH 071/238] [UPD] Update cms_form.pot --- cms_form/i18n/cms_form.pot | 301 +++++++++++++++++++++++++++++++++++++ cms_form/i18n/de.po | 216 ++++++++++++++++++++++---- 2 files changed, 489 insertions(+), 28 deletions(-) create mode 100644 cms_form/i18n/cms_form.pot diff --git a/cms_form/i18n/cms_form.pot b/cms_form/i18n/cms_form.pot new file mode 100644 index 000000000..23440566f --- /dev/null +++ b/cms_form/i18n/cms_form.pot @@ -0,0 +1,301 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * cms_form +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 11.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: cms_form +#: code:addons/cms_form/controllers/main.py:86 +#, python-format +msgid "%s model has no CMS form registered." +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_mixin +msgid "CMS Form mixin" +msgstr "" + +#. module: cms_form +#: model:ir.ui.view,arch_db:cms_form.base_form_buttons +msgid "Cancel" +msgstr "" + +#. module: cms_form +#: code:addons/cms_form/models/cms_form.py:40 +#: code:addons/cms_form/models/cms_form.py:45 +#, python-format +msgid "Create %s" +msgstr "" + +#. 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 +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_binary_mixin_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_boolean_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_char_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_date_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_float_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_image_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_integer_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_many2many_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_many2one_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_many2one_multi_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_mixin_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_one2many_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_radio_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_selection_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_text_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_x2m_mixin_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_wizard_display_name +msgid "Display Name" +msgstr "" + +#. module: cms_form +#: code:addons/cms_form/models/cms_form.py:38 +#, python-format +msgid "Edit \"%s\"" +msgstr "" + +#. module: cms_form +#: model:ir.ui.view,arch_db:cms_form.fieldsets_as_tabs +msgid "Home" +msgstr "" + +#. 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 +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_binary_mixin_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_boolean_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_char_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_date_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_float_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_image_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_integer_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_many2many_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_many2one_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_many2one_multi_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_mixin_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_one2many_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_radio_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_selection_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_text_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_x2m_mixin_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_wizard_id +msgid "ID" +msgstr "" + +#. module: cms_form +#: code:addons/cms_form/models/cms_form.py:62 +#, python-format +msgid "Item created." +msgstr "" + +#. module: cms_form +#: code:addons/cms_form/models/cms_form.py:67 +#, python-format +msgid "Item updated." +msgstr "" + +#. module: cms_form +#: model:ir.ui.view,arch_db:cms_form.field_widget_image +msgid "Keep current image" +msgstr "" + +#. 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 +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_binary_mixin___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_boolean___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_char___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_date___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_float___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_image___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_integer___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_many2many___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_many2one___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_many2one_multi___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_mixin___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_one2many___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_radio___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_selection___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_text___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_x2m_mixin___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_wizard___last_update +msgid "Last Modified on" +msgstr "" + +#. module: cms_form +#: model:ir.ui.view,arch_db:cms_form.form_horizontal_field_wrapper +#: model:ir.ui.view,arch_db:cms_form.form_vertical_field_wrapper +msgid "Name" +msgstr "" + +#. module: cms_form +#: model:ir.ui.view,arch_db:cms_form.wizard_form_buttons +msgid "Next" +msgstr "" + +#. module: cms_form +#: model:ir.ui.view,arch_db:cms_form.wizard_form_buttons +msgid "Prev" +msgstr "" + +#. module: cms_form +#: model:ir.ui.view,arch_db:cms_form.field_widget_image +msgid "Replace current image" +msgstr "" + +#. module: cms_form +#: model:ir.ui.view,arch_db:cms_form.base_form +msgid "Required fields" +msgstr "" + +#. module: cms_form +#: code:addons/cms_form/models/cms_search_form.py:63 +#: model:ir.ui.view,arch_db:cms_form.search_form_buttons +#, python-format +msgid "Search" +msgstr "" + +#. module: cms_form +#: code:addons/cms_form/models/cms_search_form.py:68 +#, python-format +msgid "Search %s" +msgstr "" + +#. module: cms_form +#: code:addons/cms_form/models/cms_form.py:71 +#, python-format +msgid "Some required fields are empty." +msgstr "" + +#. module: cms_form +#: model:ir.ui.view,arch_db:cms_form.base_form_buttons +#: model:ir.ui.view,arch_db:cms_form.wizard_form_buttons +msgid "Submit" +msgstr "" + +#. module: cms_form +#: model:ir.ui.view,arch_db:cms_form.field_widget_date +msgid "YYYY-MM-DD" +msgstr "" + +#. module: cms_form +#: code:addons/cms_form/models/cms_form_mixin.py:166 +#, python-format +msgid "You are not allowed to create any record for the model `%s`." +msgstr "" + +#. module: cms_form +#: code:addons/cms_form/models/cms_form_mixin.py:156 +#, python-format +msgid "You cannot edit this record. Model: %s, ID: %s." +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form +msgid "cms.form" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_search +msgid "cms.form.search" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_binary_mixin +msgid "cms.form.widget.binary.mixin" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_boolean +msgid "cms.form.widget.boolean" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_char +msgid "cms.form.widget.char" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_date +msgid "cms.form.widget.date" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_float +msgid "cms.form.widget.float" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_image +msgid "cms.form.widget.image" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_integer +msgid "cms.form.widget.integer" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_many2many +msgid "cms.form.widget.many2many" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_many2one +msgid "cms.form.widget.many2one" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_many2one_multi +msgid "cms.form.widget.many2one.multi" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_mixin +msgid "cms.form.widget.mixin" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_one2many +msgid "cms.form.widget.one2many" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_radio +msgid "cms.form.widget.radio" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_selection +msgid "cms.form.widget.selection" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_text +msgid "cms.form.widget.text" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_x2m_mixin +msgid "cms.form.widget.x2m.mixin" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_wizard +msgid "cms.form.wizard" +msgstr "" + diff --git a/cms_form/i18n/de.po b/cms_form/i18n/de.po index fd0e3dd70..7754cb958 100644 --- a/cms_form/i18n/de.po +++ b/cms_form/i18n/de.po @@ -18,34 +18,24 @@ msgstr "" "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 +#: code:addons/cms_form/controllers/main.py:86 #, 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 +#: code:addons/cms_form/models/cms_form.py:40 +#: code:addons/cms_form/models/cms_form.py:45 #, python-format msgid "Create %s" msgstr "%s anlegen" @@ -54,33 +44,69 @@ msgstr "%s anlegen" #: 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 +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_binary_mixin_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_boolean_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_char_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_date_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_float_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_image_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_integer_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_many2many_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_many2one_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_many2one_multi_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_mixin_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_one2many_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_radio_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_selection_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_text_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_x2m_mixin_display_name +#: model:ir.model.fields,field_description:cms_form.field_cms_form_wizard_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 +#: code:addons/cms_form/models/cms_form.py:38 #, python-format msgid "Edit \"%s\"" msgstr "\"%s\" Bearbeiten" +#. module: cms_form +#: model:ir.ui.view,arch_db:cms_form.fieldsets_as_tabs +msgid "Home" +msgstr "" + #. 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 +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_binary_mixin_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_boolean_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_char_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_date_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_float_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_image_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_integer_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_many2many_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_many2one_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_many2one_multi_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_mixin_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_one2many_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_radio_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_selection_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_text_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_x2m_mixin_id +#: model:ir.model.fields,field_description:cms_form.field_cms_form_wizard_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 +#: code:addons/cms_form/models/cms_form.py:62 #, 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 +#: code:addons/cms_form/models/cms_form.py:67 #, python-format msgid "Item updated." msgstr "Objekt wurde aktualisiert." @@ -94,14 +120,42 @@ msgstr "Aktuelles Bild beibehalten" #: 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 +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_binary_mixin___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_boolean___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_char___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_date___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_float___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_image___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_integer___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_many2many___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_many2one___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_many2one_multi___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_mixin___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_one2many___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_radio___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_selection___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_text___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_widget_x2m_mixin___last_update +#: model:ir.model.fields,field_description:cms_form.field_cms_form_wizard___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 +#: model:ir.ui.view,arch_db:cms_form.form_vertical_field_wrapper msgid "Name" msgstr "Bezeichnung" +#. module: cms_form +#: model:ir.ui.view,arch_db:cms_form.wizard_form_buttons +msgid "Next" +msgstr "" + +#. module: cms_form +#: model:ir.ui.view,arch_db:cms_form.wizard_form_buttons +msgid "Prev" +msgstr "" + #. module: cms_form #: model:ir.ui.view,arch_db:cms_form.field_widget_image msgid "Replace current image" @@ -113,29 +167,27 @@ 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 +#: code:addons/cms_form/models/cms_search_form.py:63 #: 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 +#: code:addons/cms_form/models/cms_search_form.py:68 #, 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 +#: code:addons/cms_form/models/cms_form.py:71 #, 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 +#: model:ir.ui.view,arch_db:cms_form.wizard_form_buttons msgid "Submit" msgstr "Absenden" @@ -145,6 +197,114 @@ 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" +#: code:addons/cms_form/models/cms_form_mixin.py:166 +#, python-format +msgid "You are not allowed to create any record for the model `%s`." +msgstr "" + +#. module: cms_form +#: code:addons/cms_form/models/cms_form_mixin.py:156 +#, python-format +msgid "You cannot edit this record. Model: %s, ID: %s." +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form +msgid "cms.form" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_search +msgid "cms.form.search" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_binary_mixin +msgid "cms.form.widget.binary.mixin" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_boolean +msgid "cms.form.widget.boolean" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_char +msgid "cms.form.widget.char" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_date +msgid "cms.form.widget.date" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_float +msgid "cms.form.widget.float" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_image +msgid "cms.form.widget.image" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_integer +msgid "cms.form.widget.integer" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_many2many +msgid "cms.form.widget.many2many" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_many2one +msgid "cms.form.widget.many2one" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_many2one_multi +msgid "cms.form.widget.many2one.multi" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_mixin +msgid "cms.form.widget.mixin" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_one2many +msgid "cms.form.widget.one2many" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_radio +msgid "cms.form.widget.radio" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_selection +msgid "cms.form.widget.selection" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_text +msgid "cms.form.widget.text" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_widget_x2m_mixin +msgid "cms.form.widget.x2m.mixin" +msgstr "" + +#. module: cms_form +#: model:ir.model,name:cms_form.model_cms_form_wizard +msgid "cms.form.wizard" +msgstr "" + +#~ msgid "CMS edit URL" +#~ msgstr "CMS edit URL" + +#~ msgid "website.published.mixin" +#~ msgstr "website.published.mixin" From f9e79742fbb4330eb5d0e49ce7e1fb7b3542607b Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Fri, 29 Jun 2018 16:20:57 +0200 Subject: [PATCH 072/238] [11.0] cms_form: do not use empty strings in search domain --- cms_form/models/cms_search_form.py | 3 +++ cms_form/tests/test_form_search.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/cms_form/models/cms_search_form.py b/cms_form/models/cms_search_form.py index c59bc654a..69ffe1829 100644 --- a/cms_form/models/cms_search_form.py +++ b/cms_form/models/cms_search_form.py @@ -132,6 +132,9 @@ def form_search_domain(self, search_values): # It would be nice to guess leafs in a clever way. operator = '=' if field['type'] in ('char', 'text'): + # Do not use empty strings + if not value: + continue operator = 'ilike' value = '%{}%'.format(value) elif field['type'] in ('integer', 'float', 'many2one'): diff --git a/cms_form/tests/test_form_search.py b/cms_form/tests/test_form_search.py index 2993d3210..ed0efdd3e 100644 --- a/cms_form/tests/test_form_search.py +++ b/cms_form/tests/test_form_search.py @@ -90,6 +90,11 @@ def test_search(self): form.form_process() self.assert_results(form, 1, self.expected_partners[4:]) + data = {'name': '', 'country_id': self.env.ref('base.fr').id, } + form = self.get_search_form(data) + form.form_process() + self.assert_results(form, 1, self.expected_partners[4:]) + def test_search_multi(self): countries = [ self.env.ref('base.it').id, From 089dab8334cba1b86f75498a811587857e034cb5 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 4 Jul 2018 09:01:38 +0200 Subject: [PATCH 073/238] Bump cms_form 11.0.1.4.3 --- cms_form/CHANGES.rst | 23 +++++++++++++++++++++++ cms_form/__manifest__.py | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/cms_form/CHANGES.rst b/cms_form/CHANGES.rst index fae75e8e0..0262e0c5a 100644 --- a/cms_form/CHANGES.rst +++ b/cms_form/CHANGES.rst @@ -2,6 +2,29 @@ CHANGELOG ========= +11.0.1.4.3 (2018-07-04) +======================= + +**Fixes** + +* Be defensive on error block render (do not fail if none) +* Widgets: fix missing `required` attribute +* Search form: discard empty strings in search domain +* Cleanup controller render values + + When you submit a form and there's an error Odoo will give you back + all submitted values into `kw` but: + + 1. we don't need them since all values are encapsulated + into form.form_render_values + and are already accessible on each widget + + 2. this can break website rendering because you might have fields + w/ a name that overrides a rendering value not related to a form. + Most common example: field named `website` will override + odoo record for current website. + + 11.0.1.4.2 (2018-05-31) ======================= diff --git a/cms_form/__manifest__.py b/cms_form/__manifest__.py index 08e725d54..8be691163 100644 --- a/cms_form/__manifest__.py +++ b/cms_form/__manifest__.py @@ -5,7 +5,7 @@ 'name': 'CMS Form', 'summary': """ Basic content type form""", - 'version': '11.0.1.4.2', + 'version': '11.0.1.4.3', 'license': 'LGPL-3', 'author': 'Camptocamp, Odoo Community Association (OCA)', 'depends': [ From cb9940bc1d8fafb135575c578d800ab9b2436cd7 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 4 Jul 2018 10:27:04 +0200 Subject: [PATCH 074/238] Search form: fix default URL py3 compat --- cms_form/models/cms_search_form.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cms_form/models/cms_search_form.py b/cms_form/models/cms_search_form.py index 69ffe1829..d56a1651d 100644 --- a/cms_form/models/cms_search_form.py +++ b/cms_form/models/cms_search_form.py @@ -2,6 +2,7 @@ # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). from odoo import models, _ +from odoo.tools import pycompat class CMSFormSearch(models.AbstractModel): @@ -85,7 +86,10 @@ def form_search(self, render_values): url = getattr(self.form_model, 'cms_search_url', url) if not url: # default to current path w/out paging - url = self.request.path.decode('utf-8').split('/page')[0] + path = self.request.path + if not isinstance(path, pycompat.text_type): + path = path.decode('utf-8') + url = path.split('/page')[0] pager = self._form_results_pager(count=count, page=page, url=url) order = self._form_results_orderby or None results = self.form_model.search( From c6117cdd625128ca04b881043f36c1545613db71 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 4 Jul 2018 10:39:30 +0200 Subject: [PATCH 075/238] Bump cms_form 11.0.1.4.4 --- cms_form/CHANGES.rst | 9 +++++++++ cms_form/__manifest__.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/cms_form/CHANGES.rst b/cms_form/CHANGES.rst index 0262e0c5a..85c40ccfb 100644 --- a/cms_form/CHANGES.rst +++ b/cms_form/CHANGES.rst @@ -2,6 +2,15 @@ CHANGELOG ========= + +11.0.1.4.4 (2018-07-04) +======================= + +**Fixes** + +* Search form: fix default URL py3 compat + + 11.0.1.4.3 (2018-07-04) ======================= diff --git a/cms_form/__manifest__.py b/cms_form/__manifest__.py index 8be691163..2f3a67e7e 100644 --- a/cms_form/__manifest__.py +++ b/cms_form/__manifest__.py @@ -5,7 +5,7 @@ 'name': 'CMS Form', 'summary': """ Basic content type form""", - 'version': '11.0.1.4.3', + 'version': '11.0.1.4.4', 'license': 'LGPL-3', 'author': 'Camptocamp, Odoo Community Association (OCA)', 'depends': [ From 5fe16242cac4b883a346a74dbedf30bcbcf8845a Mon Sep 17 00:00:00 2001 From: oca-travis Date: Wed, 4 Jul 2018 10:33:25 +0000 Subject: [PATCH 076/238] [UPD] Update cms_form.pot --- cms_form/i18n/cms_form.pot | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cms_form/i18n/cms_form.pot b/cms_form/i18n/cms_form.pot index 23440566f..a645e5b8f 100644 --- a/cms_form/i18n/cms_form.pot +++ b/cms_form/i18n/cms_form.pot @@ -163,14 +163,14 @@ msgid "Required fields" msgstr "" #. module: cms_form -#: code:addons/cms_form/models/cms_search_form.py:63 +#: code:addons/cms_form/models/cms_search_form.py:64 #: model:ir.ui.view,arch_db:cms_form.search_form_buttons #, python-format msgid "Search" msgstr "" #. module: cms_form -#: code:addons/cms_form/models/cms_search_form.py:68 +#: code:addons/cms_form/models/cms_search_form.py:69 #, python-format msgid "Search %s" msgstr "" From 53ebf8278997556d86cd2bc74a51c525180b9dce Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 6 Jul 2018 14:19:54 +0200 Subject: [PATCH 077/238] cms_form: handle hidden input automatically You can now specify `_form_fields_hidden = ('foo', )` to get hidden inputs. All fields declared here will be rendered as ``. --- cms_form/doc/source/advanced.rst | 71 +++++++++++++++++++++++++++++++ cms_form/doc/source/basics.rst | 38 ----------------- cms_form/doc/source/index.rst | 1 + cms_form/models/cms_form_mixin.py | 20 ++++++++- cms_form/models/widgets.py | 6 +++ cms_form/templates/form.xml | 7 ++- cms_form/templates/widgets.xml | 13 ++++++ cms_form/tests/test_form_base.py | 17 ++++++++ 8 files changed, 133 insertions(+), 40 deletions(-) create mode 100644 cms_form/doc/source/advanced.rst diff --git a/cms_form/doc/source/advanced.rst b/cms_form/doc/source/advanced.rst new file mode 100644 index 000000000..e74d9b69f --- /dev/null +++ b/cms_form/doc/source/advanced.rst @@ -0,0 +1,71 @@ +Form advanced +============= + +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 + + +Hidden fields +------------- + +You might want to use `` for certain fields. +To achieve this just use `_form_fields_hidden`: + +.. 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') + _form_fields_hidden = ('foo', ) + +Field "foo" will be rendered at the top of the form markup with an hidden input. +For create forms, you might want to pass a default value to it, like: + + +.. code-block:: python + + class PartnerForm(models.AbstractModel): + [...] + def form_load_defaults(self, main_object=None, request_values=None): + defaults = super().form_load_defaults( + main_object=main_object, request_values=request_values + ) + defaults['foo'] = 'I can pass a default value here' + return defaults diff --git a/cms_form/doc/source/basics.rst b/cms_form/doc/source/basics.rst index 849808485..c867111c0 100644 --- a/cms_form/doc/source/basics.rst +++ b/cms_form/doc/source/basics.rst @@ -148,41 +148,3 @@ NOTE: default generic routes work if the form's name is ```cms.form.search`` + m 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`. - - -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 diff --git a/cms_form/doc/source/index.rst b/cms_form/doc/source/index.rst index e028c4c8c..7320899b3 100644 --- a/cms_form/doc/source/index.rst +++ b/cms_form/doc/source/index.rst @@ -11,6 +11,7 @@ Welcome to CMS Form's documentation! :caption: Contents: basics.rst + advanced.rst wizard.rst diff --git a/cms_form/models/cms_form_mixin.py b/cms_form/models/cms_form_mixin.py index 56b6872a6..d2bea3c74 100644 --- a/cms_form/models/cms_form_mixin.py +++ b/cms_form/models/cms_form_mixin.py @@ -76,6 +76,8 @@ class CMSFormMixin(models.AbstractModel): _form_fields_whitelist = () # exclude these fields _form_fields_blacklist = () + # include fields but make them input[type]=hidden + _form_fields_hidden = () # group form fields together _form_fieldsets = [ # { @@ -214,10 +216,21 @@ def form_model(self): # queue_job tries to read properties. Be defensive. return self.env.get(self._form_model) - def form_fields(self): + def form_fields(self, hidden=None): + """Retrieve form fields. + + :param hidden: whether to include or not hidden inputs. + Options: + * None, default: include all fields, hidden or not + * True: include only hidden fields + * False: include all fields but those hidden. + """ _fields = self._form_fields() # update fields attributes self.form_update_fields_attributes(_fields) + if hidden is not None: + return {k: v for k, v in _fields.items() + if v.get('hidden', False) == hidden} return _fields @tools.cache('self') @@ -310,6 +323,8 @@ def form_update_fields_attributes(self, _fields): for fname, field in _fields.items(): if fname in self._form_required_fields: _fields[fname]['required'] = True + if fname in self._form_fields_hidden: + _fields[fname]['hidden'] = True _fields[fname]['widget'] = self.form_get_widget(fname, field) @property @@ -319,6 +334,9 @@ def form_widgets(self): def form_get_widget_model(self, fname, field): """Retrieve widget model name.""" + if field.get('hidden'): + # special case + return 'cms.form.widget.hidden' widget_model = 'cms.form.widget.char' for key in (field['type'], fname): model_key = 'cms.form.widget.' + key diff --git a/cms_form/models/widgets.py b/cms_form/models/widgets.py index cb9e73e9f..64129c710 100644 --- a/cms_form/models/widgets.py +++ b/cms_form/models/widgets.py @@ -76,6 +76,12 @@ class CharWidget(models.AbstractModel): _w_template = 'cms_form.field_widget_char' +class HiddenWidget(models.AbstractModel): + _name = 'cms.form.widget.hidden' + _inherit = 'cms.form.widget.mixin' + _w_template = 'cms_form.field_widget_hidden' + + class IntegerWidget(models.AbstractModel): _name = 'cms.form.widget.integer' _inherit = 'cms.form.widget.char' diff --git a/cms_form/templates/form.xml b/cms_form/templates/form.xml index 97cf900a5..877f02132 100644 --- a/cms_form/templates/form.xml +++ b/cms_form/templates/form.xml @@ -153,9 +153,14 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). - + + + + + +
diff --git a/cms_form/templates/widgets.xml b/cms_form/templates/widgets.xml index f8bcbf54b..adf4639e7 100644 --- a/cms_form/templates/widgets.xml +++ b/cms_form/templates/widgets.xml @@ -20,6 +20,19 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + + + @@ -160,7 +160,7 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). diff --git a/cms_form/tests/__init__.py b/cms_form/tests/__init__.py index e579d4d66..8b4917d1d 100644 --- a/cms_form/tests/__init__.py +++ b/cms_form/tests/__init__.py @@ -8,5 +8,13 @@ from . import test_loaders from . import test_marshallers from .widgets import test_widget_base -from .widgets import test_widget_char +from .widgets import test_widget_text from .widgets import test_widget_hidden +from .widgets import test_widget_integer +from .widgets import test_widget_float +from .widgets import test_widget_selection +from .widgets import test_widget_radio +from .widgets import test_widget_boolean +from .widgets import test_widget_date +from .widgets import test_widget_many2one + diff --git a/cms_form/tests/test_form_cms.py b/cms_form/tests/test_form_cms.py index 11d023629..849d1b207 100644 --- a/cms_form/tests/test_form_cms.py +++ b/cms_form/tests/test_form_cms.py @@ -82,8 +82,8 @@ def test_create_or_update_with_errors(self): values = form.form_process_POST({}) self.assertTrue( # custom modules can provide different errors for constraints - '_integrity' in values['errors'] or - '_validation' in values['errors'] + '_integrity' in values['errors'] \ + or '_validation' in values['errors'] ) def test_purge_non_model_fields(self): diff --git a/cms_form/tests/widgets/__init__.py b/cms_form/tests/widgets/__init__.py index b776d4f13..186a3bb10 100644 --- a/cms_form/tests/widgets/__init__.py +++ b/cms_form/tests/widgets/__init__.py @@ -1,3 +1,10 @@ from . import test_widget_base -from . import test_widget_char +from . import test_widget_text from . import test_widget_hidden +from . import test_widget_integer +from . import test_widget_float +from . import test_widget_selection +from . import test_widget_radio +from . import test_widget_boolean +from . import test_widget_date +from . import test_widget_many2one diff --git a/cms_form/tests/widgets/common.py b/cms_form/tests/widgets/common.py index e6e8a6e21..5903807a6 100644 --- a/cms_form/tests/widgets/common.py +++ b/cms_form/tests/widgets/common.py @@ -60,3 +60,25 @@ class TestWidgetCase(SavepointCase, HTMLRenderMixin): @classmethod def get_widget(cls, fname, field, **kw): return get_widget(cls.env, fname, field, **kw) + + def _test_widget_attributes(self, widget, tag, expected, text=None): + node = self.to_xml_node(widget.render())[0] + node_input = self.find_input_name(node, expected['name']) + self.assertEqual(len(node_input), 1) + node_input = node_input[0] + self._test_element_attributes(node_input, tag, expected, text=text) + # return node for further testing + return node_input + + def _test_element_attributes(self, node, tag, expected, text=None): + self.assertEqual(node.tag, tag) + for attr_name, attr_value in expected.items(): + self.assertEqual(node.attrib[attr_name], attr_value) + # special attrs that should be set or not completely + for attr_name in ('required', 'checked'): + if expected.get(attr_name): + self.assertIn(attr_name, node.attrib) + else: + self.assertNotIn(attr_name, node.attrib) + if text: + self.assertEqual(node.text.strip(), text) diff --git a/cms_form/tests/widgets/test_widget_base.py b/cms_form/tests/widgets/test_widget_base.py index 0899ec2be..28c765f49 100644 --- a/cms_form/tests/widgets/test_widget_base.py +++ b/cms_form/tests/widgets/test_widget_base.py @@ -70,3 +70,18 @@ def test_w_ids_from_input(self): # not valid values are skipped self.assertEqual(widget.w_ids_from_input( '1,2,3,#4, 70, 1XX, 200'), [1, 2, 3, 70, 200]) + + def test_subfields_get(self): + form = get_form( + self.env, + 'cms.form.res.partner', + sub_fields={ + 'name': {'_all': ('custom', )}, + } + ) + fields = form.form_fields() + widget = self.get_widget('name', fields['name'], form=form) + self.assertEqual( + widget.w_subfields_by_value(), + {'custom': fields['custom']} + ) diff --git a/cms_form/tests/widgets/test_widget_boolean.py b/cms_form/tests/widgets/test_widget_boolean.py new file mode 100644 index 000000000..44df30c7f --- /dev/null +++ b/cms_form/tests/widgets/test_widget_boolean.py @@ -0,0 +1,62 @@ +# Copyright 2018 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from .common import TestWidgetCase, fake_form, fake_field + + +class TestWidgetBoolean(TestWidgetCase): + + def _get_widget(self, field_value=False): + """Initialize form w/ given value and return the widget.""" + form = fake_form(a_boolean_field=field_value) + w_name, w_field = fake_field( + 'a_boolean_field', + type='boolean', + ) + return self.get_widget( + w_name, w_field, form=form, + widget_model='cms.form.widget.boolean') + + def test_widget_boolean_input(self): + widget = self._get_widget() + self.assertFalse(widget.w_field_value) + expected_attrs = { + 'type': 'checkbox', + 'name': 'a_boolean_field', + 'class': 'form-control ', + } + self._test_widget_attributes(widget, 'input', expected_attrs) + + def test_widget_boolean_input_required(self): + widget = self._get_widget() + self.assertFalse(widget.w_field_value) + widget.w_field['required'] = True + expected_attrs = { + 'type': 'checkbox', + 'name': 'a_boolean_field', + 'class': 'form-control ', + 'required': '1', + } + self._test_widget_attributes(widget, 'input', expected_attrs) + + def test_widget_boolean_input_checked(self): + widget = self._get_widget(field_value=True) + self.assertTrue(widget.w_field_value) + expected_attrs = { + 'type': 'checkbox', + 'name': 'a_boolean_field', + 'class': 'form-control ', + 'checked': 'checked', + } + self._test_widget_attributes(widget, 'input', expected_attrs) + + def test_widget_boolean_input_extract(self): + widget = self._get_widget() + self.assertIs(widget.w_extract(a_boolean_field='1'), True) + self.assertIs(widget.w_extract(a_boolean_field='ok'), True) + self.assertIs(widget.w_extract(a_boolean_field='true'), True) + self.assertIs(widget.w_extract(a_boolean_field='yes'), True) + self.assertIs(widget.w_extract(a_boolean_field=True), True) + self.assertIs(widget.w_extract(a_boolean_field=1), True) + # any other value give us False + self.assertIs(widget.w_extract(a_boolean_field=''), False) + self.assertIs(widget.w_extract(a_boolean_field='no'), False) diff --git a/cms_form/tests/widgets/test_widget_date.py b/cms_form/tests/widgets/test_widget_date.py new file mode 100644 index 000000000..2d40adb57 --- /dev/null +++ b/cms_form/tests/widgets/test_widget_date.py @@ -0,0 +1,62 @@ +# Copyright 2019 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from .common import TestWidgetCase, fake_form, fake_field + + +class TestWidgetDate(TestWidgetCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + form = fake_form(a_date_field='2019-01-12', type='date') + cls.w_name, cls.w_field = fake_field('a_date_field') + cls.widget = cls.get_widget( + cls.w_name, cls.w_field, + form=form, widget_model='cms.form.widget.date') + + def test_widget_date_input(self): + expected_attrs = { + 'type': 'text', + 'name': 'a_date_field', + 'placeholder': 'YYYY-MM-DD', + 'value': '2019-01-12', + 'data-format': 'yy-mm-dd', + 'class': 'form-control js_datepicker ', + } + self._test_widget_attributes(self.widget, 'input', expected_attrs) + + def test_widget_date_input_required(self): + self.widget.w_field['required'] = True + expected_attrs = { + 'type': 'text', + 'name': 'a_date_field', + 'placeholder': 'YYYY-MM-DD', + 'value': '2019-01-12', + 'data-format': 'yy-mm-dd', + 'class': 'form-control js_datepicker ', + 'required': '1', + } + self._test_widget_attributes(self.widget, 'input', expected_attrs) + + def test_widget_date_input_all_elems(self): + node = self.to_xml_node(self.widget.render())[0] + self._test_element_attributes( + node, 'div', {'class': 'input-group'}, + ) + self.assertEqual(len(node.getchildren()), 2) + self._test_element_attributes( + node.getchildren()[0], 'input', {} + ) + self._test_element_attributes( + node.getchildren()[1], 'span', + {'class': 'input-group-addon js_datepicker_trigger'} + ) + self._test_element_attributes( + node.getchildren()[1].getchildren()[0], 'span', + {'class': 'fa fa-calendar'} + ) + + def test_widget_date_input_extract(self): + self.assertEqual(self.widget.w_extract(a_date_field=''), None) + self.assertEqual( + self.widget.w_extract(a_date_field='2019-01-12'), '2019-01-12') diff --git a/cms_form/tests/widgets/test_widget_char.py b/cms_form/tests/widgets/test_widget_float.py similarity index 57% rename from cms_form/tests/widgets/test_widget_char.py rename to cms_form/tests/widgets/test_widget_float.py index 302498f8b..d6bcde630 100644 --- a/cms_form/tests/widgets/test_widget_char.py +++ b/cms_form/tests/widgets/test_widget_float.py @@ -1,46 +1,52 @@ -# Copyright 2018 Simone Orsi +# Copyright 2019 Simone Orsi # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). from .common import TestWidgetCase, fake_form, fake_field -class TestWidgetChar(TestWidgetCase): +class TestWidgetFloat(TestWidgetCase): @classmethod def setUpClass(cls): super().setUpClass() - form = fake_form(a_char_field='abc') - cls.w_name, cls.w_field = fake_field('a_char_field') + form = fake_form(a_float_field=2.0) + cls.w_name, cls.w_field = fake_field('a_float_field', type='float') cls.widget = cls.get_widget( cls.w_name, cls.w_field, - form=form, widget_model='cms.form.widget.char') + form=form, widget_model='cms.form.widget.float') - def test_widget_char_input(self): + def test_widget_float_input(self): node = self.to_xml_node(self.widget.render())[0] node_input = self.find_input_name(node, self.w_name) self.assertEqual(len(node_input), 1) expected_attrs = { 'type': 'text', - 'name': 'a_char_field', - 'placeholder': 'A char field...', - 'value': 'abc', + 'name': 'a_float_field', + 'placeholder': 'A float field...', + 'value': '2.0', 'class': 'form-control ', } for attr_name, attr_value in expected_attrs.items(): self.assertEqual(node_input[0].attrib[attr_name], attr_value) self.assertNotIn('required', node_input[0].attrib) - def test_widget_char_input_required(self): + def test_widget_float_input_required(self): self.widget.w_field['required'] = True node = self.to_xml_node(self.widget.render())[0] node_input = self.find_input_name(node, self.w_name) self.assertEqual(len(node_input), 1) expected_attrs = { 'type': 'text', - 'name': 'a_char_field', - 'placeholder': 'A char field...', - 'value': 'abc', + 'name': 'a_float_field', + 'placeholder': 'A float field...', + 'value': '2.0', 'class': 'form-control ', 'required': '1', } for attr_name, attr_value in expected_attrs.items(): self.assertEqual(node_input[0].attrib[attr_name], attr_value) + + def test_widget_float_input_extract(self): + self.assertEqual(self.widget.w_extract(a_float_field='1'), 1.0) + self.assertEqual(self.widget.w_extract(a_float_field='2.0'), 2.0) + self.assertEqual(self.widget.w_extract(a_float_field='2,0'), None) + self.assertEqual(self.widget.w_extract(a_float_field=''), None) diff --git a/cms_form/tests/widgets/test_widget_hidden.py b/cms_form/tests/widgets/test_widget_hidden.py index c0ea7f7c5..448b0d9f5 100644 --- a/cms_form/tests/widgets/test_widget_hidden.py +++ b/cms_form/tests/widgets/test_widget_hidden.py @@ -3,7 +3,7 @@ from .common import TestWidgetCase, fake_form, fake_field -class TestWidgetChar(TestWidgetCase): +class TestWidgetHidden(TestWidgetCase): @classmethod def setUpClass(cls): @@ -24,55 +24,41 @@ def test_widget_char_input_hidden(self): w_name, w_field = fake_field('char_field') widget = self.get_widget(w_name, w_field, form=self.form, widget_model='cms.form.widget.hidden') - node = self.to_xml_node(widget.render())[0] - node_input = self.find_input_name(node, widget.w_html_fname) - self.assertEqual(len(node_input), 1) expected_attrs = { 'type': 'hidden', 'name': 'char_field', 'value': 'abc', } - for attr_name, attr_value in expected_attrs.items(): - self.assertEqual(node_input[0].attrib[attr_name], attr_value) - self.assertNotIn('required', node_input[0].attrib) + self._test_widget_attributes(widget, 'input', expected_attrs) # make it required # we'll test this only here: behavior is the same for each field type widget.w_field['required'] = True - node = self.to_xml_node(widget.render())[0] - node_input = self.find_input_name(node, widget.w_html_fname) - self.assertIn('required', node_input[0].attrib) + expected_attrs['required'] = '1' + self._test_widget_attributes(widget, 'input', expected_attrs) def test_widget_integer_input_hidden(self): """Test int field hidden.""" w_name, w_field = fake_field('int_field', type='integer') widget = self.get_widget(w_name, w_field, form=self.form, widget_model='cms.form.widget.hidden') - node = self.to_xml_node(widget.render())[0] - node_input = self.find_input_name(node, widget.w_html_fname) - self.assertEqual(len(node_input), 1) expected_attrs = { 'type': 'hidden', 'name': 'int_field:int', 'value': '5', } - for attr_name, attr_value in expected_attrs.items(): - self.assertEqual(node_input[0].attrib[attr_name], attr_value) + self._test_widget_attributes(widget, 'input', expected_attrs) def test_widget_float_input_hidden(self): """Test float field hidden.""" w_name, w_field = fake_field('float_field', type='float') widget = self.get_widget(w_name, w_field, form=self.form, widget_model='cms.form.widget.hidden') - node = self.to_xml_node(widget.render())[0] - node_input = self.find_input_name(node, widget.w_html_fname) - self.assertEqual(len(node_input), 1) expected_attrs = { 'type': 'hidden', 'name': 'float_field:float', 'value': '5.0', } - for attr_name, attr_value in expected_attrs.items(): - self.assertEqual(node_input[0].attrib[attr_name], attr_value) + self._test_widget_attributes(widget, 'input', expected_attrs) def test_widget_selection_string_input_hidden(self): """Test selection field hidden with string values.""" @@ -82,16 +68,12 @@ def test_widget_selection_string_input_hidden(self): selection=[('1', 'A'), ('2', 'B')]) widget = self.get_widget(w_name, w_field, form=self.form, widget_model='cms.form.widget.hidden') - node = self.to_xml_node(widget.render())[0] - node_input = self.find_input_name(node, widget.w_html_fname) - self.assertEqual(len(node_input), 1) expected_attrs = { 'type': 'hidden', 'name': 'selection_str_field', 'value': '1', } - for attr_name, attr_value in expected_attrs.items(): - self.assertEqual(node_input[0].attrib[attr_name], attr_value) + self._test_widget_attributes(widget, 'input', expected_attrs) def test_widget_selection_integer_input_hidden(self): """Test selection field hidden with integer values.""" @@ -101,16 +83,12 @@ def test_widget_selection_integer_input_hidden(self): selection=[(1, 'A'), (2, 'B')]) widget = self.get_widget(w_name, w_field, form=self.form, widget_model='cms.form.widget.hidden') - node = self.to_xml_node(widget.render())[0] - node_input = self.find_input_name(node, widget.w_html_fname) - self.assertEqual(len(node_input), 1) expected_attrs = { 'type': 'hidden', 'name': 'selection_integer_field:int', 'value': '2', } - for attr_name, attr_value in expected_attrs.items(): - self.assertEqual(node_input[0].attrib[attr_name], attr_value) + self._test_widget_attributes(widget, 'input', expected_attrs) def test_widget_selection_float_input_hidden(self): """Test selection field hidden with float values.""" @@ -120,29 +98,21 @@ def test_widget_selection_float_input_hidden(self): selection=[(4.0, 'A'), (8.0, 'B')]) widget = self.get_widget(w_name, w_field, form=self.form, widget_model='cms.form.widget.hidden') - node = self.to_xml_node(widget.render())[0] - node_input = self.find_input_name(node, widget.w_html_fname) - self.assertEqual(len(node_input), 1) expected_attrs = { 'type': 'hidden', 'name': 'selection_float_field:float', 'value': '4.0', } - for attr_name, attr_value in expected_attrs.items(): - self.assertEqual(node_input[0].attrib[attr_name], attr_value) + self._test_widget_attributes(widget, 'input', expected_attrs) def test_widget_many2one_input_hidden(self): """Test many2one field hidden.""" w_name, w_field = fake_field('many2one_field', type='many2one') widget = self.get_widget(w_name, w_field, form=self.form, widget_model='cms.form.widget.hidden') - node = self.to_xml_node(widget.render())[0] - node_input = self.find_input_name(node, widget.w_html_fname) - self.assertEqual(len(node_input), 1) expected_attrs = { 'type': 'hidden', 'name': 'many2one_field:int', 'value': '10', } - for attr_name, attr_value in expected_attrs.items(): - self.assertEqual(node_input[0].attrib[attr_name], attr_value) + self._test_widget_attributes(widget, 'input', expected_attrs) diff --git a/cms_form/tests/widgets/test_widget_integer.py b/cms_form/tests/widgets/test_widget_integer.py new file mode 100644 index 000000000..28d3804c6 --- /dev/null +++ b/cms_form/tests/widgets/test_widget_integer.py @@ -0,0 +1,48 @@ +# Copyright 2019 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from .common import TestWidgetCase, fake_form, fake_field + + +class TestWidgetInteger(TestWidgetCase): + + # TODO: test extraction and conversion to proper field value + # on EVERY widget and not just rely on the marshallers. + # Of course we have to switch to `w_html_fname` approach as hidden widget. + # This implies that we test w/ a full request too. + + @classmethod + def setUpClass(cls): + super().setUpClass() + form = fake_form(an_int_field=10) + cls.w_name, cls.w_field = fake_field('an_int_field', type='integer') + cls.widget = cls.get_widget( + cls.w_name, cls.w_field, + form=form, widget_model='cms.form.widget.integer') + + def test_widget_integer_input(self): + expected_attrs = { + 'type': 'text', + 'name': 'an_int_field', + 'placeholder': 'An int field...', + 'value': '10', + 'class': 'form-control ', + } + self._test_widget_attributes(self.widget, 'input', expected_attrs) + + def test_widget_integer_input_required(self): + self.widget.w_field['required'] = True + expected_attrs = { + 'type': 'text', + 'name': 'an_int_field', + 'placeholder': 'An int field...', + 'value': '10', + 'class': 'form-control ', + 'required': '1', + } + self._test_widget_attributes(self.widget, 'input', expected_attrs) + + def test_widget_integer_input_extract(self): + self.assertEqual(self.widget.w_extract(an_int_field='1'), 1) + self.assertEqual(self.widget.w_extract(an_int_field='4'), 4) + self.assertEqual(self.widget.w_extract(an_int_field='2.0'), None) + self.assertEqual(self.widget.w_extract(an_int_field=''), None) diff --git a/cms_form/tests/widgets/test_widget_many2one.py b/cms_form/tests/widgets/test_widget_many2one.py new file mode 100644 index 000000000..f1463cd45 --- /dev/null +++ b/cms_form/tests/widgets/test_widget_many2one.py @@ -0,0 +1,131 @@ +# Copyright 2019 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import json +from .common import TestWidgetCase, fake_form, fake_field + + +class TestWidgetM2O(TestWidgetCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partners = cls.env['res.partner'].search([], limit=4) + cls.form = fake_form( + # fake defaults + m2o_field=cls.partners.ids[0], + ) + + def test_widget_many2one_base(self): + w_name, w_field = fake_field( + 'm2o_field', + type='many2one', + relation='res.partner', + domain=[('id', 'in', self.partners.ids)] + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.many2one') + self.assertEqual(widget.w_comodel, self.env['res.partner']) + self.assertEqual(widget.w_domain, [('id', 'in', self.partners.ids)]) + self.assertEqual( + widget.w_option_items, self.partners) + + def test_widget_many2one_base_load(self): + w_name, w_field = fake_field( + 'm2o_field', + type='many2one', + relation='res.partner', + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.many2one') + + self.assertEqual(widget.w_load(m2o_field='1'), 1) + self.assertEqual(widget.w_load(m2o_field='0'), None) + self.assertEqual(widget.w_load(m2o_field='a'), None) + self.assertEqual(widget.w_load(m2o_field=1), 1) + self.assertEqual(widget.w_load( + m2o_field=self.partners[0]), self.partners[0].id) + self.assertEqual(widget.w_load(m2o_field=''), None) + self.assertEqual(widget.w_load(m2o_field=None), None) + self.assertEqual(widget.w_load(m2o_field=False), False) + + def test_widget_many2one_base_extract(self): + w_name, w_field = fake_field( + 'm2o_field', + type='many2one', + relation='res.partner', + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.many2one') + + self.assertEqual(widget.w_extract(m2o_field='1'), 1) + self.assertEqual(widget.w_extract(m2o_field='0'), None) + self.assertEqual(widget.w_extract(m2o_field='a'), None) + self.assertEqual(widget.w_extract(m2o_field=1), 1) + self.assertEqual(widget.w_extract( + m2o_field=self.partners[0]), self.partners[0].id) + self.assertEqual(widget.w_extract(m2o_field=''), None) + # none to ignore any change + self.assertEqual(widget.w_extract(m2o_field=None), None) + # false to flush the field + self.assertEqual(widget.w_extract(m2o_field=False), None) + + def test_widget_many2one_base_render(self): + w_name, w_field = fake_field( + 'm2o_field', + type='many2one', + relation='res.partner', + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.many2one') + expected_attrs = { + 'name': 'm2o_field', + } + self._test_widget_attributes(widget, 'select', expected_attrs) + widget.w_field['required'] = True + expected_attrs['required'] = '1' + self._test_widget_attributes(widget, 'select', expected_attrs) + + def test_widget_many2one_multi(self): + w_name, w_field = fake_field( + 'm2o_field', + type='many2one', + relation='res.partner', + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.many2one.multi') + expected_attrs = { + 'name': 'm2o_field', + 'class': 'form-control js_select2_m2m_widget m2o', + 'placeholder': 'M2o field...', + 'data-init-value': str(self.partners.ids[0]), + 'data-model': 'res.partner', + 'data-domain': '[]', + 'data-fields': '["name"]', + } + self._test_widget_attributes(widget, 'input', expected_attrs) + + def test_widget_many2one_multi_load(self): + w_name, w_field = fake_field( + 'm2o_field', + type='many2one', + relation='res.partner', + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.many2one.multi') + # test conversion + self.assertEqual(widget.w_load(m2o_field=False), '[]') + self.assertEqual( + widget.w_load(m2o_field='{},{}'.format(*self.partners.ids[0:2])), + json.dumps(self.partners[0:2].read(['name'])), + ) + + def test_widget_many2one_multi_extract(self): + w_name, w_field = fake_field( + 'm2o_field', + type='many2one', + relation='res.partner', + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.many2one.multi') + # test conversion + self.assertEqual(widget.w_extract(m2o_field='1,2,3'), [1, 2, 3]) diff --git a/cms_form/tests/widgets/test_widget_radio.py b/cms_form/tests/widgets/test_widget_radio.py new file mode 100644 index 000000000..2171e9942 --- /dev/null +++ b/cms_form/tests/widgets/test_widget_radio.py @@ -0,0 +1,60 @@ +# Copyright 2019 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from .common import TestWidgetCase, fake_form, fake_field + + +class TestWidgetRadio(TestWidgetCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.form = fake_form( + # fake defaults + radio_field='opt2', + ) + + def test_widget_radio_base(self): + select_options = [ + ('opt1', 'Option 1'), + ('opt2', 'Option 2'), + ('opt3', 'Option 3'), + ] + w_name, w_field = fake_field( + 'radio_field', + type='selection', + selection=select_options, + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.radio') + node = self.to_xml_node(widget.render())[0] + self.assertIn('radio-select', node.attrib['class']) + # we have only 3 options + self.assertEqual(len(node.getchildren()), 3) + for i, node_option in enumerate(node.getchildren()): + self._test_element_attributes( + node_option, 'div', {'class': 'radio option-item'} + ) + # we should have only the label wrapping the input + self.assertEqual(len(node_option.getchildren()), 1) + node_label = node_option.getchildren()[0] + self._test_element_attributes( + node_label, 'label', {'for': 'radio_field_%s' % i} + ) + # we should have only the input and the span w/ text here + self.assertEqual(len(node_label.getchildren()), 2) + node_input = node_label.getchildren()[0] + expected_attrs = { + 'type': 'radio', + 'name': 'radio_field', + 'id': 'radio_field_%s' % i, + 'value': 'opt%s' % (i + 1), + } + if i == 1: + expected_attrs['checked'] = 'checked' + self._test_element_attributes( + node_input, 'input', expected_attrs, + ) + node_span = node_label.getchildren()[1] + self._test_element_attributes( + node_span, 'span', {}, text='Option %s' % (i + 1), + ) diff --git a/cms_form/tests/widgets/test_widget_selection.py b/cms_form/tests/widgets/test_widget_selection.py new file mode 100644 index 000000000..534e07adb --- /dev/null +++ b/cms_form/tests/widgets/test_widget_selection.py @@ -0,0 +1,146 @@ +# Copyright 2019 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from .common import TestWidgetCase, fake_form, fake_field + + +class TestWidgetSelection(TestWidgetCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.form = fake_form( + # fake defaults + selection_char_field='opt1', + selection_integer_field=2, + selection_float_field=3.0, + ) + + def test_widget_selection_base(self): + select_options = [ + ('opt1', 'Option 1'), + ] + w_name, w_field = fake_field( + 'selection_char_field', + type='selection', + selection=select_options, + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.selection') + expected_attrs = { + 'name': 'selection_char_field', + } + self._test_widget_attributes(widget, 'select', expected_attrs) + widget.w_field['required'] = True + expected_attrs['required'] = '1' + self._test_widget_attributes(widget, 'select', expected_attrs) + + def test_widget_selection_char(self): + select_options = [ + ('opt1', 'Option 1'), + ('opt2', 'Option 2'), + ('opt3', 'Option 3'), + ] + w_name, w_field = fake_field( + 'selection_char_field', + type='selection', + selection=select_options, + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.selection') + expected_attrs = { + 'name': 'selection_char_field', + } + node = self._test_widget_attributes(widget, 'select', expected_attrs) + node_children = node.getchildren() + self.assertEqual(len(node_children), 4) + self.assertEqual( + node_children[0].attrib, {'value': '', 'class': 'empty_item'}) + self.assertEqual( + node_children[0].text.strip(), 'Selection char field...') + for i in range(1, 4): + expected_attrs = {'value': 'opt%s' % i} + if i == 1: + expected_attrs['selected'] = 'selected' + self.assertEqual( + node_children[i].attrib, expected_attrs) + self.assertEqual( + node_children[i].text.strip(), 'Option %s' % i) + + # test conversion + extracted = widget.w_extract(selection_char_field='opt2') + self.assertTrue(isinstance(extracted, str)) + + def test_widget_selection_integer(self): + select_options = [ + (1, 'Option 1'), + (2, 'Option 2'), + (3, 'Option 3'), + ] + w_name, w_field = fake_field( + 'selection_integer_field', + type='selection', + selection=select_options, + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.selection') + expected_attrs = { + 'name': 'selection_integer_field', + } + node = self._test_widget_attributes(widget, 'select', expected_attrs) + node_children = node.getchildren() + self.assertEqual(len(node_children), 4) + self.assertEqual( + node_children[0].attrib, {'value': '', 'class': 'empty_item'}) + self.assertEqual( + node_children[0].text.strip(), 'Selection integer field...') + for i in range(1, 4): + expected_attrs = {'value': str(i)} + if i == 2: + expected_attrs['selected'] = 'selected' + self.assertEqual( + node_children[i].attrib, expected_attrs) + self.assertEqual( + node_children[i].text.strip(), 'Option %s' % i) + + # test conversion + extracted = widget.w_extract(selection_integer_field='2') + self.assertTrue(isinstance(extracted, int)) + + def test_widget_selection_float(self): + select_options = [ + (1.0, 'Option 1'), + (2.0, 'Option 2'), + (3.0, 'Option 3'), + ] + w_name, w_field = fake_field( + 'selection_float_field', + type='selection', + selection=select_options, + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.selection') + expected_attrs = { + 'name': 'selection_float_field', + } + node = self._test_widget_attributes(widget, 'select', expected_attrs) + node_children = node.getchildren() + self.assertEqual(len(node_children), 4) + self._test_element_attributes( + node_children[0], + 'option', + {'value': '', 'class': 'empty_item'}, + text='Selection float field...', + ) + for i in range(1, 4): + expected_attrs = {'value': '%s.0' % i} + if i == 3: + expected_attrs['selected'] = 'selected' + self._test_element_attributes( + node_children[i], + 'option', + expected_attrs, + text='Option %s' % i, + ) + # test conversion + extracted = widget.w_extract(selection_float_field='3.0') + self.assertTrue(isinstance(extracted, float)) diff --git a/cms_form/tests/widgets/test_widget_text.py b/cms_form/tests/widgets/test_widget_text.py new file mode 100644 index 000000000..8e0df3919 --- /dev/null +++ b/cms_form/tests/widgets/test_widget_text.py @@ -0,0 +1,81 @@ +# Copyright 2018 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from .common import TestWidgetCase, fake_form, fake_field + + +TXT = """Lorem ipsum dolor sit amet, mea te propriae verterem. +Soluta viderer no vis. Ut populo suscipit vel. +Usu ea timeam utamur consectetuer, no simul dolorum vel. +Duo illum dolore id, ea mei error gloriatur voluptaria.""" + + +class TestWidgetTxt(TestWidgetCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.form = fake_form( + a_char_field='Just a string', + a_text_field=TXT, + ) + + def test_widget_char_input(self): + w_name, w_field = fake_field('a_char_field') + widget = self.get_widget( + w_name, w_field, + form=self.form, widget_model='cms.form.widget.char') + expected_attrs = { + 'type': 'text', + 'name': 'a_char_field', + 'placeholder': 'A char field...', + 'value': 'Just a string', + 'class': 'form-control ', + } + self._test_widget_attributes(widget, 'input', expected_attrs) + widget.w_field['required'] = True + expected_attrs['required'] = '1' + self._test_widget_attributes(widget, 'input', expected_attrs) + + def test_widget_text_input(self): + w_name, w_field = fake_field('a_text_field', type='text') + widget = self.get_widget( + w_name, w_field, + form=self.form, widget_model='cms.form.widget.text') + expected_attrs = { + 'name': 'a_text_field', + 'class': 'form-control ', + } + self._test_widget_attributes( + widget, 'textarea', expected_attrs, text=TXT) + widget.w_field['required'] = True + expected_attrs['required'] = '1' + self._test_widget_attributes( + widget, 'textarea', expected_attrs, text=TXT) + + def test_widget_text_input_maxlength(self): + w_name, w_field = fake_field( + 'a_text_field', type='text', maxlength=100) + widget = self.get_widget( + w_name, w_field, + form=self.form, widget_model='cms.form.widget.text') + node_items = self.to_xml_node(widget.render()) + self.assertEqual(len(node_items), 2) + node_textarea = node_items[0] + expected_attrs = { + 'name': 'a_text_field', + 'class': 'form-control ', + 'maxlength': '100', + } + self._test_element_attributes( + node_textarea, 'textarea', expected_attrs, text=TXT, + ) + node_counter = node_items[1] + expected_attrs = { + 'type': 'text', + 'name': 'a_text_field_counter', + 'size': '3', + 'class': 'form-control text-counter', + } + self._test_element_attributes( + node_counter, 'input', expected_attrs, + ) From a88aa5093f01ce89a7dc5c9ee0af08f9d34c1a4c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Sun, 13 Jan 2019 09:15:08 +0100 Subject: [PATCH 116/238] cms_form: x2many widget test cov + fix value handling --- cms_form/models/widgets/widget_x2many.py | 32 ++--- cms_form/templates/widgets.xml | 6 +- cms_form/tests/__init__.py | 2 +- cms_form/tests/widgets/__init__.py | 1 + cms_form/tests/widgets/common.py | 3 +- cms_form/tests/widgets/test_widget_boolean.py | 3 + cms_form/tests/widgets/test_widget_date.py | 2 + cms_form/tests/widgets/test_widget_float.py | 2 + cms_form/tests/widgets/test_widget_hidden.py | 7 ++ cms_form/tests/widgets/test_widget_integer.py | 2 + .../tests/widgets/test_widget_many2one.py | 3 + .../tests/widgets/test_widget_selection.py | 4 + cms_form/tests/widgets/test_widget_text.py | 4 + cms_form/tests/widgets/test_widget_x2many.py | 118 ++++++++++++++++++ 14 files changed, 171 insertions(+), 18 deletions(-) create mode 100644 cms_form/tests/widgets/test_widget_x2many.py diff --git a/cms_form/models/widgets/widget_x2many.py b/cms_form/models/widgets/widget_x2many.py index 06da12bf8..fa1b28bab 100644 --- a/cms_form/models/widgets/widget_x2many.py +++ b/cms_form/models/widgets/widget_x2many.py @@ -17,6 +17,11 @@ def widget_init(self, form, fname, field, **kw): widget.w_domain = widget.w_field.get('domain', []) return widget + # TODO: rename all widget-specific methods like: + # `x2many_to_form` -> `_w_orm_to_form` + # `form_to_x2many` -> `_w_form_to_orm` + # and make mixin's `w_load` and `w_extract` methods use them. + # In this way we remove all the overrides on `w_load` and `w_extract`. def w_load(self, **req_values): value = super().w_load(**req_values) return self.x2many_to_form(value, **req_values) @@ -33,20 +38,19 @@ def _is_not_valued(self, value): def x2many_to_form(self, value, **req_values): if self._is_not_valued(value): return json.dumps([]) - if not isinstance(value, str) \ - and self.w_record and self.w_record[self.w_fname] == value: - # value from record - value = [ - {'id': x.id, 'name': x[self.w_diplay_field]} - for x in value or [] - ] - elif isinstance(value, str) 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_model[self.w_fname].browse( - self.w_ids_from_input(value)).read(['name']) - value = json.dumps(value) - return value + ids = [] + if isinstance(value, type(self.w_comodel)): + ids = value.ids + elif isinstance(value, str): + ids = self.w_ids_from_input(value) + req_val = self.w_ids_from_input(req_values.get(self.w_fname, '')) + if req_val: + # request value take precedence + ids = req_val[:] + read_fields = [self.w_diplay_field, ] + if 'name' in self.w_comodel: + read_fields.append('name') + return json.dumps(self.w_comodel.browse(ids).read(read_fields)) def w_extract(self, **req_values): value = super().w_extract(**req_values) diff --git a/cms_form/templates/widgets.xml b/cms_form/templates/widgets.xml index af3f4e6e0..b7b631d3a 100644 --- a/cms_form/templates/widgets.xml +++ b/cms_form/templates/widgets.xml @@ -10,6 +10,7 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). @@ -195,6 +199,4 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
- - diff --git a/cms_form/tests/__init__.py b/cms_form/tests/__init__.py index 8b4917d1d..677bf6dcc 100644 --- a/cms_form/tests/__init__.py +++ b/cms_form/tests/__init__.py @@ -17,4 +17,4 @@ from .widgets import test_widget_boolean from .widgets import test_widget_date from .widgets import test_widget_many2one - +from .widgets import test_widget_x2many diff --git a/cms_form/tests/widgets/__init__.py b/cms_form/tests/widgets/__init__.py index 186a3bb10..14ea1ed89 100644 --- a/cms_form/tests/widgets/__init__.py +++ b/cms_form/tests/widgets/__init__.py @@ -8,3 +8,4 @@ from . import test_widget_boolean from . import test_widget_date from . import test_widget_many2one +from . import test_widget_x2many diff --git a/cms_form/tests/widgets/common.py b/cms_form/tests/widgets/common.py index 5903807a6..ec56aafe9 100644 --- a/cms_form/tests/widgets/common.py +++ b/cms_form/tests/widgets/common.py @@ -6,7 +6,7 @@ from ..common import HTMLRenderMixin -def fake_form(**data): +def fake_form(main_object=None, **data): """Get a mocked fake form. :param data: kw args for setting form values @@ -14,6 +14,7 @@ def fake_form(**data): form = mock.MagicMock(name='FakeForm') form_values = mock.PropertyMock(return_value={'form_data': data}) type(form).form_render_values = form_values + form.main_object = main_object return form diff --git a/cms_form/tests/widgets/test_widget_boolean.py b/cms_form/tests/widgets/test_widget_boolean.py index 44df30c7f..337b7bce7 100644 --- a/cms_form/tests/widgets/test_widget_boolean.py +++ b/cms_form/tests/widgets/test_widget_boolean.py @@ -21,6 +21,7 @@ def test_widget_boolean_input(self): self.assertFalse(widget.w_field_value) expected_attrs = { 'type': 'checkbox', + 'id': 'a_boolean_field', 'name': 'a_boolean_field', 'class': 'form-control ', } @@ -32,6 +33,7 @@ def test_widget_boolean_input_required(self): widget.w_field['required'] = True expected_attrs = { 'type': 'checkbox', + 'id': 'a_boolean_field', 'name': 'a_boolean_field', 'class': 'form-control ', 'required': '1', @@ -43,6 +45,7 @@ def test_widget_boolean_input_checked(self): self.assertTrue(widget.w_field_value) expected_attrs = { 'type': 'checkbox', + 'id': 'a_boolean_field', 'name': 'a_boolean_field', 'class': 'form-control ', 'checked': 'checked', diff --git a/cms_form/tests/widgets/test_widget_date.py b/cms_form/tests/widgets/test_widget_date.py index 2d40adb57..8cd0e9233 100644 --- a/cms_form/tests/widgets/test_widget_date.py +++ b/cms_form/tests/widgets/test_widget_date.py @@ -17,6 +17,7 @@ def setUpClass(cls): def test_widget_date_input(self): expected_attrs = { 'type': 'text', + 'id': 'a_date_field', 'name': 'a_date_field', 'placeholder': 'YYYY-MM-DD', 'value': '2019-01-12', @@ -29,6 +30,7 @@ def test_widget_date_input_required(self): self.widget.w_field['required'] = True expected_attrs = { 'type': 'text', + 'id': 'a_date_field', 'name': 'a_date_field', 'placeholder': 'YYYY-MM-DD', 'value': '2019-01-12', diff --git a/cms_form/tests/widgets/test_widget_float.py b/cms_form/tests/widgets/test_widget_float.py index d6bcde630..ce867be60 100644 --- a/cms_form/tests/widgets/test_widget_float.py +++ b/cms_form/tests/widgets/test_widget_float.py @@ -20,6 +20,7 @@ def test_widget_float_input(self): self.assertEqual(len(node_input), 1) expected_attrs = { 'type': 'text', + 'id': 'a_float_field', 'name': 'a_float_field', 'placeholder': 'A float field...', 'value': '2.0', @@ -36,6 +37,7 @@ def test_widget_float_input_required(self): self.assertEqual(len(node_input), 1) expected_attrs = { 'type': 'text', + 'id': 'a_float_field', 'name': 'a_float_field', 'placeholder': 'A float field...', 'value': '2.0', diff --git a/cms_form/tests/widgets/test_widget_hidden.py b/cms_form/tests/widgets/test_widget_hidden.py index 448b0d9f5..1a543854b 100644 --- a/cms_form/tests/widgets/test_widget_hidden.py +++ b/cms_form/tests/widgets/test_widget_hidden.py @@ -26,6 +26,7 @@ def test_widget_char_input_hidden(self): widget_model='cms.form.widget.hidden') expected_attrs = { 'type': 'hidden', + 'id': 'char_field', 'name': 'char_field', 'value': 'abc', } @@ -43,6 +44,7 @@ def test_widget_integer_input_hidden(self): widget_model='cms.form.widget.hidden') expected_attrs = { 'type': 'hidden', + 'id': 'int_field', 'name': 'int_field:int', 'value': '5', } @@ -55,6 +57,7 @@ def test_widget_float_input_hidden(self): widget_model='cms.form.widget.hidden') expected_attrs = { 'type': 'hidden', + 'id': 'float_field', 'name': 'float_field:float', 'value': '5.0', } @@ -70,6 +73,7 @@ def test_widget_selection_string_input_hidden(self): widget_model='cms.form.widget.hidden') expected_attrs = { 'type': 'hidden', + 'id': 'selection_str_field', 'name': 'selection_str_field', 'value': '1', } @@ -85,6 +89,7 @@ def test_widget_selection_integer_input_hidden(self): widget_model='cms.form.widget.hidden') expected_attrs = { 'type': 'hidden', + 'id': 'selection_integer_field', 'name': 'selection_integer_field:int', 'value': '2', } @@ -100,6 +105,7 @@ def test_widget_selection_float_input_hidden(self): widget_model='cms.form.widget.hidden') expected_attrs = { 'type': 'hidden', + 'id': 'selection_float_field', 'name': 'selection_float_field:float', 'value': '4.0', } @@ -112,6 +118,7 @@ def test_widget_many2one_input_hidden(self): widget_model='cms.form.widget.hidden') expected_attrs = { 'type': 'hidden', + 'id': 'many2one_field', 'name': 'many2one_field:int', 'value': '10', } diff --git a/cms_form/tests/widgets/test_widget_integer.py b/cms_form/tests/widgets/test_widget_integer.py index 28d3804c6..43034108e 100644 --- a/cms_form/tests/widgets/test_widget_integer.py +++ b/cms_form/tests/widgets/test_widget_integer.py @@ -22,6 +22,7 @@ def setUpClass(cls): def test_widget_integer_input(self): expected_attrs = { 'type': 'text', + 'id': 'an_int_field', 'name': 'an_int_field', 'placeholder': 'An int field...', 'value': '10', @@ -33,6 +34,7 @@ def test_widget_integer_input_required(self): self.widget.w_field['required'] = True expected_attrs = { 'type': 'text', + 'id': 'an_int_field', 'name': 'an_int_field', 'placeholder': 'An int field...', 'value': '10', diff --git a/cms_form/tests/widgets/test_widget_many2one.py b/cms_form/tests/widgets/test_widget_many2one.py index f1463cd45..668f65e94 100644 --- a/cms_form/tests/widgets/test_widget_many2one.py +++ b/cms_form/tests/widgets/test_widget_many2one.py @@ -30,6 +30,7 @@ def test_widget_many2one_base(self): widget.w_option_items, self.partners) def test_widget_many2one_base_load(self): + # TODO: test load value from form record w_name, w_field = fake_field( 'm2o_field', type='many2one', @@ -78,6 +79,7 @@ def test_widget_many2one_base_render(self): widget = self.get_widget(w_name, w_field, form=self.form, widget_model='cms.form.widget.many2one') expected_attrs = { + 'id': 'm2o_field', 'name': 'm2o_field', } self._test_widget_attributes(widget, 'select', expected_attrs) @@ -94,6 +96,7 @@ def test_widget_many2one_multi(self): widget = self.get_widget(w_name, w_field, form=self.form, widget_model='cms.form.widget.many2one.multi') expected_attrs = { + 'id': 'm2o_field', 'name': 'm2o_field', 'class': 'form-control js_select2_m2m_widget m2o', 'placeholder': 'M2o field...', diff --git a/cms_form/tests/widgets/test_widget_selection.py b/cms_form/tests/widgets/test_widget_selection.py index 534e07adb..9775f1faa 100644 --- a/cms_form/tests/widgets/test_widget_selection.py +++ b/cms_form/tests/widgets/test_widget_selection.py @@ -27,6 +27,7 @@ def test_widget_selection_base(self): widget = self.get_widget(w_name, w_field, form=self.form, widget_model='cms.form.widget.selection') expected_attrs = { + 'id': 'selection_char_field', 'name': 'selection_char_field', } self._test_widget_attributes(widget, 'select', expected_attrs) @@ -48,6 +49,7 @@ def test_widget_selection_char(self): widget = self.get_widget(w_name, w_field, form=self.form, widget_model='cms.form.widget.selection') expected_attrs = { + 'id': 'selection_char_field', 'name': 'selection_char_field', } node = self._test_widget_attributes(widget, 'select', expected_attrs) @@ -84,6 +86,7 @@ def test_widget_selection_integer(self): widget = self.get_widget(w_name, w_field, form=self.form, widget_model='cms.form.widget.selection') expected_attrs = { + 'id': 'selection_integer_field', 'name': 'selection_integer_field', } node = self._test_widget_attributes(widget, 'select', expected_attrs) @@ -120,6 +123,7 @@ def test_widget_selection_float(self): widget = self.get_widget(w_name, w_field, form=self.form, widget_model='cms.form.widget.selection') expected_attrs = { + 'id': 'selection_float_field', 'name': 'selection_float_field', } node = self._test_widget_attributes(widget, 'select', expected_attrs) diff --git a/cms_form/tests/widgets/test_widget_text.py b/cms_form/tests/widgets/test_widget_text.py index 8e0df3919..8dd8957f6 100644 --- a/cms_form/tests/widgets/test_widget_text.py +++ b/cms_form/tests/widgets/test_widget_text.py @@ -26,6 +26,7 @@ def test_widget_char_input(self): form=self.form, widget_model='cms.form.widget.char') expected_attrs = { 'type': 'text', + 'id': 'a_char_field', 'name': 'a_char_field', 'placeholder': 'A char field...', 'value': 'Just a string', @@ -42,6 +43,7 @@ def test_widget_text_input(self): w_name, w_field, form=self.form, widget_model='cms.form.widget.text') expected_attrs = { + 'id': 'a_text_field', 'name': 'a_text_field', 'class': 'form-control ', } @@ -62,6 +64,7 @@ def test_widget_text_input_maxlength(self): self.assertEqual(len(node_items), 2) node_textarea = node_items[0] expected_attrs = { + 'id': 'a_text_field', 'name': 'a_text_field', 'class': 'form-control ', 'maxlength': '100', @@ -72,6 +75,7 @@ def test_widget_text_input_maxlength(self): node_counter = node_items[1] expected_attrs = { 'type': 'text', + 'id': 'a_text_field_counter', 'name': 'a_text_field_counter', 'size': '3', 'class': 'form-control text-counter', diff --git a/cms_form/tests/widgets/test_widget_x2many.py b/cms_form/tests/widgets/test_widget_x2many.py new file mode 100644 index 000000000..1c1172638 --- /dev/null +++ b/cms_form/tests/widgets/test_widget_x2many.py @@ -0,0 +1,118 @@ +# Copyright 2019 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import json +from .common import TestWidgetCase, fake_form, fake_field + + +class TestWidgetX2M(TestWidgetCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partners = cls.env['res.partner'].search([], limit=4) + cls.form = fake_form( + # fake defaults + # behavior of o2m or m2m ATM is the same + m2m_field=cls.partners.ids[0:2], + ) + + def test_widget_x2many_base(self): + w_name, w_field = fake_field( + 'm2m_field', + type='many2many', + relation='res.partner', + domain=[('id', 'in', self.partners.ids)] + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.many2many') + self.assertEqual(widget.w_comodel, self.env['res.partner']) + self.assertEqual(widget.w_domain, [('id', 'in', self.partners.ids)]) + + def test_widget_x2many(self): + w_name, w_field = fake_field( + 'm2m_field', + type='many2many', + relation='res.partner', + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.many2many') + expected_attrs = { + 'id': 'm2m_field', + 'name': 'm2m_field', + 'class': 'form-control js_select2_m2m_widget ', + 'placeholder': 'M2m field...', + 'data-init-value': json.dumps(self.partners.ids[0:2]), + 'data-model': 'res.partner', + 'data-domain': '[]', + 'data-fields': '["name"]', + } + self._test_widget_attributes(widget, 'input', expected_attrs) + + def test_widget_x2many_base_load(self): + w_name, w_field = fake_field( + 'm2m_field', + type='many2many', + relation='res.partner', + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.many2many') + # test conversion + self.assertEqual(widget.w_load(m2m_field=False), '[]') + self.assertEqual( + widget.w_load(m2m_field='{},{}'.format(*self.partners.ids[0:2])), + json.dumps(self.partners[0:2].read(['display_name', 'name'])), + ) + + def test_widget_x2many_base_load_from_record(self): + categs = self.env['res.partner.category'].search([], limit=3) + partner = self.partners[0] + form = fake_form( + # category_id=categs, + main_object=partner, + ) + w_name, w_field = fake_field( + 'category_id', + type='many2many', + relation=categs._name, + ) + widget = self.get_widget(w_name, w_field, form=form, + widget_model='cms.form.widget.many2many') + # flush categories if any + partner.category_id = False + # flushed, no value + self.assertEqual(widget.w_load(), '[]') + # set some value + partner.category_id = categs[0:2] + # no value override from request: load from record + self.assertEqual( + widget.w_load(), + json.dumps(partner.category_id.read(['display_name', 'name'])), + ) + self.assertEqual( + # pass new value via request + widget.w_load(category_id=str(categs[-1].id)), + json.dumps(categs[-1].read(['display_name', 'name'])), + ) + + def test_widget_x2many_base_extract(self): + w_name, w_field = fake_field( + 'm2m_field', + type='many2many', + relation='res.partner', + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.many2many') + # test conversion + self.assertEqual(widget.w_extract(m2m_field='1,2,3'), [1, 2, 3]) + + def test_widget_x2many_load_no_value(self): + w_name, w_field = fake_field( + 'm2m_field', + type='many2many', + relation='res.partner', + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.many2many') + self.assertEqual(widget.w_load(m2m_field=''), '[]') + # empty value from default_get + self.assertEqual(widget.w_load(m2m_field=[(6, 0, [])]), '[]') From caf7c7e0a24747349b6a74ab54d5911a00b42adc Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Sun, 13 Jan 2019 12:12:08 +0100 Subject: [PATCH 117/238] cms_form: binary widget test cov + fix value handling --- cms_form/models/widgets/widget_binary.py | 29 +++- cms_form/templates/widgets.xml | 14 +- cms_form/tests/__init__.py | 1 + cms_form/tests/utils.py | 28 +++- cms_form/tests/widgets/__init__.py | 1 + cms_form/tests/widgets/test_widget_binary.py | 159 +++++++++++++++++++ 6 files changed, 217 insertions(+), 15 deletions(-) create mode 100644 cms_form/tests/widgets/test_widget_binary.py diff --git a/cms_form/models/widgets/widget_binary.py b/cms_form/models/widgets/widget_binary.py index dcbd19a81..6c110aaa2 100644 --- a/cms_form/models/widgets/widget_binary.py +++ b/cms_form/models/widgets/widget_binary.py @@ -4,6 +4,7 @@ import werkzeug import base64 +from odoo.tools import pycompat from odoo.tools.mimetypes import guess_mimetype from odoo import models @@ -22,18 +23,25 @@ def binary_to_form(self, value, **req_values): # 'raw_value': '', # 'mimetype': '', } + from_request = False if value: if isinstance(value, werkzeug.datastructures.FileStorage): - # value from request, we cannot set a value for input field - value = '' - mimetype = '' + from_request = True + byte_content = value.read() + value = base64.b64encode(byte_content) + if not isinstance(value, pycompat.text_type): + value = pycompat.to_text(value) else: - value = str(value, 'utf-8') - mimetype = guess_mimetype(base64.b64decode(value)) + if not isinstance( + value, pycompat.text_type): # pragma: no cover + value = value.encode() + byte_content = base64.b64decode(value) + mimetype = guess_mimetype(byte_content) _value = { 'value': value, 'raw_value': value, 'mimetype': mimetype, + 'from_request': from_request, } if mimetype.startswith('image/'): _value['value'] = 'data:{};base64,{}'.format(mimetype, value) @@ -47,16 +55,21 @@ def form_to_binary(self, value, **req_values): if self.w_fname not in req_values: return None _value = False - if req_values.get(self.w_fname + '_keepcheck') == 'yes': + keepcheck_flag = req_values.get(self.w_fname + '_keepcheck') + if not keepcheck_flag or keepcheck_flag == 'yes': + # no flag or flag marked as "keep current value" # prevent discarding image req_values.pop(self.w_fname, None) - req_values.pop(self.w_fname + '_keepcheck') + req_values.pop(self.w_fname + '_keepcheck', None) return None if value: if hasattr(value, 'read'): file_content = value.read() - _value = base64.encodestring(file_content) + _value = base64.b64encode(file_content) + if not isinstance(value, pycompat.text_type): + _value = pycompat.to_text(_value) else: + # like 'data:image/jpeg;base64,jRyRuUm2VP... _value = value.split(',')[-1] return _value diff --git a/cms_form/templates/widgets.xml b/cms_form/templates/widgets.xml index b7b631d3a..fcdc5b721 100644 --- a/cms_form/templates/widgets.xml +++ b/cms_form/templates/widgets.xml @@ -186,11 +186,21 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
diff --git a/cms_form/tests/__init__.py b/cms_form/tests/__init__.py index 677bf6dcc..223c82b57 100644 --- a/cms_form/tests/__init__.py +++ b/cms_form/tests/__init__.py @@ -18,3 +18,4 @@ from .widgets import test_widget_date from .widgets import test_widget_many2one from .widgets import test_widget_x2many +from .widgets import test_widget_binary diff --git a/cms_form/tests/utils.py b/cms_form/tests/utils.py index c72d339f2..8d6f6ec33 100644 --- a/cms_form/tests/utils.py +++ b/cms_form/tests/utils.py @@ -1,12 +1,14 @@ # Copyright 2017-2018 Simone Orsi # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). - -from io import StringIO -from werkzeug.wrappers import Request -from werkzeug.contrib.sessions import SessionStore +import base64 +from contextlib import contextmanager +import io import mock import urllib.parse +from werkzeug.wrappers import Request +from werkzeug.contrib.sessions import SessionStore +from werkzeug.datastructures import FileStorage from odoo import http, api from odoo.tests.common import get_db_name @@ -21,7 +23,7 @@ def fake_request(form_data=None, query_string=None, w_req = Request.from_values( query_string=query_string, content_length=len(data), - input_stream=StringIO(data), + input_stream=io.StringIO(data), content_type=content_type, method=method) w_req.session = session if session is not None else mock.MagicMock() @@ -83,3 +85,19 @@ def teardown_test_model(env, model_cls): if not getattr(model_cls, '_teardown_no_delete', False): del env.registry.models[model_cls._name] env.registry.setup_models(env.cr) + + +@contextmanager +def b64_as_stream(b64_content): + stream = io.BytesIO() + stream.write(base64.b64decode(b64_content)) + stream.seek(0) + yield stream + stream.close() + + +def fake_file_from_request(input_name, stream, **kw): + return FileStorage( + name=input_name, + stream=stream, **kw + ) diff --git a/cms_form/tests/widgets/__init__.py b/cms_form/tests/widgets/__init__.py index 14ea1ed89..e2c3a4cfa 100644 --- a/cms_form/tests/widgets/__init__.py +++ b/cms_form/tests/widgets/__init__.py @@ -9,3 +9,4 @@ from . import test_widget_date from . import test_widget_many2one from . import test_widget_x2many +from . import test_widget_binary diff --git a/cms_form/tests/widgets/test_widget_binary.py b/cms_form/tests/widgets/test_widget_binary.py new file mode 100644 index 000000000..517eee05f --- /dev/null +++ b/cms_form/tests/widgets/test_widget_binary.py @@ -0,0 +1,159 @@ +# Copyright 2019 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo.addons.cms_form.tests.utils import ( + fake_file_from_request, + b64_as_stream, +) +from .common import TestWidgetCase, fake_form, fake_field + +TEST_IMAGE_GIF = ( + 'iVBORw0KGgoAAAANSUhEUgAAAA4AAAAPCAYAAADUFP50AAAABmJLR0QA/wD/AP+gvaeTAAAAC' + 'XBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4wENCQc7YpV2jQAAAbtJREFUKM+dkr9rU3EUxT' + '/3fb/vvfxoTKIJtlpoiZGkqQriFgsuipvQWdEi/j39N1zcXZRgFwc3h4JDIEqjtZXSGny/37s' + 'O0cEt9SwHLudwLude6fU7yn/A0aKgXK2RF1CvVeZDv8p6p0sYZWzcuEkaZly53KbVvkT5Qovm' + 'ko8Meqv68P42yUoXHe3ydmpoDndo7+9x++oPlu4+xk0Nw3tNXu6+Zuv5MziZ4ORiqXXXGcgxr' + '/ZjACSPOJgeki1vwOGYnwl8OfhKmmWM9j7w7s0I6fU7miYJaMGt4QNWGh7jTx/5PD0iy4UkCv' + 'A8lzwHYx2kyElVkO71NW00GsxmM9IkJi8Ua12sNf+UkaYFrufAnyodAFVFVXE9H9/3sdagOlf' + '85e2dHu5pxKOnd1grJcjm4Jr65SpFkREEIaVKhTgM8P0SURxTKfkEYUSeKdYz1DvLrB5PcJIs' + 'RwTCMEJEiMMQEOI4RoAwihERrOuAKvor5CS1WFVFRBY+/Nm3U87E4BhjyNw6FwkWMoqAAI4qH' + 'E3GbD15cb6X8z1LyYTMvjsU51jZSdIM09rEDd7P4xeE9Pod1TyjcCxm8UB+A5x0uaR5zMPmAA' + 'AAAElFTkSuQmCC' +) + +TEST_IMAGE_JPG = ( + '/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMD' + 'AsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFB' + 'QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wgARCAA' + 'KAAoDAREAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAgMEB//EABkBAAIDAQAAAAAAAAAA' + 'AAAAAAMGAQIFCP/aAAwDAQACEAMQAAABz9M6UGapOvVB3f/EABkQAAMBAQEAAAAAAAAAAAAAA' + 'AECAwQRI//aAAgBAQABBQKYZ6UBSgPvtHNn/8QAJxEAAQIEAgsAAAAAAAAAAAAAAQIEAAMRIU' + 'FCBQYSFCIxUWFxsbL/2gAIAQMBAT8BdCW2bTOIitcSTyJtU9AbAiGLjbaSlVyjHt4jWBakuW6' + 'QbFM74jRyRuUm2VPqP//EAB8RAAIBAgcAAAAAAAAAAAAAAAABAgMEERIiIzJhsf/aAAgBAgEB' + 'PwGDzTWJUjrZbcW+16VXuSP/xAAcEAACAgIDAAAAAAAAAAAAAAABAgADESFxcrH/2gAIAQEAB' + 'j8CUKN8RlbTA4Ilcv7n2f/EABwQAQACAQUAAAAAAAAAAAAAAAEAESFBUXGBkf/aAAgBAQABPy' + 'G8BcDtiGrbbB1iAXhuzyAEKAwOU//aAAwDAQACAAMAAAAQpZ//xAAbEQEBAAMAAwAAAAAAAAA' + 'AAAABEQAhMUFR4f/aAAgBAwEBPxBh1D3QEEqNBAiklwkg1dtdjqhX2oXuPXKYLGWU8x5eZy75' + 'c//EAB8RAQABBAEFAAAAAAAAAAAAAAERACFBUWExgZGhsf/aAAgBAgEBPxBIIYTAHe3tnmozN' + 'uDfDHi1AYVyknUy/a//xAAYEAEBAQEBAAAAAAAAAAAAAAABESEAMf/aAAgBAQABPxAVEtAlQU' + 'RHUNG2bw1MmiKgmSI8QkJA4764GxsIAQA7/9k=' +) + + +class TestWidgetBinary(TestWidgetCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env['res.partner'].search([], limit=1) + cls.form = fake_form( + main_object=cls.partner + ) + cls.maxDiff = None + + # TODO: we have only an image widget ATM -> add a file widget and test it + def test_widget_binary_base(self): + w_name, w_field = fake_field( + 'image', + type='binary', + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.image') + node_items = self.to_xml_node(widget.render()) + self.assertEqual(len(node_items), 1) + node_wrapper = node_items[0] + expected_attrs = { + 'class': 'image-widget-wrapper', + } + self._test_element_attributes( + node_wrapper, 'div', expected_attrs, + ) + # no existing value so we get only the input + self.assertEqual(len(node_wrapper.getchildren()), 1) + node_input = node_wrapper.getchildren()[0] + expected_attrs = { + 'type': 'file', + 'id': 'image', + 'name': 'image', + 'class': 'form-control', + 'capture': 'camera', + 'accept': 'image/*', + } + self._test_element_attributes( + node_input, 'input', expected_attrs, + ) + + def test_widget_binary_load_from_record(self): + w_name, w_field = fake_field( + 'image', type='binary', + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.image') + # test conversion + self.assertEqual(widget.w_load(image=False), {}) + # set value on partner image + self.partner.image = TEST_IMAGE_GIF + self.assertEqual(widget.w_load(), { + 'value': 'data:image/png;base64,{}'.format(TEST_IMAGE_GIF), + 'raw_value': TEST_IMAGE_GIF, + 'mimetype': 'image/png', + 'from_request': False, + }) + + def test_widget_binary_load_from_request(self): + w_name, w_field = fake_field( + 'image', type='binary', + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.image') + # test conversion + self.assertEqual(widget.w_load(image=False), {}) + + with b64_as_stream(TEST_IMAGE_JPG) as stream: + req_image = fake_file_from_request( + 'image', stream=stream, + filename='foo.jpg', content_type='image/jpeg' + ) + self.assertEqual(widget.w_load(image=req_image), { + 'value': 'data:image/jpeg;base64,{}'.format(TEST_IMAGE_JPG), + 'raw_value': TEST_IMAGE_JPG, + 'mimetype': 'image/jpeg', + 'from_request': True, + }) + + def test_widget_binary_extract_string(self): + w_name, w_field = fake_field( + 'image', type='binary', + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.image') + + # no value in request -> None + self.assertEqual(widget.w_extract(), None) + # req value can come as string + req_val = 'data:image/jpeg;base64,{}'.format(TEST_IMAGE_JPG) + # value in request but no check flag -> None + self.assertEqual(widget.w_extract(image=req_val), None) + # value in request but keep flag is ON -> None + self.assertEqual( + widget.w_extract(image=req_val, image_keepcheck='yes'), None) + # value in request but keep flag is ON -> None + self.assertEqual( + widget.w_extract(image=req_val, image_keepcheck='no'), + TEST_IMAGE_JPG) + + def test_widget_binary_extract_filestorage(self): + w_name, w_field = fake_field( + 'image', type='binary', + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.image') + + # value in request but no check flag -> None + with b64_as_stream(TEST_IMAGE_JPG) as stream: + req_val = fake_file_from_request( + 'image', stream=stream, + filename='foo.jpg', content_type='image/jpeg' + ) + self.assertEqual( + widget.w_extract(image=req_val, image_keepcheck='no'), + TEST_IMAGE_JPG) From 5c10968eff7f6857c70c531ec481d2289b21c087 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Sun, 13 Jan 2019 13:53:18 +0100 Subject: [PATCH 118/238] cms_form: utils test cov 100% --- cms_form/tests/__init__.py | 1 + cms_form/tests/test_utils.py | 51 ++++++++++++++++++++++++++++++++++++ cms_form/utils.py | 17 +++++++----- 3 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 cms_form/tests/test_utils.py diff --git a/cms_form/tests/__init__.py b/cms_form/tests/__init__.py index 223c82b57..3170d13c9 100644 --- a/cms_form/tests/__init__.py +++ b/cms_form/tests/__init__.py @@ -7,6 +7,7 @@ from . import test_form_wizard from . import test_loaders from . import test_marshallers +from . import test_utils from .widgets import test_widget_base from .widgets import test_widget_text from .widgets import test_widget_hidden diff --git a/cms_form/tests/test_utils.py b/cms_form/tests/test_utils.py new file mode 100644 index 000000000..72a189d48 --- /dev/null +++ b/cms_form/tests/test_utils.py @@ -0,0 +1,51 @@ +# Copyright 2018 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import unittest +from .. import utils + + +class TestUtils(unittest.TestCase): + + def test_safe_to_integer(self): + self.assertEqual(utils.safe_to_integer(''), None) + self.assertEqual(utils.safe_to_integer(False), 0) + self.assertEqual(utils.safe_to_integer('abc'), None) + self.assertEqual(utils.safe_to_integer('10.0'), None) + self.assertEqual(utils.safe_to_integer('0'), 0) + self.assertEqual(utils.safe_to_integer('10'), 10) + + def test_safe_to_float(self): + self.assertEqual(utils.safe_to_float(''), None) + self.assertEqual(utils.safe_to_float(False), 0.0) + self.assertEqual(utils.safe_to_float('abc'), None) + self.assertEqual(utils.safe_to_float('10,0'), None) + self.assertEqual(utils.safe_to_float('10.0'), 10.0) + self.assertEqual(utils.safe_to_float('0'), 0.0) + self.assertEqual(utils.safe_to_float('10'), 10.0) + + def test_safe_to_date(self): + self.assertEqual(utils.safe_to_date(''), None) + self.assertEqual(utils.safe_to_date('2019-01-13'), '2019-01-13') + + def test_string_to_bool(self): + for val in ('on', 'yes', 'ok', 'true', True, 1, '1', ): + self.assertTrue(utils.string_to_bool(val)) + for val in ('', ' ', '2', 'no', 'whatever is not true'): + self.assertFalse(utils.string_to_bool(val)) + + def test_data_merge(self): + a = {'a': 1, 'b': {'foo': 'bar'}, 'd': [1, 2], 'e': [5, 6]} + b = {'a': 2, 'b': {'baz': 'taz'}, 'c': 'yo', 'd': [3, 4], 'e': 8} + expected = { + 'a': 2, + 'b': {'foo': 'bar', 'baz': 'taz'}, + 'c': 'yo', + 'd': [1, 2, 3, 4], + 'e': [5, 6, 8], + } + self.assertEqual(utils.data_merge(a, b), expected) + with self.assertRaises(ValueError): + utils.data_merge({'a': {'x': 1, 'y': 2}}, {'a': ['not', 'compat']}) + with self.assertRaises(NotImplementedError): + utils.data_merge({'a': set([1, 2, 3])}, {'a': set([4, ])}) diff --git a/cms_form/utils.py b/cms_form/utils.py index 4d3fa73ac..543fa6968 100644 --- a/cms_form/utils.py +++ b/cms_form/utils.py @@ -63,12 +63,15 @@ def data_merge(a, b): else: a[key] = b[key] else: - raise Exception( - 'Cannot merge non-dict "%s" into dict "%s"' % (b, a)) + raise ValueError( + 'Cannot merge non-dict "%s" into dict "%s"' % (b, a) + ) else: - raise Exception('NOT IMPLEMENTED "%s" into "%s"' % (b, a)) - except TypeError as e: - raise Exception( - 'TypeError "%s" in key "%s" ' - 'when merging "%s" into "%s"' % (e, key, b, a)) + raise NotImplementedError( + 'NOT IMPLEMENTED "%s" into "%s"' % (b, a) + ) + except TypeError as e: # pragma: no cover + raise TypeError( + '"%s" in key "%s" when merging "%s" into "%s"' % (e, key, b, a) + ) return a From b3aa88460c3ad5b7946c7f694ee1bd40f9a21dae Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Sun, 13 Jan 2019 16:41:19 +0100 Subject: [PATCH 119/238] cms_form: cms.form.mixin test cov 100% --- cms_form/models/cms_form_mixin.py | 29 +++++----- cms_form/tests/fake_models.py | 6 +- cms_form/tests/test_form_base.py | 80 +++++++++++++++++++++++++- cms_form/tests/test_form_permission.py | 33 ++++++++++- 4 files changed, 128 insertions(+), 20 deletions(-) diff --git a/cms_form/models/cms_form_mixin.py b/cms_form/models/cms_form_mixin.py index 0c543be81..2f41cef6b 100644 --- a/cms_form/models/cms_form_mixin.py +++ b/cms_form/models/cms_form_mixin.py @@ -178,11 +178,13 @@ def _can_create(self, raise_exception=True): if self._form_model: return self.form_model.check_access_rights( 'create', raise_exception=raise_exception) + # just a safe fallback if you call this method directly return True def _can_edit(self, raise_exception=True): """Check that current user can edit main object if any.""" if not self.main_object: + # just a safe fallback if you call this method directly return True try: self.main_object.check_access_rights('write') @@ -196,11 +198,11 @@ def _can_edit(self, raise_exception=True): @property def form_title(self): - return '' + return '' # pragma: no cover @property def form_description(self): - return '' + return '' # pragma: no cover @property def form_mode(self): @@ -300,15 +302,16 @@ def _form_prepare_subfields(self, _all_fields): """Add subfields to related main fields.""" # TODO: test this for mainfield, subfields in self._form_sub_fields.items(): - if mainfield in _all_fields: - _subfields = {} - for val, subs in subfields.items(): - _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 + if mainfield not in _all_fields: + continue + _subfields = {} + for val, subs in subfields.items(): + _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.""" @@ -522,11 +525,11 @@ def form_process(self, **kw): def form_process_GET(self, render_values): """Process GET requests.""" - return render_values + return render_values # pragma: no cover def form_process_POST(self, render_values): """Process POST requests.""" - raise NotImplementedError() + raise NotImplementedError() # pragma: no cover @property def form_wrapper_css_klass(self): diff --git a/cms_form/tests/fake_models.py b/cms_form/tests/fake_models.py index 9588654c2..4c9cbc73a 100644 --- a/cms_form/tests/fake_models.py +++ b/cms_form/tests/fake_models.py @@ -13,11 +13,7 @@ class FakePartnerForm(models.AbstractModel): _form_model_fields = ('name', 'country_id') _form_required_fields = ('name', 'country_id') - def form_check_permission(self, raise_exception=True): - # no need for this - pass - - custom = fields.Char() + custom = fields.Char(default=lambda self: 'I am your default') def _form_load_custom(self, fname, field, value, **req_values): return req_values.get('custom', 'oh yeah!') diff --git a/cms_form/tests/test_form_base.py b/cms_form/tests/test_form_base.py index 7462ead9a..8f29def55 100644 --- a/cms_form/tests/test_form_base.py +++ b/cms_form/tests/test_form_base.py @@ -1,7 +1,7 @@ # Copyright 2017-2018 Simone Orsi # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). - +import mock from werkzeug.wrappers import Request from odoo import http @@ -52,6 +52,14 @@ def test_form_init_overrides(self): for k, v in overrides.items(): self.assertEqual(getattr(form, '_form_' + k), v) + def test_form_mode(self): + form = self.get_form('cms.form.mixin') + self.assertEqual(form.form_mode, 'create') + form = self.get_form('cms.form.mixin', main_object=object()) + self.assertEqual(form.form_mode, 'edit') + form = self.get_form('cms.form.mixin', mode='custom') + self.assertEqual(form.form_mode, 'custom') + def test_fields_load(self): form = self.get_form('cms.form.res.partner') fields = form.form_fields() @@ -106,6 +114,13 @@ def test_fields_attributes(self): # this one is forced to required in our custom form self.assertTrue(fields['country_id']['required']) + def test_fields_defaults(self): + form = self.get_form('cms.form.res.partner') + self.env['ir.default'].set('res.partner', 'name', 'DEFAULT NAME') + fields = form.form_fields() + self.assertEqual(fields['name']['_default'], 'DEFAULT NAME') + self.assertEqual(fields['custom']['_default'], 'I am your default') + def test_fields_hidden(self): form = self.get_form( 'cms.form.res.partner', fields_hidden=('country_id', )) @@ -139,6 +154,28 @@ def test_fields_hidden_keep_order(self): self.assertListEqual( list(fields.keys()), ['custom', 'name', ]) + def test_subfields(self): + form = self.get_form( + 'cms.form.res.partner', + sub_fields={ + 'name': {'_all': ('custom', )}, + 'do_not_exists': {'_all': ('foo', )} # skipped + } + ) + fields = form.form_fields() + self.assertEqual( + fields['name']['subfields'], + {'_all': {'custom': fields['custom']}} + ) + self.assertTrue(fields['custom']['is_subfield']) + + def test_fields_binary(self): + form = self.get_form( + 'cms.form.res.partner', + model_fields=['name', 'image'] + ) + self.assertEqual(list(form.form_file_fields.keys()), ['image', ]) + def test_fields_protected(self): group = self.env.ref('website.group_website_designer') user = self.env.ref('base.user_demo') @@ -314,6 +351,40 @@ def test_extract_from_request_no_value(self): for fname in ['a_many2many', 'a_one2many', ]: self.assertEqual(values[fname], [(5, )]) + def test_extract_from_request_custom_extractor(self): + # test custom extractor integration w/ form_extract_values + form = self.get_form('cms.form.test_fields') + # values from request + data = { + 'a_char': 'Jack White', + 'a_number': '10', + 'a_float': '5', + 'a_many2one': '123', + 'a_many2many': '1,2,3', + 'a_one2many': '4,5,6', + } + request = fake_request(form_data=data) + # write mode + form = self.get_form('cms.form.test_fields', req=request) + + def custom_extractor(form, fname, value, **request_values): + return 'custom for: ' + fname + + # by type + form._form_extract_a_char = custom_extractor + form._form_extract_a_number = custom_extractor + values = form.form_extract_values() + expected = { + 'a_char': 'custom for: a_char', + 'a_number': 'custom for: a_number', + 'a_float': 5.0, + 'a_many2one': 123, + 'a_many2many': [(6, False, [1, 2, 3]), ], + 'a_one2many': [(6, False, [4, 5, 6]), ], + } + for k, v in values.items(): + self.assertEqual(expected[k], v) + def test_get_widget(self): form = self.get_form('cms.form.test_fields') expected = { @@ -390,3 +461,10 @@ def test_field_wrapper_css_klass(self): ), ('form-group form-field field-float ' 'field-foo_field field-required has-error') ) + + def test_info_merge_call(self): + form = self.get_form('cms.form.res.partner') + with mock.patch('odoo.addons.cms_form' + '.models.cms_form_mixin.utils.data_merge') as mocked: + form._form_info_merge({'a': '1'}, {'b': '2'}) + mocked.assert_called_with({'a': '1'}, {'b': '2'}) diff --git a/cms_form/tests/test_form_permission.py b/cms_form/tests/test_form_permission.py index b2b026673..b9a8a3652 100644 --- a/cms_form/tests/test_form_permission.py +++ b/cms_form/tests/test_form_permission.py @@ -5,7 +5,11 @@ import mock from .common import FormTestCase -from .fake_models import FakePublishModel, FakePublishModelForm +from .fake_models import ( + FakePublishModel, + FakePublishModelForm, + FakePartnerForm, +) class TestFormPermCheck(FormTestCase): @@ -13,6 +17,7 @@ class TestFormPermCheck(FormTestCase): TEST_MODELS_KLASSES = [ FakePublishModel, FakePublishModelForm, + FakePartnerForm, ] @classmethod @@ -72,3 +77,29 @@ def test_form_check_permission_cannot_edit(self): 'You cannot edit this record. Model: %s, ID: %d.' ) % (self.record._name, self.record.id) self.assertEqual(err.name, msg) + + def test_form_check_permission_no_ws_mixin_can_create(self): + form = self.get_form(FakePartnerForm._name, main_object=None) + self.assertTrue(form.form_check_permission()) + + def test_form_check_permission_no_ws_mixin_can_edit(self): + partner = self.env['res.partner'].search([], limit=1) + form = self.get_form(FakePartnerForm._name, main_object=partner) + self.assertTrue(form.form_check_permission()) + + def test_form_check_permission_no_record_no_model_can_edit_create(self): + form = self.get_form(FakePartnerForm._name, main_object=None) + form._form_model = None + self.assertTrue(form._can_edit()) + self.assertTrue(form._can_create()) + + def test_form_check_permission_form_cannot_edit(self): + form = self.get_form( + FakePartnerForm._name, main_object=self.record) + with mock.patch.object( + type(self.record), 'check_access_rights' + ) as patched: + patched.side_effect = exceptions.AccessError('boom') + with self.assertRaises(exceptions.AccessError): + form._can_edit() + self.assertFalse(form._can_edit(raise_exception=False)) From d23f1c71e7f69796d89a28d1bbc3b5b665edf1a4 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 16 Jan 2019 11:47:20 +0100 Subject: [PATCH 120/238] cms_form: fix selection widget w/ non-Selection field Potentially you can apply form widgets to different type of fields. Easy case: apply a select to an integer field. In such case we should not rely on `selection` key to be there when extracting the value. --- cms_form/models/widgets/widget_selection.py | 6 ++++-- cms_form/tests/widgets/test_widget_selection.py | 11 +++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/cms_form/models/widgets/widget_selection.py b/cms_form/models/widgets/widget_selection.py index 5f5024dab..05667e311 100644 --- a/cms_form/models/widgets/widget_selection.py +++ b/cms_form/models/widgets/widget_selection.py @@ -17,7 +17,9 @@ def w_extract(self, **req_values): # and not brake existing forms/widgets. value = super().w_extract(**req_values) first_value = None - if self.w_field['selection']: + # use `get` as you might want to use the selection widget + # for non-Selection fields and just pass options via `w_option_items`. + if self.w_field.get('selection'): # `fields.Selection` does this under the hood # to state the PG column type to be used. first_value = self.w_field['selection'][0][0] @@ -31,7 +33,7 @@ def w_extract(self, **req_values): def w_option_items(self): return [ {'value': x[0], 'label': x[1]} - for x in self.w_field['selection'] + for x in self.w_field.get('selection', []) ] diff --git a/cms_form/tests/widgets/test_widget_selection.py b/cms_form/tests/widgets/test_widget_selection.py index 9775f1faa..2228c94fb 100644 --- a/cms_form/tests/widgets/test_widget_selection.py +++ b/cms_form/tests/widgets/test_widget_selection.py @@ -148,3 +148,14 @@ def test_widget_selection_float(self): # test conversion extracted = widget.w_extract(selection_float_field='3.0') self.assertTrue(isinstance(extracted, float)) + + def test_widget_selection_non_selection_field(self): + w_name, w_field = fake_field( + 'selection_char_field', + type='selection', + # do not pass `selection` + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.selection') + # no selection found: should not fail and give back an empty list + self.assertEqual(widget.w_option_items, []) From 6fff9f4e7e9afd83e34cc0e3717d27b546b192e0 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 16 Jan 2019 13:11:50 +0100 Subject: [PATCH 121/238] [REF] cms_form: date widget use snippet animation --- cms_form/models/widgets/widget_date.py | 2 ++ cms_form/static/src/js/date_widget.js | 50 ++++++++++++++++---------- cms_form/templates/widgets.xml | 3 +- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/cms_form/models/widgets/widget_date.py b/cms_form/models/widgets/widget_date.py index c3a16ffb1..43b392b29 100644 --- a/cms_form/models/widgets/widget_date.py +++ b/cms_form/models/widgets/widget_date.py @@ -13,6 +13,8 @@ class DateWidget(models.AbstractModel): _w_template = 'cms_form.field_widget_date' # TODO: allow customization of date format + # TODO: adopt this attr to control placeholders on all widgets + w_placeholder = 'YYYY-MM-DD' def widget_init(self, form, fname, field, **kw): widget = super().widget_init(form, fname, field, **kw) diff --git a/cms_form/static/src/js/date_widget.js b/cms_form/static/src/js/date_widget.js index e88a01d8d..79c96779f 100644 --- a/cms_form/static/src/js/date_widget.js +++ b/cms_form/static/src/js/date_widget.js @@ -1,14 +1,17 @@ odoo.define('cms_form.date_widget', function (require) { 'use strict'; - require('web.dom_ready'); + var sAnimation = require('website.content.snippets.animation'); - // TODO: migrate to snippet animation - $(document).ready(function () { - $("input.js_datepicker").each(function () { - var $input = $(this); - var options = _.defaults( - $input.data('params') || {}, { + sAnimation.registry.CMSDateWidget = sAnimation.Class.extend({ + selector: ".cms_form_wrapper form input.js_datepicker", + start: function () { + this.load_options(); + this.setup_datepicker(); + }, + load_options: function() { + this.options = _.defaults( + this.$el.data('params') || {}, { useSeconds: false, icons: { time: 'fa fa-clock-o', @@ -16,29 +19,38 @@ odoo.define('cms_form.date_widget', function (require) { up: 'fa fa-chevron-up', down: 'fa fa-chevron-down' }, - dateFormat: $input.data('format') + // python = YYYY-MM-DD + dateFormat: 'yy-mm-dd' } ) - $input.datepicker(options); - var defaultDate = $input.data('params').defaultDate; + }, + setup_datepicker: function() { + var self = this; + this.$el.datepicker(this.options); + this.set_default_date(); + this.$el + .closest('.input-group') + .find('.js_datepicker_trigger') + .click(function () { + self.$el.datepicker('show'); + }); + }, + set_default_date: function () { + var self = this; + var defaultDate = self.options.defaultDate; if (defaultDate) { // use custom date defaultDate = new Date(defaultDate); - } else if ($input.data('params').defaultToday) { + } else if (self.options.defaultToday) { // unless blocked go for today date defaultDate = new Date(); } - if (defaultDate && !$input.val()) { - $input.datepicker( + if (defaultDate && !self.$el.val()) { + self.$el.datepicker( "setDate", defaultDate ); } - $input - .closest('.input-group') - .find('.js_datepicker_trigger').click(function () { - $input.datepicker('show'); - }); - }); + } }); diff --git a/cms_form/templates/widgets.xml b/cms_form/templates/widgets.xml index fcdc5b721..4ceb1d107 100644 --- a/cms_form/templates/widgets.xml +++ b/cms_form/templates/widgets.xml @@ -128,7 +128,8 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). Date: Fri, 18 Jan 2019 08:57:57 +0100 Subject: [PATCH 122/238] Bump cms_form 11.0.1.6.3 --- cms_form/CHANGES.rst | 12 ++++++++++++ cms_form/__manifest__.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/cms_form/CHANGES.rst b/cms_form/CHANGES.rst index 5dfa7781a..f484fdde2 100644 --- a/cms_form/CHANGES.rst +++ b/cms_form/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +11.0.1.6.3 (2019-01-18) +======================= + +**Fixes** + +* binary widget test cov + fix value handling +* x2many widget test cov + fix value handling +* fix selection widget w/ non-Selection field +* cms.form.mixin test cov 100% +* utils test cov 100% +* widgets test cov 100% + 11.0.1.6.2 (2018-08-21) ======================= diff --git a/cms_form/__manifest__.py b/cms_form/__manifest__.py index 195108532..2bf5989d9 100644 --- a/cms_form/__manifest__.py +++ b/cms_form/__manifest__.py @@ -5,7 +5,7 @@ 'name': 'CMS Form', 'summary': """ Basic content type form""", - 'version': '11.0.1.6.2', + 'version': '11.0.1.6.3', 'license': 'LGPL-3', 'author': 'Camptocamp, Odoo Community Association (OCA)', 'depends': [ From f0dde273bc70b06f7626e1830d2ca3c56e18ce84 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 21 Jan 2019 21:39:24 +0100 Subject: [PATCH 123/238] cms_form: go ahead w/ test cov 100% --- cms_form/__init__.py | 6 +- cms_form/controllers/main.py | 5 - cms_form/models/cms_form.py | 20 +-- cms_form/models/cms_form_mixin.py | 3 +- cms_form/models/cms_form_wizard.py | 3 +- cms_form/models/cms_search_form.py | 42 +++--- cms_form/models/widgets/widget_binary.py | 13 ++ cms_form/models/widgets/widget_mixin.py | 6 +- cms_form/tests/common.py | 4 +- cms_form/tests/fake_models.py | 31 +++- cms_form/tests/test_controllers.py | 83 ++++++++++- cms_form/tests/test_form_base.py | 90 +++++++++++- cms_form/tests/test_form_cms.py | 147 ++++++++++++++++++- cms_form/tests/test_form_permission.py | 30 ++-- cms_form/tests/test_form_search.py | 120 +++++++++++++++ cms_form/tests/test_form_wizard.py | 73 +++++++++ cms_form/tests/test_marshallers.py | 7 + cms_form/tests/utils.py | 3 +- cms_form/tests/widgets/test_widget_binary.py | 49 +++++++ 19 files changed, 661 insertions(+), 74 deletions(-) diff --git a/cms_form/__init__.py b/cms_form/__init__.py index f7209b171..658653461 100644 --- a/cms_form/__init__.py +++ b/cms_form/__init__.py @@ -1,2 +1,4 @@ -from . import models -from . import controllers +# not sure why these lines are counted as not test covered +# since nothing would work in tests w/out them being loaded +from . import models # pragma: no cover +from . import controllers # pragma: no cover diff --git a/cms_form/controllers/main.py b/cms_form/controllers/main.py index e98bb615e..1aa2656ff 100644 --- a/cms_form/controllers/main.py +++ b/cms_form/controllers/main.py @@ -34,10 +34,6 @@ def get_render_values(self, form, **kw): You can override this to inject more values. """ main_object = form.main_object - parent = None - if getattr(main_object, 'parent_id', None): - # get the parent if any - parent = main_object.parent_id # Cleanup render values and remove form fields' values. # When you submit a form and there's an error odoo will give you back # all submitted values into `kw` but: @@ -52,7 +48,6 @@ def get_render_values(self, form, **kw): vals.update({ 'form': form, 'main_object': main_object, - 'parent': parent, 'controller': self, }) return vals diff --git a/cms_form/models/cms_form.py b/cms_form/models/cms_form.py index cc1821087..d6aa37a7b 100644 --- a/cms_form/models/cms_form.py +++ b/cms_form/models/cms_form.py @@ -1,7 +1,6 @@ # Copyright 2017-2018 Simone Orsi # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). -import werkzeug from psycopg2 import IntegrityError from odoo import models, exceptions, _ @@ -90,18 +89,10 @@ def form_cancel_url(self, main_object=None): return main_object.website_url return self.request.referrer or '/' - def form_check_empty_field(self, fname, field, value, **req_values): + def form_check_empty_value(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, '') + # delegate to each specific widget + return field['widget'].w_check_empty_value(value, **req_values) def form_validate(self, request_values=None): """Validate submitted values.""" @@ -113,9 +104,8 @@ def form_validate(self, request_values=None): for fname, field in self.form_fields().items(): value = request_values.get(fname) error = False - if field['required'] \ - and self.form_check_empty_field( - fname, field, value, **request_values): + if field['required'] and self.form_check_empty_value( + fname, field, value, **request_values): errors[fname] = 'missing' missing = True validator = self.form_get_validator(fname, field) diff --git a/cms_form/models/cms_form_mixin.py b/cms_form/models/cms_form_mixin.py index 2f41cef6b..220eb8f10 100644 --- a/cms_form/models/cms_form_mixin.py +++ b/cms_form/models/cms_form_mixin.py @@ -520,8 +520,7 @@ def form_process(self, **kw): 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 + self.form_render_values = dict(render_values, **handler(render_values)) def form_process_GET(self, render_values): """Process GET requests.""" diff --git a/cms_form/models/cms_form_wizard.py b/cms_form/models/cms_form_wizard.py index c28461ca3..493360d01 100644 --- a/cms_form/models/cms_form_wizard.py +++ b/cms_form/models/cms_form_wizard.py @@ -110,7 +110,7 @@ def wiz_get_step_info(self, step): try: return self.wiz_configure_steps()[step] except KeyError: - raise ValueError('Step `%d` does not exists.' % step) + raise ValueError('Step `%s` does not exists.' % str(step)) def wiz_current_step(self): return self.wiz_storage_get().get('current') or 1 @@ -142,6 +142,7 @@ def wiz_save_step(self, values, step=None): step = step or self.wiz_current_step() storage = self.wiz_storage_get() if step not in storage['steps']: + # safely re-init step storage['steps'][step] = {} storage['steps'][step].update(values) diff --git a/cms_form/models/cms_search_form.py b/cms_form/models/cms_search_form.py index d56a1651d..77782f842 100644 --- a/cms_form/models/cms_search_form.py +++ b/cms_form/models/cms_search_form.py @@ -27,8 +27,12 @@ class CMSFormSearch(models.AbstractModel): _form_search_fields_multi = () # declare custom domain computation rules _form_search_domain_rules = { - # field name: (leaf field name, operator, format value), - # 'product_id': ('product_id.name', 'ilike', '{}'), + # opt 1: `field name: (leaf field name, operator, format value)` + # `format_value` is a formatting compatible string + # 'product_id': ('product_id.name', 'ilike', '{}') + + # opt 2: function that give back `(fname, operator, value)`` + # 'foo': lambda field, value, search_values: ('foo', 'not like', value) } def form_check_permission(self): @@ -81,15 +85,7 @@ def form_search(self, render_values): 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) - if not url: - # default to current path w/out paging - path = self.request.path - if not isinstance(path, pycompat.text_type): - path = path.decode('utf-8') - url = path.split('/page')[0] + url = self._form_get_url_for_pager(render_values) pager = self._form_results_pager(count=count, page=page, url=url) order = self._form_results_orderby or None results = self.form_model.search( @@ -105,6 +101,17 @@ def form_search(self, render_values): } return self.form_search_results + def _form_get_url_for_pager(self, render_values): + # default to current path w/out paging + path = pycompat.to_text(self.request.path) + url = path.split('/page')[0] + if self._form_model: + # rely on model's cms search url + url = getattr(self.form_model, 'cms_search_url', None) or url + # override via controller/request specific value + url = render_values.get('extra_args', {}).get('pager_url', url) + return url + def pager(self, **kw): return self.env['website'].pager(**kw) @@ -147,11 +154,6 @@ def form_search_domain(self, search_values): if not value: continue operator = 'in' - elif field['type'] in ('many2one', ): - value = int(value) if value.isdigit() else 0 - if not value or value < 1: - # we need an existing ID here ( > 0) - continue elif field['type'] in ('boolean', ): value = value == 'on' and True elif field['type'] in ('date', 'datetime'): @@ -159,11 +161,11 @@ def form_search_domain(self, search_values): # searching for an empty string breaks search continue if fname in self._form_search_domain_rules: - fname, operator, fmt_value = \ - self._form_search_domain_rules[fname] - if hasattr(fmt_value, '__call__'): - value = fmt_value(field, value, search_values) + rule = self._form_search_domain_rules[fname] + if hasattr(rule, '__call__'): + fname, operator, value = rule(field, value, search_values) else: + fname, operator, fmt_value = rule value = fmt_value.format(value) if fmt_value else value leaf = (fname, operator, value) domain.append(leaf) diff --git a/cms_form/models/widgets/widget_binary.py b/cms_form/models/widgets/widget_binary.py index 6c110aaa2..52133c5c4 100644 --- a/cms_form/models/widgets/widget_binary.py +++ b/cms_form/models/widgets/widget_binary.py @@ -73,6 +73,19 @@ def form_to_binary(self, value, **req_values): _value = value.split(',')[-1] return _value + def w_check_empty_value(self, value, **req_values): + if isinstance(value, werkzeug.datastructures.FileStorage): + has_value = bool(value.filename) + keep_flag = req_values.get(self.w_fname + '_keepcheck') + if not has_value and keep_flag == '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 super().w_check_empty_value(value, **req_values) + class ImageWidget(models.AbstractModel): _name = 'cms.form.widget.image' diff --git a/cms_form/models/widgets/widget_mixin.py b/cms_form/models/widgets/widget_mixin.py index 7b0915722..58dd16bb8 100644 --- a/cms_form/models/widgets/widget_mixin.py +++ b/cms_form/models/widgets/widget_mixin.py @@ -43,7 +43,7 @@ def w_css_klass(self): 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) + # we could have form-only fields 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 @@ -54,6 +54,10 @@ def w_extract(self, **req_values): """Extract value from form submit.""" return req_values.get(self.w_fname) + def w_check_empty_value(self, value, **req_values): + # `None` values are meant to be ignored as not changed + return value in (False, '') + @staticmethod def w_ids_from_input(value): """Convert list of ids from form input.""" diff --git a/cms_form/tests/common.py b/cms_form/tests/common.py index c0824c001..fab375680 100644 --- a/cms_form/tests/common.py +++ b/cms_form/tests/common.py @@ -1,7 +1,6 @@ # Copyright 2017-2018 Simone Orsi # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). - from lxml import html from odoo.tests.common import SavepointCase, HttpCase from .utils import ( @@ -114,3 +113,6 @@ def tearDown(self): def html_get(self, url): resp = self.url_open(url, timeout=30) return html.document_fromstring(resp.content) + + def get_form(self, form_model, **kw): + return get_form(self.env, form_model, **kw) diff --git a/cms_form/tests/fake_models.py b/cms_form/tests/fake_models.py index 4c9cbc73a..0aec2e4b5 100644 --- a/cms_form/tests/fake_models.py +++ b/cms_form/tests/fake_models.py @@ -68,6 +68,16 @@ def form_widgets(self): return res +class FakePartnerChannelForm(models.AbstractModel): + """A test model form.""" + + _name = 'cms.form.mail.channel.partner' + _inherit = 'cms.form' + # This model has `_rec_name = 'partner_id'` and allows us + # to test a specific case for form_title computation + _form_model = 'mail.channel.partner' + + class FakeFieldsForm(models.AbstractModel): """A test model form.""" @@ -193,16 +203,33 @@ class FakeWizStep3Partner(models.AbstractModel): # `AbstractModel` or `TransientModel` needed to make ACL check happy` -class FakePublishModel(models.TransientModel): +class FakePubModel(models.TransientModel): _name = 'fake.publishable' _inherit = [ 'website.published.mixin', ] name = fields.Char() + def _compute_website_url(self): + for item in self: + item.website_url = '/publishable/%d' % item.id + -class FakePublishModelForm(models.AbstractModel): +class FakePubModelForm(models.AbstractModel): _name = 'cms.form.fake.publishable' _inherit = 'cms.form' _form_model = 'fake.publishable' _form_model_fields = ('name', ) + + +# `AbstractModel` or `TransientModel` needed to make ACL check happy` +class FakeNonPubModel(models.TransientModel): + _name = 'fake.non.publishable' + name = fields.Char() + + +class FakeNonPubModelForm(models.AbstractModel): + _name = 'cms.form.fake.non.publishable' + _inherit = 'cms.form' + _form_model = 'fake.non.publishable' + _form_model_fields = ('name', ) diff --git a/cms_form/tests/test_controllers.py b/cms_form/tests/test_controllers.py index fcb820113..b7e843503 100644 --- a/cms_form/tests/test_controllers.py +++ b/cms_form/tests/test_controllers.py @@ -30,10 +30,10 @@ def setUp(self): self.authenticate('admin', 'admin') @contextmanager - def mock_assets(self): + def mock_assets(self, req=None): """Mocks some stuff like request.""" with mock.patch('%s.request' % IMPORT) as request: - faked = fake_request() + faked = req or fake_request() request.session = self.session request.env = self.env request.httprequest = faked.httprequest @@ -41,6 +41,61 @@ def mock_assets(self): 'request': request, } + def test_get_template(self): + with self.mock_assets(): + form = self.form_controller.get_form('res.partner') + # default + self.assertEqual( + self.form_controller.get_template(form), + 'cms_form.form_wrapper' + ) + # custom on form + form.form_wrapper_template = 'foo.baz' + self.assertEqual( + self.form_controller.get_template(form), + 'foo.baz' + ) + self.form_controller.template = None + form.form_wrapper_template = None + with self.assertRaises(NotImplementedError): + self.form_controller.get_template(form) + + def test_get_render_values(self): + with self.mock_assets(): + form = self.form_controller.get_form('res.partner') + # default, no main object + self.assertEqual( + self.form_controller.get_render_values(form), + { + 'form': form, + 'main_object': self.env['res.partner'], + 'controller': self.form_controller, + } + ) + # get a main obj + partner = self.env.ref('base.res_partner_12') + form = self.form_controller.get_form( + 'res.partner', model_id=partner.id) + self.assertEqual( + self.form_controller.get_render_values(form), + { + 'form': form, + 'main_object': partner, + 'controller': self.form_controller, + } + ) + # strip out form fields values (they are held by the form itself) + self.assertEqual( + self.form_controller.get_render_values( + form, name='John', custom='foo', not_a_form_field=1), + { + 'form': form, + 'main_object': partner, + 'controller': self.form_controller, + 'not_a_form_field': 1 + } + ) + def test_get_no_form(self): with self.mock_assets(): # we do not have a specific form for res.groups @@ -48,6 +103,14 @@ def test_get_no_form(self): with self.assertRaises(NotImplementedError): self.form_controller.get_form('res.groups') + def test_get_form_no_model_no_main_object(self): + with self.mock_assets(): + form = self.form_controller.get_form( + None, form_model_key=FakePartnerForm._name) + self.assertEqual( + form.main_object, self.env[FakePartnerForm._name] + ) + def test_get_default_form(self): with self.mock_assets(): # we have one for res.partner @@ -80,6 +143,22 @@ def test_get_wizard_form(self): self.assertEqual(form._form_model, 'res.partner') self.assertEqual(form.form_mode, 'create') + def test_redirect_after_success(self): + req = fake_request( + form_data={'name': 'John'}, + method='POST', + ) + with self.mock_assets(req=req): + partner = self.env.ref('base.res_partner_12') + response = self.form_controller.make_response( + 'res.partner', model_id=partner.id) + self.assertEqual(response.status_code, 303) + if 'website_url' in partner: + # website_partner installed + self.assertEqual(response.location, partner.website_url) + else: + self.assertEqual(response.location, '/') + def _check_rendering(self, dom, form_model, model, mode, extra_klass=''): """Check default markup for form and form wrapper.""" # test wrapper klass diff --git a/cms_form/tests/test_form_base.py b/cms_form/tests/test_form_base.py index 8f29def55..92dd096b5 100644 --- a/cms_form/tests/test_form_base.py +++ b/cms_form/tests/test_form_base.py @@ -353,7 +353,6 @@ def test_extract_from_request_no_value(self): def test_extract_from_request_custom_extractor(self): # test custom extractor integration w/ form_extract_values - form = self.get_form('cms.form.test_fields') # values from request data = { 'a_char': 'Jack White', @@ -385,6 +384,95 @@ def custom_extractor(form, fname, value, **request_values): for k, v in values.items(): self.assertEqual(expected[k], v) + def test_form_process_GET(self): + form = self.get_form('cms.form.test_fields') + self.assertEqual( + form.form_render_values, { + 'main_object': None, + 'form': form, + 'form_data': {}, + 'errors': {}, + 'errors_messages': {}, + } + ) + with mock.patch.object(type(form), 'form_process_GET') as handler: + handler.return_value = { + 'extra_key1': 'foo', + 'extra_key2': 'baz', + } + form.form_process() + # the right process method has been called + handler.assert_called() + # and got called w/ the right render values + default_form_data = { + 'a_char': None, + 'a_float': None, + 'a_many2many': '[]', + 'a_many2one': None, + 'a_number': None, + 'a_one2many': '[]' + } + expected = { + 'main_object': None, + 'form': form, + 'form_data': default_form_data, + 'errors': {}, + 'errors_messages': {}, + } + handler.assert_called_with(expected) + # and the result of the handler is injected into render values + self.assertEqual(form.form_render_values, { + # extra args have been injected + 'extra_key1': 'foo', + 'extra_key2': 'baz', + 'main_object': None, + 'form': form, + 'form_data': default_form_data, + 'errors': {}, + 'errors_messages': {}, + }) + + def test_form_process_POST(self): + request = fake_request(method='POST') + form = self.get_form('cms.form.test_fields', req=request) + with mock.patch.object(type(form), 'form_process_POST') as handler: + handler.return_value = { + 'extra_key3': 'foo', + 'extra_key4': 'baz', + } + form.form_process() + # the right process method has been called + handler.assert_called() + # and got called w/ the right render values + default_form_data = { + 'a_char': None, + 'a_float': None, + 'a_many2many': '[]', + 'a_many2one': None, + 'a_number': None, + 'a_one2many': '[]' + } + expected = { + 'main_object': None, + 'form': form, + 'form_data': default_form_data, + 'errors': {}, + 'errors_messages': {}, + } + handler.assert_called_with(expected) + # self.assert_nested_dict_equal(call_args, expected) + # and the result of the handler is injected into render values + self.assertEqual(form.form_render_values, { + # extra args have been injected + 'extra_key3': 'foo', + 'extra_key4': 'baz', + 'main_object': None, + 'form': form, + 'form_data': default_form_data, + 'errors': {}, + 'errors_messages': {}, + }) + def test_get_widget(self): form = self.get_form('cms.form.test_fields') expected = { diff --git a/cms_form/tests/test_form_cms.py b/cms_form/tests/test_form_cms.py index 849d1b207..67f9ccdc2 100644 --- a/cms_form/tests/test_form_cms.py +++ b/cms_form/tests/test_form_cms.py @@ -2,16 +2,29 @@ # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). import mock +from odoo import exceptions from odoo.tools import mute_logger from .common import FormTestCase from .utils import fake_request -from .fake_models import FakePartnerForm, FakeFieldsForm +from .fake_models import ( + FakePartnerForm, + FakeFieldsForm, + FakePartnerChannelForm, + FakePubModel, + FakePubModelForm, +) class TestCMSForm(FormTestCase): - TEST_MODELS_KLASSES = [FakePartnerForm, FakeFieldsForm] + TEST_MODELS_KLASSES = [ + FakePartnerForm, + FakeFieldsForm, + FakePartnerChannelForm, + FakePubModel, + FakePubModelForm, + ] @classmethod def setUpClass(cls): @@ -23,6 +36,94 @@ def tearDownClass(cls): cls._teardown_models() super(TestCMSForm, cls).tearDownClass() + def test_form_base_attrs(self): + form = self.get_form('cms.form.test_fields') + # no form model, no default title + self.assertEqual(form.form_title, '') + form = self.get_form('cms.form.res.partner') + # form model present, title now depends on mode and model descr + # no record to edit, create mode on + self.assertEqual(form.form_mode, 'create') + self.assertEqual(form.form_title, 'Create Contact') + # now edit a record + partner = self.env.ref('base.main_partner') + form = self.get_form('cms.form.res.partner', main_object=partner) + self.assertEqual(form.form_mode, 'edit') + self.assertEqual(form.form_title, 'Edit "%s"' % partner.name) + # now edit a record that has a m2o as rec name + partner.name = 'Johnny' + partner_channel = self.env['mail.channel.partner'].create({ + 'partner_id': partner.id, + }) + form = self.get_form( + 'cms.form.mail.channel.partner', + main_object=partner_channel + ) + self.assertEqual(form.form_title, 'Edit "Johnny"') + + def test_form_special_attrs_getter_setter(self): + form = self.get_form('cms.form.test_fields') + # submit success flag + self.assertFalse(form.form_success) + form.form_success = True + self.assertTrue(form.form_success) + # submit redirect flag + self.assertFalse(form.form_redirect) + form.form_redirect = True + self.assertTrue(form.form_redirect) + + def test_next_url(self): + # no record, no redirrect param, default to root + form = self.get_form('cms.form.res.partner') + self.assertEqual(form.form_next_url(), '/') + # redirect param in request + request = fake_request(query_string='redirect=/foo') + form = self.get_form( + 'cms.form.res.partner', + req=request) + self.assertEqual(form.form_next_url(), '/foo') + # edit a record: get to its ws URL + record = self.env['fake.publishable'].create({'name': 'Baz'}) + form = self.get_form( + 'cms.form.fake.publishable', + main_object=record + ) + self.assertEqual(form.form_next_url(), '/publishable/%d' % record.id) + # edit a record that has an URL but got redirect in request + request = fake_request(query_string='redirect=/sorry/go/here') + form = self.get_form( + 'cms.form.fake.publishable', + req=request, + main_object=record, + ) + self.assertEqual(form.form_next_url(), '/sorry/go/here') + + def test_cancel_url(self): + # no record, no redirrect param, default to root + form = self.get_form('cms.form.res.partner') + self.assertEqual(form.form_cancel_url(), '/') + # redirect param in request + request = fake_request(query_string='redirect=/foo') + form = self.get_form( + 'cms.form.res.partner', + req=request) + self.assertEqual(form.form_cancel_url(), '/foo') + # edit a record: get to its ws URL + record = self.env['fake.publishable'].create({'name': 'Baz'}) + form = self.get_form( + 'cms.form.fake.publishable', + main_object=record + ) + self.assertEqual(form.form_cancel_url(), '/publishable/%d' % record.id) + # edit a record that has an URL but got redirect in request + request = fake_request(query_string='redirect=/sorry/go/here') + form = self.get_form( + 'cms.form.fake.publishable', + req=request, + main_object=record, + ) + self.assertEqual(form.form_cancel_url(), '/sorry/go/here') + def test_validate(self): form = self.get_form('cms.form.test_fields') # values from request @@ -40,10 +141,16 @@ def test_validate(self): form = self.get_form( 'cms.form.test_fields', req=request, required_fields=required) errors, errors_message = form.form_validate() - self.assertTrue('a_char' in errors) - self.assertTrue('a_float' in errors) - self.assertTrue('a_many2one' in errors) - self.assertTrue('a_many2many' in errors) + self.assertEqual(errors, { + 'a_char': True, + 'a_float': True, + 'a_many2many': 'missing', + 'a_many2one': 'missing', + }) + self.assertEqual(errors_message, { + 'a_char': 'Text length must be greater than 8!', + 'a_float': 'Must be greater than 5!', + }) def test_create_or_update(self): # create @@ -55,7 +162,8 @@ def test_create_or_update(self): 'cms.form.res.partner', req=request, required_fields=('name', )) - main_object = form.form_create_or_update() + form.form_process() + main_object = form.main_object self.assertEqual(main_object._name, 'res.partner') self.assertEqual(main_object.name, data['name']) # update @@ -68,8 +176,9 @@ def test_create_or_update(self): form = self.get_form( 'cms.form.res.partner', req=request, + main_object=main_object, required_fields=('name', )) - main_object = form.form_create_or_update() + form.form_process() self.assertEqual(main_object.name, data['name']) self.assertEqual(main_object.country_id.id, data['country_id']) @@ -80,11 +189,33 @@ def test_create_or_update_with_errors(self): req=request) with mute_logger('odoo.sql_db'): values = form.form_process_POST({}) + self.assertFalse(form.form_success) self.assertTrue( # custom modules can provide different errors for constraints '_integrity' in values['errors'] \ or '_validation' in values['errors'] ) + with mock.patch.object(type(form), 'form_create_or_update') as mocked: + random_msg = ( + 'Error while validating constraint\n' + '\nEnd Date cannot be set before Start Date.\nNone' + ) + mocked.side_effect = exceptions.ValidationError(random_msg) + values = form.form_process_POST({}) + # validation error flag + self.assertEqual(values['errors'], {'_validation': True}) + # formatted error message + self.assertEqual( + values['errors_message'], { + '_validation': 'Error while validating constraint' + '
' + 'End Date cannot be set before Start Date.' + } + ) + + def test_purge_non_model_fields_no_model(self): + form = self.get_form('cms.form.test_fields') + self.assertEqual(form._form_purge_non_model_fields({}), {}) def test_purge_non_model_fields(self): data = { diff --git a/cms_form/tests/test_form_permission.py b/cms_form/tests/test_form_permission.py index b9a8a3652..4d1c04446 100644 --- a/cms_form/tests/test_form_permission.py +++ b/cms_form/tests/test_form_permission.py @@ -6,8 +6,10 @@ from .common import FormTestCase from .fake_models import ( - FakePublishModel, - FakePublishModelForm, + FakePubModel, + FakePubModelForm, + FakeNonPubModel, + FakeNonPubModelForm, FakePartnerForm, ) @@ -15,8 +17,10 @@ class TestFormPermCheck(FormTestCase): TEST_MODELS_KLASSES = [ - FakePublishModel, - FakePublishModelForm, + FakePubModel, + FakePubModelForm, + FakeNonPubModel, + FakeNonPubModelForm, FakePartnerForm, ] @@ -24,7 +28,7 @@ class TestFormPermCheck(FormTestCase): def setUpClass(cls): super().setUpClass() cls._setup_models() - cls.record = cls.env[FakePublishModel._name].create({'name': 'Foo'}) + cls.record = cls.env[FakePubModel._name].create({'name': 'Foo'}) @classmethod def tearDownClass(cls): @@ -36,7 +40,7 @@ def tearDownClass(cls): def test_form_check_permission_can_create(self): form = self.get_form( - FakePublishModelForm._name, main_object=None) + FakePubModelForm._name, main_object=None) with mock.patch(self.mixin_path + '.cms_can_create') as patched: patched.return_value = True self.assertTrue(form.form_check_permission()) @@ -44,7 +48,7 @@ def test_form_check_permission_can_create(self): def test_form_check_permission_cannot_create(self): form = self.get_form( - FakePublishModelForm._name, main_object=None) + FakePubModelForm._name, main_object=None) with mock.patch(self.mixin_path + '.cms_can_create') as patched: patched.return_value = False try: @@ -52,12 +56,12 @@ def test_form_check_permission_cannot_create(self): except exceptions.AccessError as err: patched.assert_called() msg = ('You are not allowed to create any record ' - 'for the model `%s`.') % FakePublishModel._name + 'for the model `%s`.') % FakePubModel._name self.assertEqual(err.name, msg) def test_form_check_permission_can_edit(self): form = self.get_form( - FakePublishModelForm._name, + FakePubModelForm._name, main_object=self.record) with mock.patch(self.mixin_path + '.cms_can_edit') as patched: patched.return_value = True @@ -66,7 +70,7 @@ def test_form_check_permission_can_edit(self): def test_form_check_permission_cannot_edit(self): form = self.get_form( - FakePublishModelForm._name, main_object=self.record) + FakePubModelForm._name, main_object=self.record) with mock.patch(self.mixin_path + '.cms_can_edit') as patched: patched.return_value = False try: @@ -79,12 +83,12 @@ def test_form_check_permission_cannot_edit(self): self.assertEqual(err.name, msg) def test_form_check_permission_no_ws_mixin_can_create(self): - form = self.get_form(FakePartnerForm._name, main_object=None) + form = self.get_form(FakeNonPubModelForm._name, main_object=None) self.assertTrue(form.form_check_permission()) def test_form_check_permission_no_ws_mixin_can_edit(self): - partner = self.env['res.partner'].search([], limit=1) - form = self.get_form(FakePartnerForm._name, main_object=partner) + rec = self.env[FakeNonPubModel._name].create({'name': 'Foo'}) + form = self.get_form(FakeNonPubModelForm._name, main_object=rec) self.assertTrue(form.form_check_permission()) def test_form_check_permission_no_record_no_model_can_edit_create(self): diff --git a/cms_form/tests/test_form_search.py b/cms_form/tests/test_form_search.py index ed0efdd3e..f6b78b131 100644 --- a/cms_form/tests/test_form_search.py +++ b/cms_form/tests/test_form_search.py @@ -6,6 +6,7 @@ from .fake_models import ( FakePartnerForm, FakeSearchPartnerForm, FakeSearchPartnerFormMulti ) +import mock class TestCMSSearchForm(FormTestCase): @@ -69,6 +70,80 @@ def get_search_form( form.test_record_ids = self.expected_partners_ids return form + def test_form_base_attrs(self): + form = self.get_search_form({}) + self.assertEqual(form.form_mode, 'search') + self.assertEqual(form.form_title, 'Search Contact') + + def test_search_domain(self): + form = self.get_search_form({}) + form.test_record_ids = [] + form._form_search_domain_rules = { + 'float_field': lambda field, value, search_values: ( + 'float_field', '>', value + ) + } + + def mock_fields(form): + return { + 'char_field': { + 'type': 'char', + }, + 'text_field': { + 'type': 'text', + }, + 'int_field': { + 'type': 'integer', + }, + 'float_field': { + 'type': 'float', + }, + 'm2o_field': { + 'type': 'many2one', + }, + 'bool_field': { + 'type': 'boolean', + }, + 'date_field': { + 'type': 'date', + }, + 'datetime_field': { + 'type': 'date', + }, + 'o2m_field': { + 'type': 'one2many', + }, + 'm2m_field': { + 'type': 'many2many', + }, + } + with mock.patch.object(type(form), 'form_fields', mock_fields): + search_values = { + 'char_field': 'foo', + 'text_field': '', + 'int_field': 2, + 'float_field': 1.0, + 'm2o_field': 3, + 'bool_field': 'on', + 'date_field': '2019-01-26', + 'datetime_field': '', + 'o2m_field': [1, 2, 3], + 'm2m_field': '', + } + expected = [ + ('char_field', 'ilike', '%foo%'), + ('int_field', '=', 2), + ('float_field', '>', 1.0), + ('m2o_field', '=', 3), + ('bool_field', '=', True), + ('date_field', '=', '2019-01-26'), + ('o2m_field', 'in', [1, 2, 3]), + ] + self.assertEqual( + sorted(form.form_search_domain(search_values)), + sorted(expected), + ) + def test_search(self): data = {'name': 'Salmo', } form = self.get_search_form(data) @@ -95,6 +170,51 @@ def test_search(self): form.form_process() self.assert_results(form, 1, self.expected_partners[4:]) + def test_search_no_result(self): + form = self.get_search_form({}, show_results_no_submit=False) + form.form_process() + self.assertEqual(form.form_search_results, {}) + + def test_pager_url(self): + data = {'name': 'Salmo', } + form = self.get_search_form(data) + # value from rendering + render_values = {'extra_args': {'pager_url': '/foo'}} + self.assertEqual( + form._form_get_url_for_pager(render_values), + '/foo' + ) + # value from model's `cms_search_url` if available + form = self.get_search_form(data) + # add fake cms_search_url on current model + # NOTE: when website_partner is installed this mocking is useless + # but we need it here just to demonstrate we are handling it + with mock.patch.object( + type(form.form_model), + 'cms_search_url', + property(lambda x: '/cms/search/res.partner'), + create=True, + ): + self.assertEqual( + form._form_get_url_for_pager({}), + '/cms/search/res.partner' + ) + # value from request path + request = fake_request(url='/search/custom/page/1?foo=baz') + form = self.get_form('cms.form.search.res.partner', req=request) + # on the contrary, here, we make sure `cms_search_url` is empty + # so that the default via request path is used + with mock.patch.object( + type(form.form_model), + 'cms_search_url', + property(lambda x: ''), + create=True, + ): + self.assertEqual( + form._form_get_url_for_pager({}), + '/search/custom' + ) + def test_search_multi(self): countries = [ self.env.ref('base.it').id, diff --git a/cms_form/tests/test_form_wizard.py b/cms_form/tests/test_form_wizard.py index 94d517d0c..9787fc726 100644 --- a/cms_form/tests/test_form_wizard.py +++ b/cms_form/tests/test_form_wizard.py @@ -6,6 +6,7 @@ from .fake_models import ( FakeWiz, FakeWizStep1Country, FakeWizStep2Partner, FakeWizStep3Partner, + FAKE_STORAGE, ) @@ -26,6 +27,72 @@ def tearDownClass(cls): cls._teardown_models() super().tearDownClass() + def tearDown(self): + FAKE_STORAGE.clear() + super().tearDown() + + def test_wiz_base_attrs(self): + form = self.get_form(FakeWizStep1Country._name) + # check wrapper class override + self.assertEqual( + form.form_wrapper_css_klass, + # wrapper klass, normalized wizard step model + 'cms_form_wrapper fake_wiz_step1_country ' + # normalized model, mode, normalized wizard model + 'res_country mode_wizard fake_wiz' + ) + # check default storage key + self.assertEqual(form._wiz_storage_key, 'fake.wiz') + # check default storage + self.assertEqual( + form._wiz_storage, { + 'fake.wiz': { + 'steps': {1: {}, 2: {}, 3: {}}, + 'current': 1, + 'next': 2, + 'prev': None + } + } + ) + self.assertEqual(form.wiz_steps, [1, 2, 3]) + + def test_wiz_use_session_by_default(self): + req = fake_request(session=self.session) + form = self.get_form('cms.form.wizard', req=req) + self.assertEqual( + form._wiz_storage.__class__.__name__, 'OpenERPSession') + + def test_wiz_configure_steps(self): + form = self.get_form('cms.form.wizard') + self.assertEqual(form.wiz_configure_steps(), {}) + + def test_wiz_step_info(self): + form = self.get_form(FakeWizStep1Country._name) + with self.assertRaises(ValueError) as err: + form.wiz_get_step_info(100) + self.assertEqual(str(err.exception), 'Step `100` does not exists.') + self.assertEqual( + form.wiz_get_step_info(1), {'form_model': 'fake.wiz.step1.country'} + ) + self.assertEqual( + form.wiz_get_step_info(2), {'form_model': 'fake.wiz.step2.partner'} + ) + self.assertEqual( + form.wiz_get_step_info(3), {'form_model': 'fake.wiz.step3.partner'} + ) + + def test_wiz_save_step(self): + form = self.get_form(FakeWizStep1Country._name) + # no step passed, use current one (1) + form.wiz_save_step({'foo': 'baz'}) + self.assertEqual(form.wiz_load_step(1), {'foo': 'baz'}) + form.wiz_save_step({'boo': 'waz'}, step=3) + self.assertEqual(form.wiz_load_step(3), {'boo': 'waz'}) + # corner case whereas a step in the storage has been removed + form.wiz_storage_get()['steps'].pop(2) + form.wiz_save_step({'get': 'back'}, step=2) + self.assertEqual(form.wiz_load_step(2), {'get': 'back'}) + def test_wiz_init(self): form = self.get_form(FakeWizStep1Country._name) self.assertEqual(form.wiz_storage_get()['current'], 1) @@ -33,6 +100,12 @@ def test_wiz_init(self): self.assertEqual(form.wiz_storage_get()['prev'], None) self.assertEqual(len(form.wiz_storage_get()['steps']), 3) + def test_wiz_init_from_another_page(self): + form = self.get_form(FakeWizStep1Country._name, page=2) + self.assertEqual(form.wiz_storage_get()['current'], 2) + self.assertEqual(form.wiz_storage_get()['next'], 3) + self.assertEqual(form.wiz_storage_get()['prev'], 1) + def test_wiz_next_prev1(self): form = self.get_form(FakeWizStep1Country._name) self.assertEqual(form.wiz_prev_step(), None) diff --git a/cms_form/tests/test_marshallers.py b/cms_form/tests/test_marshallers.py index 8fe7aa110..ce7dab80b 100644 --- a/cms_form/tests/test_marshallers.py +++ b/cms_form/tests/test_marshallers.py @@ -19,6 +19,13 @@ def test_plain_values(self): self.assertEqual(marshalled['b'], '2') self.assertEqual(marshalled['c'], '3') + def test_skip_csrf_token(self): + data = MultiDict([ + ('csrf_token', 'whatever'), + ]) + marshalled = marshallers.marshal_request_values(data) + self.assertEqual(marshalled, {}) + def test_marshal_list(self): data = MultiDict([ ('a', '1'), diff --git a/cms_form/tests/utils.py b/cms_form/tests/utils.py index 8d6f6ec33..4a38b68e3 100644 --- a/cms_form/tests/utils.py +++ b/cms_form/tests/utils.py @@ -14,13 +14,14 @@ from odoo.tests.common import get_db_name -def fake_request(form_data=None, query_string=None, +def fake_request(form_data=None, query_string=None, url='/fake/path', method='GET', content_type=None, session=None): data = urllib.parse.urlencode(form_data or {}) query_string = query_string or '' content_type = content_type or 'application/x-www-form-urlencoded' # werkzeug request w_req = Request.from_values( + url, query_string=query_string, content_length=len(data), input_stream=io.StringIO(data), diff --git a/cms_form/tests/widgets/test_widget_binary.py b/cms_form/tests/widgets/test_widget_binary.py index 517eee05f..3995a3f52 100644 --- a/cms_form/tests/widgets/test_widget_binary.py +++ b/cms_form/tests/widgets/test_widget_binary.py @@ -157,3 +157,52 @@ def test_widget_binary_extract_filestorage(self): self.assertEqual( widget.w_extract(image=req_val, image_keepcheck='no'), TEST_IMAGE_JPG) + + def test_widget_binary_check_empty(self): + w_name, w_field = fake_field( + 'image', type='binary', + ) + widget = self.get_widget(w_name, w_field, form=self.form, + widget_model='cms.form.widget.image') + + # no value at all -> empty + self.assertIs(widget.w_check_empty_value(''), True) + self.assertIs(widget.w_check_empty_value(False), True) + # behavior w/ file value + with b64_as_stream(TEST_IMAGE_JPG) as stream: + req_val = fake_file_from_request( + 'image', stream=stream, + content_type='image/jpeg' + ) + # no filename -> empty + self.assertIs(widget.w_check_empty_value(req_val), True) + # no filename and keep flag -> empty, since we want to preserve + self.assertIs( + widget.w_check_empty_value(req_val, image_keepcheck='yes'), + False + ) + req_val = fake_file_from_request( + 'image', stream=stream, + filename='foo.jpg', content_type='image/jpeg' + ) + # got file w/ filename and no keep flag -> not empty + self.assertIs(widget.w_check_empty_value(req_val), False) + req_val = fake_file_from_request( + 'image', stream=stream, + filename='foo.jpg', content_type='image/jpeg' + ) + # got file w/ filename and yes keep flag -> not empty + self.assertIs( + widget.w_check_empty_value(req_val, image_keepcheck='yes'), + False + ) + # got file w/ filename and no keep flag -> not empty + self.assertIs( + widget.w_check_empty_value(req_val, image_keepcheck='no'), + False + ) + # got file w/ filename and yes keep flag -> not empty + self.assertIs( + widget.w_check_empty_value(req_val, image_keepcheck='yes'), + False + ) From 35c6a7c722f91c30c5d6584fda6c55e8064829ad Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 16 Jan 2019 16:49:12 +0100 Subject: [PATCH 124/238] [REF] cms.form.widget.date: use BS datepicker, handle lang * use bootstrap-datetimepicker which will allow to reuse most of the same code for a datetime widget in the future. * default to current language date format * allow override of date format and placeholder via widget kwargs --- cms_form/models/widgets/widget_date.py | 13 ++-- cms_form/models/widgets/widget_mixin.py | 5 +- cms_form/static/src/js/date_widget.js | 72 +++++++++++++++++----- cms_form/templates/widgets.xml | 14 +++-- cms_form/tests/widgets/test_widget_date.py | 68 +++++++++++++++----- 5 files changed, 128 insertions(+), 44 deletions(-) diff --git a/cms_form/models/widgets/widget_date.py b/cms_form/models/widgets/widget_date.py index 43b392b29..b037c39a2 100644 --- a/cms_form/models/widgets/widget_date.py +++ b/cms_form/models/widgets/widget_date.py @@ -2,7 +2,6 @@ # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). from odoo import models - from ... import utils @@ -12,15 +11,20 @@ class DateWidget(models.AbstractModel): _inherit = 'cms.form.widget.mixin' _w_template = 'cms_form.field_widget_date' - # TODO: allow customization of date format - # TODO: adopt this attr to control placeholders on all widgets - w_placeholder = 'YYYY-MM-DD' + # Both default to current lang format. + w_placeholder = '' + w_date_format = '' def widget_init(self, form, fname, field, **kw): widget = super().widget_init(form, fname, field, **kw) if 'defaultToday' not in widget.w_data: # set today's date by default widget.w_data['defaultToday'] = True + if kw.get('format', widget.w_date_format): + widget.w_data['dp'] = { + 'format': kw.get('format', widget.w_date_format) + } + widget.w_placeholder = kw.get('placeholder', widget.w_placeholder) return widget def w_extract(self, **req_values): @@ -28,5 +32,4 @@ def w_extract(self, **req_values): return self.form_to_date(value, **req_values) def form_to_date(self, value, **req_values): - # TODO: should be validated by current format return utils.safe_to_date(value) diff --git a/cms_form/models/widgets/widget_mixin.py b/cms_form/models/widgets/widget_mixin.py index 58dd16bb8..fac26a9fb 100644 --- a/cms_form/models/widgets/widget_mixin.py +++ b/cms_form/models/widgets/widget_mixin.py @@ -13,7 +13,8 @@ class Widget(models.AbstractModel): _w_css_klass = '' def widget_init(self, form, fname, field, - data=None, subfields=None, template='', css_klass=''): + data=None, subfields=None, + template='', css_klass='', **kw): widget = self.new() widget.w_form = form widget.w_form_model = form.form_model @@ -68,4 +69,4 @@ def w_subfields_by_value(self, value='_all'): return self.w_subfields.get(value, {}) def w_data_json(self): - return json.dumps(self.w_data) + return json.dumps(self.w_data, sort_keys=True) diff --git a/cms_form/static/src/js/date_widget.js b/cms_form/static/src/js/date_widget.js index 79c96779f..0ed233e78 100644 --- a/cms_form/static/src/js/date_widget.js +++ b/cms_form/static/src/js/date_widget.js @@ -2,56 +2,96 @@ odoo.define('cms_form.date_widget', function (require) { 'use strict'; var sAnimation = require('website.content.snippets.animation'); + var time_utils = require('web.time'); sAnimation.registry.CMSDateWidget = sAnimation.Class.extend({ selector: ".cms_form_wrapper form input.js_datepicker", start: function () { + // The datepicker is attached to $fname_display field. + // The real value is held by the real field input (hidden). + this.$realField = this.$el.next( + '#' + this.$el.attr('name').replace('_display', '') + ); this.load_options(); this.setup_datepicker(); }, load_options: function() { - this.options = _.defaults( - this.$el.data('params') || {}, { - useSeconds: false, + // global options + this.options = _.omit(this.$el.data('params'), 'dp'); + // datepicker specific ones + this.picker_options = _.defaults( + // You can pass datepicker specific options via `dp` attr in params. + // We don't pass them via `params` otherwise the picker + // will fail on it if non recognized params are there. + this.$el.data('params').dp || {}, + { icons: { time: 'fa fa-clock-o', date: 'fa fa-calendar', up: 'fa fa-chevron-up', down: 'fa fa-chevron-down' }, - // python = YYYY-MM-DD - dateFormat: 'yy-mm-dd' + locale: moment.locale(), + format: time_utils.getLangDateFormat(), + useCurrent: false } ) }, setup_datepicker: function() { var self = this; - this.$el.datepicker(this.options); - this.set_default_date(); + var placeholder = this.$el.attr('placeholder'); + // placeholder empty: set default via lang format + // plahoholder not defined: leave it not set + if (!placeholder && !_.isUndefined(placeholder)) { + /* TODO: we should make this translatable. Example: + + lang format in French is `DD.MM.YYYY` + what the user wants to see is `JJ.MM.AAAA` + + As workaround you can define the placeholder as widget attribute + and translate it. + */ + this.$el.attr('placeholder', time_utils.getLangDateFormat()); + } + // init bootstrap-datetimepicker + this.$el.datetimepicker(this.picker_options); + this.picker = this.$el.data('DateTimePicker'); + this.$el.on('dp.change', function(e){ + // Update real value field. + // WARNING: this format should not be touched, it matches server side. + var real_val = ''; + // e.date is false when no value is set + if (e.date) { + real_val = e.date.format('YYYY-MM-DD'); + } + self.$realField.val(real_val); + }); + this._init_date(); + // enable calendar icon trigger this.$el .closest('.input-group') .find('.js_datepicker_trigger') .click(function () { - self.$el.datepicker('show'); + self.picker.show(); }); }, - set_default_date: function () { + _init_date: function () { var self = this; - var defaultDate = self.options.defaultDate; + // retrieve current date from real field + var defaultDate = self.$realField.val(); if (defaultDate) { - // use custom date defaultDate = new Date(defaultDate); } else if (self.options.defaultToday) { - // unless blocked go for today date defaultDate = new Date(); } if (defaultDate && !self.$el.val()) { - self.$el.datepicker( - "setDate", defaultDate - ); + this.picker.date(defaultDate) } + }, + destroy: function() { + this.picker.destroy(); + this._super.apply(this, arguments); } - }); }); diff --git a/cms_form/templates/widgets.xml b/cms_form/templates/widgets.xml index 4ceb1d107..a6a1431d8 100644 --- a/cms_form/templates/widgets.xml +++ b/cms_form/templates/widgets.xml @@ -123,17 +123,21 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). diff --git a/cms_form/templates/form.xml b/cms_form/templates/form.xml index 1179ffee0..239b021f6 100644 --- a/cms_form/templates/form.xml +++ b/cms_form/templates/form.xml @@ -147,7 +147,9 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + enctype="multipart/form-data" + t-att-data-ajax="form._form_ajax or None" + t-att-data-ajax-onchange="form._form_ajax_onchange or None"> @@ -198,7 +200,9 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). - +
+ +
@@ -218,6 +222,24 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + + +