diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a2e57182..f799c9e3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,6 @@ exclude: | (?x) # NOT INSTALLABLE ADDONS - ^cms_form/| ^cms_form_example/| # END NOT INSTALLABLE ADDONS # Files and folders generated by bots, to avoid loops diff --git a/cms_form/CHANGES.rst b/cms_form/CHANGES.rst deleted file mode 100644 index f484fdde..00000000 --- a/cms_form/CHANGES.rst +++ /dev/null @@ -1,343 +0,0 @@ -========= -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) -======================= - -**Fixes** - -* Date widget JS datepicker default date regression - - Make sure we use today date unless specified otherwise. - - -11.0.1.6.1 (2018-08-07) -======================= - -**Fixes** - -* Date widget JS datepicker options custom - - You can now override all the options of the datepicker via `data`. - For instance:: - - def form_get_widget(self, fname, field, **kw): - """Customize datepicker.""" - if fname == 'date': - kw['data'] = { - 'minDate': '2018-01-01' - } - return super().form_get_widget(fname, field, **kw) - - -11.0.1.6.0 (2018-07-25) -======================= - -**Improvements** - -* Add `:float` marshaller - - You can now use `$foo:float` as field name to cast value to float. - -* Hidden input respect field type value - - Hidden input values pass through requests as chars. - This means that any m2o or selection field with integer/float values - won't be really happy on create/write. - - Now we rely on request marshallers to convert those values - to correct values based on field type. - - NOTE: this is the preliminary step for adopting marshallers - for all field types/widgets when needed. - - -**Fixes** - -* Fix `safe_to_date` to make form extractor happy - - Form extractor ignores non required fields if their values is `None`. - In the case of the date field, the util was returning `False` - even if the value was not submitted, leading to an ORM error - whenever the missing field was required. - - Now we return `None` and let the extractor deal with proper values - and validation. - -**Coverage** - -* Test field wrapper rendering -* Test css klass methods -* Test `get_widget` -* Test conversion of no value -* Test fieldsets rendering - - Make sure fieldsets are not rendered if they have no fields. - -* Allow to skip HttpCase on demand - - Super-useful when you use pytest which does not support them. - -* Add basic tests for widget -* Add test for hidden widget -* Add test for char widget - - -11.0.1.5.2 (2018-07-12) -======================= - -**Fixes** - -* Fix ordering w/ `groups` protected fields - - If `groups` attribute was assigned to a field - it made fields ordering crash as the field is not there - when groups are not satisfied - -* Fix selection widget to handle integer values - - `fields.Selection` can hold both strings and integer values. - Till the value was not converted automatically - and using selection fields w/ integer values was a bit complex - as you had to convert it yourself or use a str selection field. - - Now the widget inspects selection options - and converts request value accordingly. - - -11.0.1.5.1 (2018-07-09) -======================= - -**Fixes** - -* Fix regression fields ordering + hidden - - When calling `form_fields` w/ hidden=True/False - the order of the fields was not respected anymore. - - This a regression from commit 56b37ca - - -11.0.1.5.0 (2018-07-06) -======================= - -**Improvements** - -* Handle hidden input automatically - - You can now specify `_form_fields_hidden = ('foo', )` - to get hidden inputs. All fields declared here - will be rendered as ``. - - -11.0.1.4.4 (2018-07-04) -======================= - -**Fixes** - -* Search form: fix default URL py3 compat - - -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) -======================= - -**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) -======================= - -**Docs** - -* Move documentation from README to `doc` folder - - -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) -======================= - -**Improvements** - -* 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) -======================= - -**Improvements** - -* Add wizard support to easily create custom wizards - - -11.0.1.2.1 (2018-04-13) -======================= - -**Fixes** - -* 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) -======================= - -**Improvements** - -* 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. - - -**Fixes** - -* 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) -======================= - -**Fixes** - -* Fix date widget: default today only if empty - - -11.0.1.1.0 (2018-03-26) -======================= - -**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** - -* 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) -======================= - -**Improvements** - -* Ease override of JSON info -* Add fieldsets support -* cms_form_example: add fieldsets forms - - -11.0.1.0.3 (2018-03-21) -======================= - -**Improvements** - -* Form controller: main_object defaults to empty recordset - -**Fixes** - -* Fix x2m widget value comparison -* Fix x2m widget load default value empt^^ diff --git a/cms_form/README.rst b/cms_form/README.rst index a8dd7f64..45c4137c 100644 --- a/cms_form/README.rst +++ b/cms_form/README.rst @@ -5,9 +5,9 @@ CMS Form ======== -Basic website contents form framework. Allows to define front-end forms for every models in a simple way. +Advanced 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, +If you are tired of re-defining every time an edit form or a search form for your odoo portal or website, this is the module you are looking for. Features diff --git a/cms_form/__manifest__.py b/cms_form/__manifest__.py index 48cb8575..18af6fda 100644 --- a/cms_form/__manifest__.py +++ b/cms_form/__manifest__.py @@ -5,17 +5,36 @@ "name": "CMS Form", "summary": """ Basic content type form""", - "version": "13.0.1.0.1", + "version": "16.0.1.0.0", "license": "LGPL-3", "author": "Camptocamp, Odoo Community Association (OCA)", - "mainainers": ["simahawk"], + "maintainers": ["simahawk"], "website": "https://github.com/OCA/website-cms", - "depends": ["website", "cms_info", "cms_status_message"], + "depends": [ + "cms_info", + "cms_status_message", + # TODO: get rid of portal too + "portal", + "base_sparse_field", + ], "data": [ "security/cms_form.xml", - "templates/assets.xml", "templates/form.xml", "templates/widgets.xml", + "templates/portal.xml", ], - "installable": False, + "installable": True, + "assets": { + "web.assets_frontend": [ + "cms_form/static/src/scss/cms_form.scss", + "cms_form/static/src/scss/progressbar.scss", + # TODO: review them all w/ modern JS + "cms_form/static/src/js/select2widgets.js", + "cms_form/static/src/js/date_widget.js", + "cms_form/static/src/js/textarea_widget.js", + "cms_form/static/src/js/master_slave.js", + "cms_form/static/src/js/lock_copy_paste.js", + "cms_form/static/src/js/ajax.js", + ], + }, } diff --git a/cms_form/controllers/main.py b/cms_form/controllers/main.py index 0f57ffd9..268589ae 100644 --- a/cms_form/controllers/main.py +++ b/cms_form/controllers/main.py @@ -12,7 +12,7 @@ class FormControllerMixin(object): # default template - template = "cms_form.form_wrapper" + template = "cms_form.portal_form_wrapper" def get_template(self, form, **kw): """Retrieve rendering template. @@ -21,10 +21,7 @@ def get_template(self, form, **kw): 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 + template = form.form_wrapper_template or self.template if not template: raise NotImplementedError("You must provide a template!") @@ -46,7 +43,7 @@ def get_render_values(self, form, **kw): # 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 = {k: v for k, v in kw.items() if k not in form.form_fields_get()} vals.update({"form": form, "main_object": main_object, "controller": self}) return vals @@ -136,14 +133,44 @@ class CMSFormController(http.Controller, FormControllerMixin): website=True, ) def cms_form(self, model, model_id=None, **kw): - """Handle a `form` route. - """ + """Handle a `form` route.""" return self.make_response(model, model_id=model_id, **kw) + @http.route( + [ + "/cms/render/form/", + "/cms/render/form//", + ], + type="json", + auth="user", + website=True, + # FIXME + csfr=False, + ) + def cms_form_render(self, form_model, model_id=None, **kw): + kw["form_model_key"] = form_model + data = request.get_json_data() + process_form = data.get("process", kw.pop("process", False)) + widget_params = data.get("widget_params", kw.pop("widget_params", {})) + if widget_params: + kw["form_model_fields"] = list(widget_params) + form = self.get_form(None, model_id=model_id, **kw) + form.form_check_permission() + if process_form: + form.form_process() + by_widget = {} + for fname, params in widget_params.items(): + widget = form.form_get_current_widget(fname) + widget.update(params) + by_widget[fname] = widget.render() + if by_widget: + return {"by_widget": by_widget} + return {"form": form.form_render()} + class WizardFormControllerMixin(FormControllerMixin): - template = "cms_form.wizard_form_wrapper" + template = "cms_form.portal_wizard_form_wrapper" def make_response(self, wiz_model, model_id=None, page=1, **kw): """Custom response. @@ -156,7 +183,7 @@ def make_response(self, wiz_model, model_id=None, page=1, **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 + model = request.env[form_model].form_model_name kw["form_model_key"] = form_model return super().make_response(model, model_id=model_id, page=page, **kw) @@ -171,14 +198,13 @@ class CMSWizardFormController(http.Controller, WizardFormControllerMixin): website=True, ) def cms_wiz(self, wiz_model, model_id=None, **kw): - """Handle a wizard route. - """ + """Handle a wizard route.""" return self.make_response(wiz_model, model_id=model_id, **kw) class SearchFormControllerMixin(FormControllerMixin): - template = "cms_form.search_form_wrapper" + template = "cms_form.portal_search_form_wrapper" def form_model_key(self, model, **kw): return "cms.form.search." + model @@ -199,8 +225,7 @@ class CMSSearchFormController(http.Controller, SearchFormControllerMixin): website=True, ) def cms_form(self, model, **kw): - """Handle a search `form` route. - """ + """Handle a search `form` route.""" response = self.make_response(model, **kw) return response @@ -218,8 +243,5 @@ def ajax(self, model, model_id=None, **kw): return self.make_response_ajax(model, **kw) def _make_response_ajax_content(self, response): - return ( - request.env.ref(response.qcontext["form"].form_search_results_template) - .render(response.qcontext) - .decode("utf8") - ) + template = response.qcontext["form"].form_search_results_template + return request.env["ir.qweb"]._render(template, response.qcontext) diff --git a/cms_form/doc/source/basics.rst b/cms_form/doc/source/basics.rst index c867111c..229ca58e 100644 --- a/cms_form/doc/source/basics.rst +++ b/cms_form/doc/source/basics.rst @@ -12,9 +12,9 @@ Just inherit from ``cms.form`` to add a form for your model. Quick example for p _name = 'cms.form.res.partner' _inherit = 'cms.form' - _form_model = 'res.partner' - _form_model_fields = ('name', 'country_id') - _form_required_fields = ('name', 'country_id') + 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: @@ -55,9 +55,9 @@ The form above can be extended with extra fields that are not part of the ``_for _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_model = 'res.partner' + form_model_fields = ('name', 'country_id', 'email') + form_required_fields = ('name', 'country_id', 'email') notify_partner = fields.Boolean() @@ -81,10 +81,10 @@ You want to group fields into meaningful groups. You can use fieldsets: _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 = [ + form_model = 'res.partner' + form_model_fields = ('name', 'country_id', 'email') + form_required_fields = ('name', 'country_id', 'email') + form_fieldsets = [ { 'id': 'main', 'title': 'Main', @@ -116,8 +116,8 @@ If you want fieldsets to be displayed as tabs, just override this option: _name = 'cms.form.res.partner' _inherit = 'cms.form' - _form_fieldsets = [...] - _form_fieldsets_display = 'tabs' + form_fieldsets = [...] + form_fieldsets_display = 'tabs' .. image:: ./_static/images/cms_form_example_tabbed.png @@ -135,9 +135,9 @@ Just inherit from ``cms.form.search`` to add a form for your model. Quick exampl _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', ) + form_model = 'res.partner' + form_model_fields = ('name', 'country_id', ) + form_fields_order = ('country_id', 'name', ) .. image:: ./_static/images/cms_form_example_search.png diff --git a/cms_form/doc/source/conf.py b/cms_form/doc/source/conf.py index bc334304..8fdc6dd8 100644 --- a/cms_form/doc/source/conf.py +++ b/cms_form/doc/source/conf.py @@ -19,7 +19,8 @@ # -- Project information ----------------------------------------------------- project = "Odoo CMS Form" -copyright = "2018, Simone Orsi" +# FIXME: builtin word. In any case move to GH pages! +# copyright = "2018, Simone Orsi" author = "Simone Orsi" # The short X.Y version diff --git a/cms_form/marshallers.py b/cms_form/marshallers.py index 3a8f8d4b..d78af851 100644 --- a/cms_form/marshallers.py +++ b/cms_form/marshallers.py @@ -1,116 +1,235 @@ # Copyright 2018 Simone Orsi # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import base64 import html +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any +import werkzeug -def marshal_request_values(values): - """Transform given request values using marshallers. - - Available marshallers: - - * `:int` transform to integer - * `:float` transform to float - * `: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 - if k.endswith(":esc"): - k, v = marshal_esc(values, k, v) - res[k] = v - 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 - if k.endswith(":float"): - k, v = marshal_float(values, k, v) - res[k] = v - continue - res[k] = v - return res - - -def marshal_esc(values, orig_key, orig_value): - """Transform `foo:esc` inputs to escaped value.""" - k = orig_key[: -len(":esc")] - v = html.escape(orig_value) - return k, v - - -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 +from odoo.tools import pycompat +from odoo.tools.mimetypes import guess_mimetype +from . import utils -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_request_values(values): + """Transform given request values using marshallers. -def marshal_float(values, orig_key, orig_value): - """Transform `foo:float` inputs to float values.""" - k = orig_key[: -len(":float")] - try: - v = float(orig_value.replace(",", ".")) - except (ValueError, TypeError): - v = 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 merged into a dict whereas 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', - } - + Available marshallers: see Marshaller class. """ - 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:dict:int -> get a dictionary w/ integer values - full_key, _, __ = _k.partition(":dict") - res[full_key.split(".")[-1]] = _v - return key, res + return Marshaller(values).marshall() + + +@dataclass +class Todo: + okey: str + oval: Any + handlers: list[Callable] + + +class Marshaller: + def __init__(self, req_values): + self.req_values = req_values + self.todos = [] + self.skip_keys = {"csrf_token"} + self._collect_todo() + + def _add_todo(self, orig_key, orig_value, *handlers): + self.todos.append(Todo(okey=orig_key, oval=orig_value, handlers=handlers)) + + def _collect_todo(self): + done = set() + for k, v in self.req_values.items(): + if k in self.skip_keys: + continue + for operator, handler in self._marshallers(): + if k.endswith(operator): + self._add_todo(k, v, handler) + done.add(k) + continue + # plain + if k not in done: + self._add_todo(k, v, self.marshal_plain) + done.add(k) + + def _marshallers(self): + # TODO: add docs + # TODO: support combinations like `:list:int` or `:dict:int` + return ( + (":esc", self.marshal_esc), + (":dict:list", self.marshal_dict_list), + (":list", self.marshal_list), + (":dict", self.marshal_dict), + (":int", self.marshal_int), + (":float", self.marshal_float), + (":file", self.marshal_file), + ) + + def marshall(self): + res = {} + for todo in self.todos: + k, v = todo.okey, todo.oval + for handler in todo.handlers: + k, v = handler(k, v) + res[k] = v + return res + + def marshal_plain(self, orig_key, orig_value): + """No transform.""" + return orig_key, orig_value + + def marshal_esc(self, orig_key, orig_value): + """Transform `foo:esc` inputs to escaped value.""" + k = orig_key[: -len(":esc")] + v = html.escape(orig_value) + return k, v + + def marshal_list(self, orig_key, orig_value): + """Transform `foo:list` inputs to list of values.""" + k = orig_key[: -len(":list")] + v = self.req_values.getlist(orig_key) + return k, v + + def marshal_int(self, orig_key, orig_value): + """Transform `foo:int` inputs to integer values.""" + k = orig_key[: -len(":int")] + return k, utils.safe_to_integer(orig_value) + + def marshal_float(self, orig_key, orig_value): + """Transform `foo:float` inputs to float values.""" + k = orig_key[: -len(":float")] + return k, utils.safe_to_float(orig_value) + + def marshal_dict(self, 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 merged into a dict whereas 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 self.req_values.items(): + # get all the keys matching fname + if not _k.startswith(key): + continue + # TODO: `__` will be to support extra marshallers, like: + # foo.1:dict:int -> get a dictionary w/ integer values + full_key, _, __ = _k.partition(":dict") + res[full_key.split(".")[-1]] = _v + return key, res + + def marshal_dict_list(self, orig_key, orig_value): + """Transform `foo:dict:list` inputs to list of dict values. + + `orig_key` must be formatted like: + + `$fname.$index.$dict_key:dict:list` + + Every request key matching `$fname` prefix + will be merged into a list of dicts whereas keys will match all `$dict_key` + and the position in the list will match $index. + + Example: + + values = [ + ("b.1.x:dict:list", "b1x"), + ("b.1.y:dict:list", "b1y"), + ("b.2.x:dict:list", "b2x"), + ("b.2.y:dict:list", "b2y"), + ] + + will be translated to: + + values['b'] = [ + { + 'x': 'b1x', + 'y': 'b1y', + }, + { + 'x': 'b2x', + 'y': 'b2y', + }, + } + + """ + res = [] + + def parse_key(key): + main_key, index, inner_key = key[: -len(":dict:list")].split(".") + if not index.isdigit(): + raise ValueError(":dict:list requires an integer index") + return main_key, int(index), inner_key + + main_key, index, inner_key = parse_key(orig_key) + by_index = {} + for _k, _v in self.req_values.items(): + # get all the keys matching fname + if not _k.startswith(f"{main_key}."): + continue + self.skip_keys.add(_k) + __, index, inner_key = parse_key(_k) + by_index.setdefault(index, []).append((inner_key, _v)) + # by_index = {0: [(x, xv), (y, yv)]} + for __, values in sorted(by_index.items()): + item = {} + for inner_key, value in values: + item[inner_key] = value + res.append(item) + return main_key, res + + def marshal_file(self, orig_key, orig_value): + k = orig_key[: -len(":file")] + value = orig_value + if isinstance(value, werkzeug.datastructures.FileStorage): + _value = self._filedata_from_filestorage(value) + else: + mimetype = guess_mimetype(value) + _value = { + "value": value, + "raw_value": value, + "mimetype": mimetype, + "content_type": mimetype, + } + _value["_from_request"] = True + return k, _value + + @staticmethod + def _filedata_from_filestorage(fs): + raw_value = fs.read() + value = base64.b64encode(raw_value) + value = pycompat.to_text(value) + data = dict(raw_value=value, value=value) + for attr in ( + "content_length", + "content_type", + "filename", + "headers", + "mimetype", + "mimetype_params", + ): + data[attr] = getattr(fs, attr) + return data diff --git a/cms_form/models/cms_form.py b/cms_form/models/cms_form.py index 9d322b57..52334348 100644 --- a/cms_form/models/cms_form.py +++ b/cms_form/models/cms_form.py @@ -1,9 +1,12 @@ # Copyright 2017 Simone Orsi # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). -from psycopg2 import IntegrityError -from odoo import _, exceptions, models +import psycopg2 as pg + +from odoo import _, exceptions, fields, models + +from .fields import Serialized class CMSForm(models.AbstractModel): @@ -11,25 +14,22 @@ class CMSForm(models.AbstractModel): _description = "CMS Form" _inherit = "cms.form.mixin" - # default validators by field type - _form_validators = { - # 'many2one': 'my_validation_method' - } - # internal flag for successful form - __form_success = False - - @property - def form_success(self): - return self.__form_success + form_success = fields.Boolean(form_tech=True, default=False) + # internal flag for turning on redirection + form_redirect = fields.Boolean(form_tech=True, default=False) + # default validators by field type + # 'many2one': 'my_validation_method' + form_validators = Serialized(form_tech=True, default={}) + # make it computed + form_title = fields.Char(form_tech=True, compute="_compute_form_title") - @form_success.setter - def form_success(self, value): - self.__form_success = value + def _compute_form_title(self): + for rec in self: + rec.form_title = rec._get_form_title() - @property - def form_title(self): - if not self._form_model: + def _get_form_title(self): + if not self.form_model_name: return "" if self.main_object: rec_field = self.main_object[self.form_model._rec_name] @@ -38,27 +38,16 @@ def form_title(self): title = _('Edit "%s"') % rec_field else: title = _("Create %s") - if self._form_model: + if self.form_model_name: model = ( self.env["ir.model"] .sudo() - .search([("model", "=", self._form_model)]) + .search([("model", "=", self.form_model_name)]) ) name = model and model.name or "" title = _("Create %s") % name return title - # internal flag for turning on redirection - __form_redirect = False - - @property - def form_redirect(self): - return self.__form_redirect - - @form_redirect.setter - def form_redirect(self, value): - self.__form_redirect = value - @property def form_msg_success_created(self): # TODO: include form model name if any @@ -79,8 +68,8 @@ def form_next_url(self, main_object=None): # redirect overridden return self.request.args.get("redirect") main_object = main_object or self.main_object - if main_object and "website_url" in main_object: - return main_object.website_url + if main_object and "url" in main_object._fields: + return main_object.url return "/" def form_cancel_url(self, main_object=None): @@ -89,8 +78,8 @@ def form_cancel_url(self, main_object=None): # redirect overridden return self.request.args.get("redirect") main_object = main_object or self.main_object - if main_object and "website_url" in main_object: - return main_object.website_url + if main_object and "url" in main_object._fields: + return main_object.url return self.request.referrer or "/" def form_check_empty_value(self, fname, field, value, **req_values): @@ -105,7 +94,7 @@ def form_validate(self, request_values=None): request_values = request_values or self.form_get_request_values() missing = False - for fname, field in self.form_fields().items(): + for fname, field in self.form_fields_get().items(): value = request_values.get(fname) error = False if field["required"] and self.form_check_empty_value( @@ -121,15 +110,15 @@ def form_validate(self, request_values=None): errors_message[fname] = error_msg # error message for empty required fields - if missing and self.o_request.website: + if missing: msg = self.form_msg_error_missing - self.o_request.website.add_status_message(msg, type_="danger") + self.add_status_message(msg, kind="danger") return errors, errors_message def form_get_validator(self, fname, field): """Retrieve field validator.""" # 1nd lookup for a default type validator - validator = self._form_validators.get(field["type"], None) + validator = self.form_validators.get(field["type"], None) # 2nd lookup for a specific type validator validator = getattr(self, "_form_validate_" + field["type"], validator) # 3rd lookup and override by named validator if any @@ -138,20 +127,19 @@ def form_get_validator(self, fname, field): def form_before_create_or_update(self, values, extra_values): """Pre create/update hook.""" - pass def form_after_create_or_update(self, values, extra_values): """Post create/update hook.""" - pass def _form_purge_non_model_fields(self, values): """Purge fields that are not in `form_model` schema and return them.""" extra_values = {} - if not self._form_model: + if not self.form_model_name: return extra_values _model_fields = list( self.form_model.fields_get( - self._form_model_fields, attributes=self._form_fields_attributes, + self.form_model_fields, + attributes=self.form_fields_attributes, ).keys() ) submitted_keys = list(values.keys()) @@ -182,15 +170,17 @@ def form_create_or_update(self): else: self._form_create(write_values) msg = self.form_msg_success_created - if msg and self.o_request.website: - self.o_request.website.add_status_message(msg) # post hook self.form_after_create_or_update(write_values, extra_values) + if msg: + self.add_status_message(msg) return self.main_object def form_process_POST(self, render_values): """Process POST requests.""" errors, errors_message = self.form_validate() + # Do not flush to keep the caches of current in memory objects + savepoint = self.env.cr.savepoint(flush=False) if not errors: try: self.form_create_or_update() @@ -206,14 +196,18 @@ def form_process_POST(self, render_values): # u'Error while validating constraint\n # \nEnd Date cannot be set before Start Date.\nNone' errors_message["_validation"] = "
".join( - [x for x in err.name.replace("None", "").split("\n") if x.strip()] + [ + x + for x in err.args[0].replace("None", "").split("\n") + if x.strip() + ] ) - except IntegrityError as err: + except (pg.IntegrityError, pg.OperationalError) as err: errors["_integrity"] = True errors_message["_integrity"] = "
".join( [x for x in str(err).split("\n") if x.strip()] ) - + savepoint.rollback() # TODO: how to handle validation error on create? # If you use @api.constrains to validate fields' value # the check happens only AFTER the record has been created. @@ -230,9 +224,12 @@ def form_process_POST(self, render_values): orm_error = errors.get("_validation") or errors.get("_integrity") if orm_error: msg = errors_message.get("_validation") or errors_message.get("_integrity") - if msg and self.o_request.website: - self.o_request.website.add_status_message( - msg, type_="danger", title=None + if msg: + self.add_status_message( + msg, kind="danger", title=None, dismissible=False ) render_values.update({"errors": errors, "errors_message": errors_message}) return render_values + + def add_status_message(self, msg, **kw): + self.env["ir.http"].add_status_message(msg, request=self.o_request, **kw) diff --git a/cms_form/models/cms_form_mixin.py b/cms_form/models/cms_form_mixin.py index 57a58df1..4e61324e 100644 --- a/cms_form/models/cms_form_mixin.py +++ b/cms_form/models/cms_form_mixin.py @@ -1,13 +1,13 @@ # Copyright 2017 Simone Orsi # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). -import inspect import json from collections import OrderedDict -from odoo import _, exceptions, models, tools +from odoo import _, api, exceptions, fields, models, tools from .. import marshallers, utils +from .fields import Serialized IGNORED_FORM_FIELDS = [ "display_name", @@ -20,6 +20,7 @@ "message_unread", "message_unread_counter", "message_needaction_counter", + # Loose dep "website_message_ids", "website_published", ] + models.MAGIC_COLUMNS @@ -31,105 +32,174 @@ class CMSFormMixin(models.AbstractModel): _name = "cms.form.mixin" _description = "CMS Form mixin" - # template to render the form - form_template = "cms_form.base_form" - form_fields_template = "cms_form.base_form_fields" - form_buttons_template = "cms_form.base_form_buttons" - form_display_mode = "horizontal" # or 'vertical' - form_action = "" - form_method = "POST" - _form_mode = "" + id = fields.Id(automatic=True) + + # Special fields / + # TODO: would be better to have python obj fields + request = fields.Binary(form_tech=True, store=False) + o_request = fields.Binary(form_tech=True, store=False) + main_object = fields.Binary(form_tech=True, store=False, default=None) + # Values used to render the form + form_render_values = fields.Binary( + form_tech=True, store=False, compute="_compute_form_render_values" + ) + form_data = Serialized(form_tech=True, default={}) + # / special fields + form_wrapper_template = fields.Char(form_tech=True, default="") + form_template = fields.Char(form_tech=True, default="cms_form.base_form") + form_fields_template = fields.Char( + form_tech=True, default="cms_form.base_form_fields" + ) + form_fields_wrapper_template = fields.Char( + form_tech=True, compute="_compute_form_fields_wrapper_template", readonly=False + ) + form_buttons_template = fields.Char( + form_tech=True, default="cms_form.base_form_buttons" + ) + form_display_mode = fields.Selection( + form_tech=True, + selection=[("horizontal", "Horizontal"), ("vertical", "Vertical")], + default="horizontal", + ) + form_action = fields.Char(form_tech=True, default="") + form_method = fields.Char(form_tech=True, default="POST") + form_mode = fields.Char( + form_tech=True, default="", compute="_compute_form_mode", readonly=False + ) + form_title = fields.Char(form_tech=True, default="") + form_description = fields.Char(form_tech=True, default="") # extra css klasses for the whole form wrapper - _form_wrapper_extra_css_klass = "" + form_wrapper_extra_css_klass = fields.Char(form_tech=True, default="") # extra css klasses for just the form element - _form_extra_css_klass = "" + form_extra_css_klass = fields.Char(form_tech=True, default="") # model tied to this form - _form_model = "" + form_model_name = fields.Char(form_tech=True, default="") # model's fields to load - _form_model_fields = [] + form_model_fields = Serialized(form_tech=True, default=[]) # force fields order - _form_fields_order = [] + form_fields_order = Serialized(form_tech=True, default=[]) # quickly force required fields - _form_required_fields = () + form_required_fields = Serialized(form_tech=True, default=[]) # mark some fields as "sub". # For usability reasons you might want to move some fields # inside the widget of another field. # If you mark a field as "sub" this field # won't be included into fields' list as usual # but you can still find it in `form_fields` value. - _form_sub_fields = { - # 'mainfield': { - # # loaded for a specific value - # 'mainfield_value': ('subfield1', 'subfield2'), - # # loaded for all values - # '_all': ('subfield3', ), - # } - } + # 'mainfield': { + # # loaded for a specific value + # 'mainfield_value': ('subfield1', 'subfield2'), + # # loaded for all values + # '_all': ('subfield3', ), + # } + form_sub_fields = Serialized(form_tech=True, default={}) # fields' attributes to load - _form_fields_attributes = [ - "type", - "string", - "domain", - "required", - "readonly", - "relation", - "store", - "help", - "selection", - ] + form_fields_attributes = Serialized( + form_tech=True, + default=[ + "type", + "string", + "domain", + "required", + "readonly", + "relation", + "store", + "help", + "selection", + ], + ) # include only these fields - _form_fields_whitelist = () + form_fields_whitelist = Serialized(form_tech=True, default=[]) # exclude these fields - _form_fields_blacklist = () + form_fields_blacklist = Serialized(form_tech=True, default=[]) # include fields but make them input[type]=hidden - _form_fields_hidden = () + form_fields_hidden = Serialized(form_tech=True, default=[]) # group form fields together - _form_fieldsets = [ - # { - # 'id': 'main', - # 'title': 'My group of fields', - # 'description': 'Bla bla bla', - # 'fields': ['name', 'age', 'foo'], - # 'css_extra_klass': 'best_fieldset', - # }, - # { - # 'id': 'extras', - # 'title': 'My group of fields 2', - # 'description': 'Bla bla bla', - # 'fields': ['some', 'other', 'field'], - # 'css_extra_klass': '', - # }, - ] + # [ + # { + # 'id': 'main', + # 'title': 'My group of fields', + # 'description': 'Bla bla bla', + # 'fields': ['name', 'age', 'foo'], + # 'css_extra_klass': 'best_fieldset', + # }, + # { + # 'id': 'extras', + # 'title': 'My group of fields 2', + # 'description': 'Bla bla bla', + # 'fields': ['some', 'other', 'field'], + # 'css_extra_klass': '', + # }, + # ] + form_fieldsets = Serialized(form_tech=True, default=[]) # control fieldset display # options: # * `tabs` -> rendered as tabs # * `vertical` -> one after each other, vertically - _form_fieldsets_display = "vertical" + form_fieldsets_display = fields.Selection( + form_tech=True, + selection=[("tabs", "Tabs"), ("vertical", "Vertical")], + default="vertical", + ) # extract values mode # This param can be used to alter value format # when extracting values from request. # eg: in write mode you can get on a m2m [(6, 0, [1,2,3])] # while in read mode you can get just the ids [1,2,3] - _form_extract_value_mode = "write" + form_extract_value_mode = fields.Selection( + form_tech=True, + selection=[("write", "Write"), ("read", "Read")], + default="write", + ) # ignore this fields default - __form_fields_ignore = IGNORED_FORM_FIELDS - # current edit object if any - __form_main_object = None + form_fields_ignore = Serialized(form_tech=True, default=IGNORED_FORM_FIELDS) # default is to post the form and have a full reload. Set to true to keep # the search form as it is and only replace the result pane - _form_ajax = False - # submit the form for every change event - _form_ajax_onchange = False + form_ajax = fields.Boolean(form_tech=True, default=False) + form_ajax_onchange = fields.Boolean(form_tech=True, default=False) + # jQuery selector to find container of search results + form_content_selector = fields.Char(form_tech=True, default=".form_content") + # used to interpolate widgets' html field name + form_fname_pattern = fields.Char(form_tech=True, default="") + + def _valid_field_parameter(self, field, name): + res = super()._valid_field_parameter(field, name) + # allow form tech fields + return name.startswith("form_") or res + + def _compute_form_render_values(self): + for rec in self: + rec.form_render_values = rec._get_render_values() + + def _get_render_values(self): + return { + # TODO: default for BInary field is "False" but we need "None" + "main_object": self.main_object or None, + "form": self, + "errors": {}, + "errors_messages": {}, + } - @property - def main_object(self): - """Current main object.""" - return self.__form_main_object + def _compute_form_mode(self): + for rec in self: + rec.form_mode = rec._get_form_mode() + + def _get_form_mode(self): + if self.form_mode: + # forced mode + return self.form_mode + if self.main_object: + return "edit" + return "create" + + _form_field_wrapper_template_pattern = ( + "cms_form.form_{form.form_display_mode}_field_wrapper" + ) - @main_object.setter - def main_object(self, value): - """Current main object setter.""" - self.__form_main_object = value + def _compute_form_fields_wrapper_template(self): + pattern = self._form_field_wrapper_template_pattern + for rec in self: + rec.form_fields_wrapper_template = pattern.format(form=rec) def form_init(self, request, main_object=None, **kw): """Initalize a form instance. @@ -137,17 +207,18 @@ def form_init(self, request, main_object=None, **kw): @param request: an odoo-wrapped werkeug request @param main_object: current model instance if any @param kw: pass any override for `_form_` attributes - ie: `fields_attributes` -> `_form_fields_attributes` + ie: `fields_attributes` -> `form_fields_attributes` """ - form = self.new() - form.o_request = request # odoo wrapped request - form.request = request.httprequest # werkzeug request, the "real" one - form.main_object = main_object - # override `_form_` parameters - for k, v in kw.items(): - attr = getattr(form, "_form_" + k, "__no__attr__") - if attr != "__no__attr__" and not inspect.ismethod(attr): - setattr(form, "_form_" + k, v) + vals = { + "o_request": request, + "request": request.httprequest, + "main_object": main_object, + } + form_kw = {k: v for k, v in kw.items() if k in self._fields} + vals.update(form_kw) + form = self.new(vals) + if "form_data" not in vals: + form.form_data = form.form_load_defaults() return form def form_check_permission(self, raise_exception=True): @@ -160,20 +231,20 @@ def form_check_permission(self, raise_exception=True): else: # `cms.info.mixin` not provided by model. 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, + msg = _( + "You cannot edit this record. Model: %(model)s, ID: %(obj_id)s.", + model=self.main_object._name, + obj_id=self.main_object.id, ) else: - if self._form_model: + if self.form_model_name: 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 + % self.form_model_name ) if raise_exception and not res: raise exceptions.AccessError(msg) @@ -181,7 +252,7 @@ def form_check_permission(self, raise_exception=True): def _can_create(self, raise_exception=True): """Check that current user can create instances of given model.""" - if self._form_model: + if self.form_model_name: return self.form_model.check_access_rights( "create", raise_exception=raise_exception ) @@ -203,29 +274,12 @@ def _can_edit(self, raise_exception=True): can = False return can - @property - def form_title(self): - return "" # pragma: no cover - - @property - def form_description(self): - return "" # pragma: no cover - - @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): # queue_job tries to read properties. Be defensive. - return self.env.get(self._form_model) + return self.env.get(self.form_model_name) - def form_fields(self, hidden=None): + def form_fields_get(self, hidden=None): """Retrieve form fields. :param hidden: whether to include or not hidden inputs. @@ -234,7 +288,7 @@ def form_fields(self, hidden=None): * True: include only hidden fields * False: include all fields but those hidden. """ - _fields = self._form_fields() + _fields = self._form_fields_get() # update fields attributes self.form_update_fields_attributes(_fields) if hidden is not None: @@ -246,8 +300,11 @@ def form_fields(self, hidden=None): return filtered return _fields + def _form_fields_attributes_get(self): + return self.form_fields_attributes or [] + @tools.cache("self") - def _form_fields(self): + def _form_fields_get(self): """Retrieve form fields ready to be used. Fields lookup: @@ -257,32 +314,35 @@ def _form_fields(self): Blacklisted fields are skipped. Whitelisted fields are loaded only. """ + attributes = self._form_fields_attributes_get() _all_fields = OrderedDict() # load model fields _model_fields = {} - if self._form_model: + if self.form_model_name: _model_fields = self.form_model.fields_get( - self._form_model_fields, attributes=self._form_fields_attributes, + self.form_model_fields, + attributes=attributes, ) # inject defaults - defaults = self.form_model.default_get(self._form_model_fields) + defaults = self.form_model.default_get(self.form_model_fields) for k, v in defaults.items(): _model_fields[k]["_default"] = v # load form fields - _form_fields = self.fields_get(attributes=self._form_fields_attributes) + _form_fields = self.fields_get(attributes=attributes) # inject defaults for k, v in self.default_get(list(_form_fields.keys())).items(): _form_fields[k]["_default"] = v _all_fields.update(_model_fields) # form fields override model fields - _all_fields.update(_form_fields) + # TODO: add tests + _all_fields = utils.data_merge(_all_fields, _form_fields) # exclude blacklisted - for fname in self._form_fields_blacklist: + 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: + for fname in self.form_fields_whitelist: _all_whitelisted[fname] = _all_fields[fname] _all_fields = _all_whitelisted or _all_fields # remove unwanted fields @@ -293,9 +353,9 @@ def _form_fields(self): # whereas some core fields attributes are missing. _all_fields = {k: v for k, v in _all_fields.items() if v.get("store")} # update fields order - if self._form_fields_order: + if self.form_fields_order: _sorted_all_fields = OrderedDict() - for fname in self._form_fields_order: + for fname in self.form_fields_order: # this check is required since you can have `groups` attribute # on a field, making the field unavailable if not satisfied. if fname in _all_fields: @@ -305,10 +365,20 @@ def _form_fields(self): self._form_prepare_subfields(_all_fields) return _all_fields + @api.model + def fields_get(self, allfields=None, attributes=None): + res = super().fields_get(allfields, attributes) + # Wipe tech fields + return { + k: v + for k, v in res.items() + if not getattr(self._fields[k], "form_tech", False) + } + def _form_prepare_subfields(self, _all_fields): """Add subfields to related main fields.""" # TODO: document this - for mainfield, subfields in self._form_sub_fields.items(): + for mainfield, subfields in self.form_sub_fields.items(): if mainfield not in _all_fields: continue _subfields = {} @@ -322,14 +392,14 @@ def _form_prepare_subfields(self, _all_fields): def _form_remove_uwanted(self, _all_fields): """Remove fields from form fields.""" - for fname in self.__form_fields_ignore: + for fname in self.form_fields_ignore: _all_fields.pop(fname, None) - def form_fieldsets(self): + def form_fieldsets_get(self): # exclude empty ones - form_fields = self._form_fields() + form_fields = self._form_fields_get() res = [] - for fset in self._form_fieldsets: + for fset in self.form_fieldsets: if any([form_fields.get(fname) for fname in fset["fields"]]): # at least one field is here res.append(fset) @@ -338,25 +408,30 @@ def form_fieldsets(self): @property def form_fieldsets_wrapper_klass(self): klass = [] - if self._form_fieldsets: - klass = ["has_fieldsets", self._form_fieldsets_display] + if self.form_fieldsets: + klass = ["has_fieldsets", self.form_fieldsets_display] return " ".join(klass) def form_update_fields_attributes(self, _fields): """Manipulate fields attributes.""" for fname, field in _fields.items(): - if fname in self._form_required_fields: + if fname in self.form_required_fields: _fields[fname]["required"] = True - if fname in self._form_fields_hidden: + if self._form_is_field_hidden(fname, field): _fields[fname]["hidden"] = 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_is_field_hidden(self, fname, field): + return ( + fname in self.form_fields_hidden + or fname in self._fields + and getattr(self._fields[fname], "form_hidden", False) + ) - def form_get_widget_model(self, fname, field): + def form_get_field_wrapper_template(self, fname, field): + return field["widget"].w_wrapper_template or self.form_fields_wrapper_template + + def _form_get_default_widget_model(self, fname, field): """Retrieve widget model name.""" if field.get("hidden"): # special case @@ -366,18 +441,53 @@ def form_get_widget_model(self, fname, field): model_key = "cms.form.widget." + key if model_key in self.env: widget_model = model_key - return self.form_widgets.get(fname, widget_model) + return widget_model def form_get_widget(self, fname, field, **kw): """Retrieve and initialize widget.""" - return self.env[self.form_get_widget_model(fname, field)].widget_init( - self, fname, field, **kw - ) + specific_widget = self._form_get_specific_widget(fname, field, **kw) + if specific_widget: + return specific_widget + model = self._form_get_default_widget_model(fname, field) + return self.env[model].widget_init(self, fname, field, **kw) + + def form_get_current_widget(self, fname): + return self.form_fields_get()[fname]["widget"] + + def _form_get_specific_widget(self, fname, field, **kw): + """Retrieve and initialize fields' specific widgets. + + Form fields' can declare custom widgets using `form_widget` attribute. + Properties: + + `resolver`: callable that returns a widget already initialized (optional) + `model`: widget model if no `resolver` is passed (mandatory) + `options`: dictionary or callable to resolve widget's options + """ + widget_conf = {} + if fname in self._fields: + # Note: a custom widget for a field on the related model + # can come only from an override of the field in the form. + widget_conf = getattr(self._fields[fname], "form_widget", {}) + if not widget_conf: + return None + if widget_conf.get("resolver"): + return widget_conf["resolver"](self, fname, field, **kw) + try: + model = widget_conf["model"] + except KeyError: + model = self._form_get_default_widget_model(fname, field) + options = widget_conf.get("options", {}) + if options and callable(options): + options = options(self, fname, field, **kw) + return self.env[model].widget_init(self, fname, field, **options) @property def form_file_fields(self): """File fields.""" - return {k: v for k, v in self.form_fields().items() if v["type"] == "binary"} + return { + k: v for k, v in self.form_fields_get().items() if v["type"] == "binary" + } def form_get_request_values(self): """Retrieve fields values from current request.""" @@ -392,9 +502,30 @@ def form_get_request_values(self): # normal fields res = marshallers.marshal_request_values(_values) # file fields - res.update({k: v for k, v in self.request.files.items()}) + files = self.request.files + # Convert files always. Main reasons: + # * file descriptors will be consumed on 1st read. + # If you access them again you won't find any info. + # * homegenous handling of files + # * no need to parse metadata down the stack as is done by the marshaller + parsed_files = getattr(self.request, "_cms_form_files_processed", None) + if files and not parsed_files: + _file_values = {} + _file_fields = self.form_file_fields + for fname, fobj in files.items(): + if fname in _file_fields: + # fake field name enforcing marshaller + if not fname.endswith(":file"): + fname = f"{fname}:file" + _file_values[fname] = fobj + file_values = marshallers.marshal_request_values(_file_values) + self.request._cms_form_files_processed = file_values + elif parsed_files: + res.update(parsed_files) return res + # TODO: rename to form_load + # TODO: adapt signature to form_extract (eg: kw args) def form_load_defaults(self, main_object=None, request_values=None): """Load default values. @@ -403,10 +534,12 @@ def form_load_defaults(self, main_object=None, request_values=None): 1. `main_object` fields' values (if an existing main_object is passed) 2. request parameters (only parameters matching form fields names) """ + if self.form_data: + return self.form_data 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() + form_fields = self.form_fields_get() for fname, field in form_fields.items(): value = field["widget"].w_load(**request_values) # override via specific form loader when needed @@ -433,11 +566,12 @@ def form_get_loader(self, fname, field, main_object=None, value=None, **req_valu loader = getattr(self, "_form_load_" + fname, loader) return loader + # TODO: rename to form_extract 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().items(): + for fname, field in self.form_fields_get().items(): value = field["widget"].w_extract(**request_values) # override via specific form extractor when needed extractor = self.form_get_extractor( @@ -470,39 +604,16 @@ def form_get_extractor(self, fname, field, value=None, **req_values): 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) - values["field_wrapper_template"] = "cms_form.form_{}_field_wrapper".format( - self.form_display_mode - ) - return self.env.ref(self.form_template).render(values) + return self.env["ir.qweb"]._render(self.form_template, values) def form_process(self, **kw): """Process current request. @@ -517,7 +628,6 @@ def form_process(self, **kw): """ 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()) self.form_render_values = dict(render_values, **handler(render_values)) @@ -540,14 +650,14 @@ def form_wrapper_css_klass(self): 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 + * `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, + self.form_model_name.replace(".", "_").lower(), + self.form_wrapper_extra_css_klass, "mode_" + self.form_mode, ] return " ".join([x.strip() for x in parts if x.strip()]) @@ -556,7 +666,7 @@ def form_wrapper_css_klass(self): def form_css_klass(self): """Return `
` element css klasses. - By default you can provide extra klasses via `_form_extra_css_klass`. + By default you can provide extra klasses via `form_extra_css_klass`. """ klass = "" if self.form_display_mode == "horizontal": @@ -564,8 +674,8 @@ def form_css_klass(self): elif self.form_display_mode == "vertical": # actually not a real BS3 css klass but helps styling klass = "form-vertical" - if self._form_extra_css_klass: - klass += " " + self._form_extra_css_klass + if self.form_extra_css_klass: + klass += " " + self.form_extra_css_klass return klass def form_make_field_wrapper_klass(self, fname, field, **kw): @@ -580,6 +690,8 @@ def form_make_field_wrapper_klass(self, fname, field, **kw): klass.append("field-required") if kw.get("errors", {}).get(fname): klass.append("has-error") + if field["widget"].w_wrapper_css_klass: + klass.append(field["widget"].w_wrapper_css_klass) return " ".join(klass).format(fname=fname, **field) def _form_json_info(self): @@ -587,8 +699,8 @@ def _form_json_info(self): info.update( { "master_slave": self._form_master_slave_info(), - "model": self._form_model, - "form_content_selector": getattr(self, "_form_content_selector", None,), + "model": self.form_model_name, + "form_content_selector": self.form_content_selector, } ) return info diff --git a/cms_form/models/cms_form_wizard.py b/cms_form/models/cms_form_wizard.py index df67fea2..3d1d74d7 100644 --- a/cms_form/models/cms_form_wizard.py +++ b/cms_form/models/cms_form_wizard.py @@ -3,7 +3,9 @@ from copy import deepcopy -from odoo import models +from odoo import fields, models + +from .fields import Serialized class CMSFormWizard(models.AbstractModel): @@ -18,16 +20,25 @@ class CMSFormWizard(models.AbstractModel): _name = "cms.form.wizard" _description = "CMS Form wizard" _inherit = "cms.form" - _form_mode = "wizard" _wiz_name = _name - form_buttons_template = "cms_form.wizard_form_buttons" + form_buttons_template = fields.Char( + form_tech=True, default="cms_form.wizard_form_buttons" + ) # display wizard progress bar? - wiz_show_progress_bar = True - # fields declared here will be automatically stored + form_show_progress_bar = fields.Boolean(form_tech=True, default=True) + # Fields declared here will be automatically stored # into wizard storage - # Use `_wiz_step_stored_fields = 'all'` to store them all. + # Use `form_step_store_all_fields = True` 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" + form_step_stored_fields = Serialized(form_tech=True, default=[]) + form_step_store_all_fields = fields.Boolean(form_tech=True, default=True) + form_reset = fields.Boolean(form_tech=True, default=False) + + def _is_wiz_main_model(self): + return self._name == self._wiz_name + + def _get_form_mode(self): + return "wizard" @property def form_wrapper_css_klass(self): @@ -41,15 +52,30 @@ def _wiz_storage_key(self): @property def _wiz_storage(self): - return self.request.session + return self.o_request.session def wiz_storage_get(self): - if not self._wiz_storage.get(self._wiz_storage_key): + self._wiz_storage_prepare() + storage = self._wiz_storage[self._wiz_storage_key] + if "steps" in storage: + # Depending of the type of session storage data might be serialized. + # When this happens steps keys might be converted to strings. + # Ensure we always get integers. + storage["steps"] = {int(k): v for k, v in storage["steps"].items()} + return storage + + def _wiz_storage_prepare(self, reset=False): + if not self._wiz_storage.get(self._wiz_storage_key) or reset: # 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] + + def wiz_storage_set(self, storage): + self.o_request.session.update({self._wiz_storage_key: storage}) + # Important: ensure the session is stored (will be flagged as dirty) + # Mandatory since v16 when storing nested objs. + self.o_request.session.touch() DEFAULT_STORAGE_KEYS = { "steps": {}, @@ -60,6 +86,9 @@ def wiz_storage_get(self): def form_init(self, request, main_object=None, page=1, wizard=None, **kw): form = super().form_init(request, main_object=main_object, **kw) + if form.form_reset: + form._wiz_storage_prepare(reset=True) + form.form_reset = False form.wiz_init(page=page, **kw) return form @@ -113,8 +142,8 @@ def wiz_get_step_info(self, step): step = int(step) try: return self.wiz_configure_steps()[step] - except KeyError: - raise ValueError("Step `%s` does not exists." % str(step)) + except KeyError as e: + raise ValueError("Step `%s` does not exists." % str(step)) from e def wiz_current_step(self): return self.wiz_storage_get().get("current") or 1 @@ -131,6 +160,14 @@ def form_next_url(self, main_object=None): step = self.wiz_next_step() else: step = self.wiz_prev_step() + + main_object = main_object or self.main_object + if ( + main_object + and "url" in main_object._fields + and self.is_final_step_process() + ): + return main_object.url if not step: # fallback to page 1 step = 1 @@ -148,24 +185,62 @@ def wiz_save_step(self, values, step=None): if step not in storage["steps"]: # safely re-init step storage["steps"][step] = {} + storage["steps"][step].update(values) + self.wiz_storage_set(storage) def wiz_load_step(self, step=None): step = step or self.wiz_current_step() return self.wiz_storage_get()["steps"].get(step) or {} + def wiz_load_steps(self, steps=None): + """Load all steps data merged together.""" + data = self.wiz_storage_get()["steps"] + steps = steps or data.keys() + res = {} + for step in steps: + res.update(data.get(step, {})) + return res + 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) + if self.is_final_step_process(): + # Wipe session data when done + self._wiz_storage_prepare(reset=True) + + def is_final_step_process(self): + # Helper method to determine if the submot action is the last one + return self.request.form.get("wiz_submit") == "process" def _prepare_step_values_to_store(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 = self.form_step_stored_fields + if not stored_fields and self.form_step_store_all_fields: stored_fields = values.keys() for fname in stored_fields: if fname in values: step_values[fname] = values[fname] return step_values + + # TODO: tests + def form_load_defaults(self, main_object=None, request_values=None): + # Override to load values from the storage + if self._is_wiz_main_model(): + # Do not load anything if we are initializing the main wiz model + return {} + defaults = super().form_load_defaults( + main_object=main_object, request_values=request_values + ) + request_values = request_values or {} + step_values = self.wiz_load_step() + if step_values: + for fname in self.form_fields_get().keys(): + if fname in request_values: + # req value has precedence + continue + if fname in step_values: + defaults[fname] = step_values[fname] + return defaults diff --git a/cms_form/models/cms_search_form.py b/cms_form/models/cms_search_form.py index c7eb5ddb..445eb782 100644 --- a/cms_form/models/cms_search_form.py +++ b/cms_form/models/cms_search_form.py @@ -1,41 +1,79 @@ # Copyright 2017 Simone Orsi # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). -from odoo import _, models +from odoo import _, fields, models from odoo.tools import pycompat +from odoo.addons.portal.controllers.portal import pager as portal_pager + +from .fields import Serialized + class CMSFormSearch(models.AbstractModel): _name = "cms.form.search" _description = "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" + # change defaults + form_buttons_template = fields.Char( + form_tech=True, default="cms_form.search_form_buttons" + ) + form_search_results_template = fields.Char( + form_tech=True, default="cms_form.search_results" + ) + form_no_search_results_template = fields.Char( + form_tech=True, default="cms_form.no_search_results" + ) + form_method = fields.Char(form_tech=True, default="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" + form_show_search_form = fields.Boolean(form_tech=True, default=True) + form_mode = fields.Char(form_tech=True, default="search") + form_extract_value_mode = fields.Char(form_tech=True, default="read") # show results if no query has been submitted? - _form_show_results_no_submit = True - _form_results_per_page = 10 + form_show_search_form = fields.Boolean(form_tech=True, default=True) + form_show_results_no_submit = fields.Boolean(form_tech=True, default=True) + form_results_per_page = fields.Integer(form_tech=True, default=10) # sort by this param, defaults to model's `_order` - _form_results_orderby = "" + form_results_orderby = fields.Char(form_tech=True, default="") # declare fields that must be searched w/ multiple values - _form_search_fields_multi = () + form_search_fields_multi = Serialized(form_tech=True, default=[]) # declare custom domain computation rules - _form_search_domain_rules = { - # 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) - } - # jQuery selector to find container of search results - _form_content_selector = ".form_content" + # 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) + form_search_domain_rules = fields.Binary(form_tech=True, default={}, store=False) + form_search_results = fields.Binary(form_tech=True, default={}, store=False) + + # make it computed + form_title = fields.Char(form_tech=True, compute="_compute_form_title") + form_no_result_msg = fields.Char( + form_tech=True, compute="_compute_form_no_result_msg" + ) + + def _get_form_mode(self): + return "search" + + def _compute_form_title(self): + for rec in self: + rec.form_title = rec._get_form_title() + + def _get_form_title(self): + title = _("Search") + if self.form_model_name: + model = self.env["ir.model"]._get(self.form_model_name) + name = model and model.name or "" + title = _("Search %s") % name + return title + + def _compute_form_no_result_msg(self): + for rec in self: + rec.form_no_result_msg = rec._get_form_no_result_msg() + + def _get_form_no_result_msg(self): + return _("No items") def form_check_permission(self): """Just searching, nothing to check here.""" @@ -43,39 +81,18 @@ def form_check_permission(self): def form_update_fields_attributes(self, _fields): """No field should be mandatory.""" - super().form_update_fields_attributes(_fields) + res = super().form_update_fields_attributes(_fields) for _fname, field in _fields.items(): field["required"] = False + return res - def form_get_widget_model(self, fname, field): + def _form_get_default_widget_model(self, fname, field): """Search via related field needs a simple char widget.""" - res = super().form_get_widget_model(fname, field) - if fname in self._form_search_domain_rules: + res = super()._form_get_default_widget_model(fname, field) + if fname in self.form_search_domain_rules: res = "cms.form.widget.char" return res - __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"].sudo().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 @@ -83,17 +100,17 @@ def form_process_GET(self, 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: + 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 = 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 + order = self.form_results_orderby or None results = self.form_model.search( domain, - limit=self._form_results_per_page, + limit=self.form_results_per_page, offset=pager["offset"], order=order, ) @@ -108,7 +125,7 @@ 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: + if self.form_model_name: # rely on model's cms search url url = getattr(self.form_model, "cms_search_url", None) or url # override via controller/request specific value @@ -116,7 +133,7 @@ def _form_get_url_for_pager(self, render_values): return url def pager(self, **kw): - return self.env["website"].pager(**kw) + return portal_pager(**kw) def _form_results_pager(self, count=None, page=0, url="", url_args=None): """Prepare pager for current search.""" @@ -126,19 +143,19 @@ def _form_results_pager(self, count=None, page=0, url="", url_args=None): url=url, total=count, page=page, - step=self._form_results_per_page, - scope=self._form_results_per_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().items(): + for fname, field in self.form_fields_get().items(): value = search_values.get(fname) if value is None: continue - if fname in self._form_search_fields_multi: + if fname in self.form_search_fields_multi: leaf = (fname, "in", value) domain.append(leaf) continue @@ -163,8 +180,8 @@ def form_search_domain(self, search_values): if not value: # searching for an empty string breaks search continue - if fname in self._form_search_domain_rules: - rule = self._form_search_domain_rules[fname] + if fname in self.form_search_domain_rules: + rule = self.form_search_domain_rules[fname] if callable(rule): fname, operator, value = rule(field, value, search_values) else: diff --git a/cms_form/models/fields.py b/cms_form/models/fields.py new file mode 100644 index 00000000..477231f7 --- /dev/null +++ b/cms_form/models/fields.py @@ -0,0 +1,35 @@ +# Copyright 2023 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import json +import logging + +from odoo.addons.base_sparse_field.models.fields import Serialized as BaseSerialized + +_logger = logging.getLogger(__name__) + + +class Serialized(BaseSerialized): + """Better implementation of Serialized field. + + 1. load proper default (core always load {}) + 2. do not fail if value is already a py obj + TODO: propose to odoo core + """ + + def convert_to_record(self, value, record): + default = ( + self.default(self.model_name) if callable(self.default) else self.default + ) + # Important: if you want to set an empty value and bypass the default + # you must use a string (eg: "[]" or "{}") + value = value if value is not None else default + if isinstance(value, str): + try: + return json.loads(value) + except ValueError: + _logger.error("%s got bad json string: %s", self.name, value) + # Likely a string that is not convert-able. + # Consider using a special encoder/decoder. + return value + + return value diff --git a/cms_form/models/widgets/__init__.py b/cms_form/models/widgets/__init__.py index 998bfc46..d9bac019 100644 --- a/cms_form/models/widgets/__init__.py +++ b/cms_form/models/widgets/__init__.py @@ -1,4 +1,5 @@ from . import widget_mixin +from . import widget_rel_mixin from . import widget_text from . import widget_hidden from . import widget_numeric diff --git a/cms_form/models/widgets/widget_binary.py b/cms_form/models/widgets/widget_binary.py index c512e8d9..36036245 100644 --- a/cms_form/models/widgets/widget_binary.py +++ b/cms_form/models/widgets/widget_binary.py @@ -1,13 +1,11 @@ # Copyright 2017 Simone Orsi # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). -import base64 import werkzeug -from odoo import models -from odoo.tools import pycompat -from odoo.tools.mimetypes import guess_mimetype +from odoo import fields, models +from odoo.tools.image import image_data_uri class BinaryWidget(models.AbstractModel): @@ -15,60 +13,32 @@ class BinaryWidget(models.AbstractModel): _inherit = "cms.form.widget.mixin" _description = "CMS Form binary widget" - def w_load(self, **req_values): - value = super().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': '', - } - from_request = False - if value: - if isinstance(value, werkzeug.datastructures.FileStorage): - from_request = True - byte_content = value.read() - value = base64.b64encode(byte_content) - value = pycompat.to_text(value) - else: - value = pycompat.to_text(value) - 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) - return _value - def w_extract(self, **req_values): value = super().w_extract(**req_values) return self.form_to_binary(value, **req_values) def form_to_binary(self, value, **req_values): - if self.w_fname not in req_values: + if self.html_fname not in req_values: return None _value = False - keepcheck_flag = req_values.get(self.w_fname + "_keepcheck") - if not keepcheck_flag or keepcheck_flag == "yes": + keepcheck_flag_key = self.html_fname + "_keepcheck" + keepcheck_flag = req_values.get(keepcheck_flag_key) + # If no keepcheck flag is given the file or img is always replaced + if keepcheck_flag_key in req_values and 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", None) + req_values.pop(self.html_fname, None) + req_values.pop(keepcheck_flag_key, None) return None if value: - if hasattr(value, "read"): - file_content = value.read() - _value = base64.b64encode(file_content) - _value = pycompat.to_text(_value) - else: - # like 'data:image/jpeg;base64,jRyRuUm2VP... - _value = value.split(",")[-1] + _value = value + # TODO: move this to image widget + if isinstance(value, str): + if value.startswith("data:"): + # like 'data:image/jpeg;base64,jRyRuUm2VP... + _value = value.split(",")[-1] + elif isinstance(value, dict): + _value = value.get("value") return _value def w_check_empty_value(self, value, **req_values): @@ -89,4 +59,44 @@ class ImageWidget(models.AbstractModel): _name = "cms.form.widget.image" _inherit = "cms.form.widget.binary.mixin" _description = "CMS Form image widget" - _w_template = "cms_form.field_widget_image" + + w_template = fields.Char(default="cms_form.field_widget_image") + + def w_load(self, **req_values): + value = super().w_load(**req_values) + # TODO: can we get a dict here? Likely only when loading from request + if isinstance(value, dict) and value.get("mimetype", "").startswith("image/"): + val = ( + value["value"].encode() + if isinstance(value["value"], str) + else value["value"] + ) + value["value"] = image_data_uri(val) + elif isinstance(value, (str, bytes)): + bvalue = value + if isinstance(value, str): + bvalue = value.encode() + else: + value = value.decode() + if value.startswith("data:"): + raw_value = value.split(",")[-1] + else: + raw_value = value + value = image_data_uri(bvalue) + mimetype = value.split(";")[0].replace("data:", "") + value = { + "value": value, + "raw_value": raw_value, + "mimetype": mimetype, + "content_type": mimetype, + "content_lenght": len(raw_value), + } + return value + + +class FileWidget(models.AbstractModel): + _name = "cms.form.widget.binary" + _inherit = "cms.form.widget.binary.mixin" + _description = "CMS Form file widget" + + w_template = fields.Char(default="cms_form.field_widget_file") diff --git a/cms_form/models/widgets/widget_boolean.py b/cms_form/models/widgets/widget_boolean.py index c04503c0..2a9c28ce 100644 --- a/cms_form/models/widgets/widget_boolean.py +++ b/cms_form/models/widgets/widget_boolean.py @@ -1,18 +1,22 @@ # Copyright 2017 Simone Orsi # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). -from odoo import models +from odoo import fields, models from ... import utils +from ..fields import Serialized class BooleanWidget(models.AbstractModel): _name = "cms.form.widget.boolean" _inherit = "cms.form.widget.mixin" _description = "CMS Form boolean widget" - _w_template = "cms_form.field_widget_boolean" - w_true_values = utils.TRUE_VALUES + w_template = fields.Char(default="cms_form.field_widget_boolean") + w_wrapper_template = fields.Char(default="cms_form.form_field_label_after_wrapper") + w_wrapper_css_klass = fields.Char(default="form-check") + w_true_values = Serialized(default=utils.TRUE_VALUES) + w_field_value = fields.Boolean() def widget_init(self, form, fname, field, **kw): widget = super().widget_init(form, fname, field, **kw) diff --git a/cms_form/models/widgets/widget_date.py b/cms_form/models/widgets/widget_date.py index 0d649a70..dd819a5a 100644 --- a/cms_form/models/widgets/widget_date.py +++ b/cms_form/models/widgets/widget_date.py @@ -1,7 +1,7 @@ # Copyright 2017 Simone Orsi # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). -from odoo import models +from odoo import fields, models from ... import utils @@ -11,19 +11,24 @@ class DateWidget(models.AbstractModel): _name = "cms.form.widget.date" _inherit = "cms.form.widget.mixin" _description = "CMS Form date widget" - _w_template = "cms_form.field_widget_date" + w_template = fields.Char(default="cms_form.field_widget_date") # Both default to current lang format. - w_placeholder = "" - w_date_format = "" + w_placeholder = fields.Char(default="") + w_date_format = fields.Char(default="") + # change type of field + w_field_value = fields.Date(default=None) + w_default_today = fields.Boolean(default=True) def widget_init(self, form, fname, field, **kw): widget = super().widget_init(form, fname, field, **kw) - if "defaultToday" not in widget.w_data: + w_data = widget.w_data + if "defaultToday" not in w_data: # set today's date by default - widget.w_data["defaultToday"] = True + w_data["defaultToday"] = widget.w_default_today if kw.get("format", widget.w_date_format): - widget.w_data["dp"] = {"format": kw.get("format", widget.w_date_format)} + w_data["dp"] = {"format": kw.get("format", widget.w_date_format)} + widget.w_data = w_data widget.w_placeholder = kw.get("placeholder", widget.w_placeholder) return widget diff --git a/cms_form/models/widgets/widget_hidden.py b/cms_form/models/widgets/widget_hidden.py index 7e0ecd45..a8c9e182 100644 --- a/cms_form/models/widgets/widget_hidden.py +++ b/cms_form/models/widgets/widget_hidden.py @@ -1,17 +1,18 @@ # Copyright 2017 Simone Orsi # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). -from odoo import models +from odoo import fields, models class HiddenWidget(models.AbstractModel): _name = "cms.form.widget.hidden" _inherit = "cms.form.widget.mixin" _description = "CMS Form hidden widget" - _w_template = "cms_form.field_widget_hidden" + + w_template = fields.Char(default="cms_form.field_widget_hidden") @property - def w_html_fname(self): + def html_fname(self): """Field name for final HTML markup.""" # TODO: use this for all fields and get rid of custom w_extract # where possible @@ -27,4 +28,4 @@ def w_html_fname(self): marshaller = ":int" elif isinstance(first_value, float): marshaller = ":float" - return self.w_fname + marshaller + return super().html_fname + marshaller diff --git a/cms_form/models/widgets/widget_many2one.py b/cms_form/models/widgets/widget_many2one.py index 818a8b20..62161ed1 100644 --- a/cms_form/models/widgets/widget_many2one.py +++ b/cms_form/models/widgets/widget_many2one.py @@ -3,22 +3,19 @@ import json -from odoo import models +from odoo import fields, models from ... import utils +from ..fields import Serialized class M2OWidget(models.AbstractModel): _name = "cms.form.widget.many2one" - _inherit = "cms.form.widget.mixin" + _inherit = "cms.form.widget.rel.mixin" _description = "CMS Form M2O widget" - _w_template = "cms_form.field_widget_m2o" - def widget_init(self, form, fname, field, **kw): - widget = super().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 + w_template = fields.Char(default="cms_form.field_widget_m2o") + w_field_value = fields.Integer() @property def w_option_items(self): @@ -55,9 +52,11 @@ class M2OMultiWidget(models.AbstractModel): _name = "cms.form.widget.many2one.multi" _inherit = "cms.form.widget.many2one" _description = "CMS Form M2O multi widget" - _w_template = "cms_form.field_widget_m2o_multi" + + w_template = fields.Char(default="cms_form.field_widget_m2o_multi") # TODO: not used ATM - w_diplay_field = "display_name" + w_display_field = fields.Char(default="display_name") + w_field_value = Serialized(default=[]) def m2o_to_form(self, value, **req_values): if not value: diff --git a/cms_form/models/widgets/widget_mixin.py b/cms_form/models/widgets/widget_mixin.py index f61d794f..09f34040 100644 --- a/cms_form/models/widgets/widget_mixin.py +++ b/cms_form/models/widgets/widget_mixin.py @@ -3,7 +3,9 @@ import json -from odoo import models +from odoo import fields, models + +from ..fields import Serialized class Widget(models.AbstractModel): @@ -11,44 +13,71 @@ class Widget(models.AbstractModel): _description = "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="", - **kw - ): - widget = self.new() - widget.w_form = form - widget.w_form_model = form.form_model - 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 + + id = fields.Id(automatic=True) + # Special fields + # TODO: would be better to have python obj fields + w_form = fields.Binary(store=False) + w_record = fields.Binary(store=False) + w_field = fields.Binary(store=False) + w_subfields = fields.Binary(default={}, store=False) + + w_template = fields.Char(default="") + w_wrapper_template = fields.Char(default="") + w_css_klass = fields.Char(default="") + w_wrapper_css_klass = fields.Char(default="") + + w_fname = fields.Char(default="") + w_field_value = fields.Char() + w_readonly = fields.Boolean() + w_data = Serialized(default={}) + + @property + def html_fname(self): + if self.w_form.form_fname_pattern: + return self.w_form.form_fname_pattern.format(widget=self) + return self.w_fname + + @property + def html_readonly(self): + return self.w_form.form_mode == "readonly" or self.w_readonly + + @property + def html_value(self): + return self.w_field_value + + def widget_init(self, form, fname, field, data=None, subfields=None, **kw): + vals = { + "w_form": form, + "w_record": form.main_object, + "w_field": field, + "w_fname": fname, + "w_data": data or {}, + "w_subfields": subfields or field.get("subfields", {}), + } + for k, v in kw.items(): + if k in self._fields: + vals[k] = v + for k in ("template", "css_klass"): + if kw.get(k): + # TODO: deprecate + vals[f"w_{k}"] = kw[k] + field_value = form.form_data.get(fname, kw.get("field_value")) + if field_value: + vals["w_field_value"] = field_value + widget = self.new(vals) return widget def render(self): - return self.env.ref(self.w_template).render({"widget": self}) + return self.env["ir.qweb"]._render(self.w_template, {"widget": self}) @property - def w_template(self): - return self._w_template + def w_form_model(self): + return self.w_form.form_model @property - def w_css_klass(self): - return self._w_css_klass + def w_form_values(self): + return self.w_form.form_data def w_load(self, **req_values): """Load value for current field in current request.""" @@ -58,11 +87,22 @@ def w_load(self, **req_values): 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) + if isinstance(value, str) and value == "None": + # Corner case for when field values are set as None in the request. + # Odoo request will convert the value to a string. + # Here we might have data stored in session (eg: wizards) + # or other kind of serialized value. + value = None return value def w_extract(self, **req_values): """Extract value from form submit.""" - return req_values.get(self.w_fname) + value = req_values.get(self.w_fname) + if isinstance(value, str) and value == "None": + # Corner case for when field values are set as None in the request. + # Odoo request will convert the value to a string. + value = None + return value def w_check_empty_value(self, value, **req_values): # `None` values are meant to be ignored as not changed @@ -81,4 +121,8 @@ 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, sort_keys=True) + data = dict(self.w_data, name=self.html_fname) + return self._data_to_json(data) + + def _data_to_json(self, data): + return json.dumps(data, sort_keys=True) diff --git a/cms_form/models/widgets/widget_numeric.py b/cms_form/models/widgets/widget_numeric.py index 9a74de40..f6342bca 100644 --- a/cms_form/models/widgets/widget_numeric.py +++ b/cms_form/models/widgets/widget_numeric.py @@ -1,14 +1,41 @@ # Copyright 2017 Simone Orsi # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). -from odoo import models +from odoo import fields, models from ... import utils +class NumericWidgetMixin(models.AbstractModel): + _name = "cms.form.widget.numeric.mixin" + _inherit = "cms.form.widget.char.mixin" + _description = "CMS Form numeric mixin widget" + + w_template = fields.Char(default="cms_form.field_widget_numeric") + w_input_type = fields.Char(default="number") + w_input_min = fields.Char(default="") + w_input_max = fields.Char(default="") + + def _num_value_to_attr(self, value): + """Safely convert numeric value for html attr rendering.""" + if value is False or value is None: + return None + if isinstance(value, str) and not value.isdigit(): + return None + return str(value) + + @property + def html_input_min(self): + return self._num_value_to_attr(self.w_input_min) + + @property + def html_input_max(self): + return self._num_value_to_attr(self.w_input_max) + + class IntegerWidget(models.AbstractModel): _name = "cms.form.widget.integer" - _inherit = "cms.form.widget.char" + _inherit = "cms.form.widget.numeric.mixin" _description = "CMS Form integer widget" def w_extract(self, **req_values): diff --git a/cms_form/models/widgets/widget_rel_mixin.py b/cms_form/models/widgets/widget_rel_mixin.py new file mode 100644 index 00000000..f5d5b03e --- /dev/null +++ b/cms_form/models/widgets/widget_rel_mixin.py @@ -0,0 +1,29 @@ +# Copyright 2017 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + + +from odoo import fields, models + +from ..fields import Serialized + + +class RelWidgetMixin(models.AbstractModel): + _name = "cms.form.widget.rel.mixin" + _inherit = "cms.form.widget.mixin" + _description = "CMS Form relation widget mixin" + + w_comodel_name = fields.Char(default="") + w_domain = Serialized(default=[]) + w_display_field = fields.Char(default="display_name") + + def widget_init(self, form, fname, field, **kw): + widget = super().widget_init(form, fname, field, **kw) + widget.w_comodel_name = widget.w_field["relation"] + for k in ("domain", "display_field"): + if widget.w_field.get(k): + setattr(widget, f"w_{k}", widget.w_field.get(k)) + return widget + + @property + def w_comodel(self): + return self.env[self.w_comodel_name] diff --git a/cms_form/models/widgets/widget_selection.py b/cms_form/models/widgets/widget_selection.py index 7e895fca..0af7e437 100644 --- a/cms_form/models/widgets/widget_selection.py +++ b/cms_form/models/widgets/widget_selection.py @@ -1,14 +1,17 @@ # Copyright 2017 Simone Orsi # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). -from odoo import models +from odoo import fields, models + +from ..fields import Serialized class SelectionWidget(models.AbstractModel): _name = "cms.form.widget.selection" _inherit = "cms.form.widget.mixin" _description = "CMS Form selection widget" - _w_template = "cms_form.field_widget_selection" + + w_template = fields.Char(default="cms_form.field_widget_selection") def w_extract(self, **req_values): # Handle case where sel options are integers. @@ -17,6 +20,9 @@ def w_extract(self, **req_values): # and a widget field name. In any case we should be careful # and not brake existing forms/widgets. value = super().w_extract(**req_values) + return self.cast_field_value(value) + + def cast_field_value(self, value): first_value = None # use `get` as you might want to use the selection widget # for non-Selection fields and just pass options via `w_option_items`. @@ -36,15 +42,23 @@ def w_option_items(self): {"value": x[0], "label": x[1]} for x in self.w_field.get("selection", []) ] + def is_option_selected(self, opt_item): + return ( + "selected" + if opt_item["value"] == self.cast_field_value(self.w_field_value) + else None + ) + class RadioSelectionWidget(models.AbstractModel): _name = "cms.form.widget.radio" _inherit = "cms.form.widget.selection" _description = "CMS Form radio widget" - _w_template = "cms_form.field_widget_radio_selection" + + w_template = fields.Char(default="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 = {} + w_options_help = Serialized(default={}) def widget_init(self, form, fname, field, **kw): widget = super(RadioSelectionWidget, self).widget_init(form, fname, field, **kw) diff --git a/cms_form/models/widgets/widget_text.py b/cms_form/models/widgets/widget_text.py index f3d33ea8..2c29f8ad 100644 --- a/cms_form/models/widgets/widget_text.py +++ b/cms_form/models/widgets/widget_text.py @@ -1,22 +1,40 @@ # Copyright 2017 Simone Orsi # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). -from odoo import models +from odoo import fields, models + + +class CharWidgetMixin(models.AbstractModel): + _name = "cms.form.widget.char.mixin" + _inherit = "cms.form.widget.mixin" + _description = "CMS Form char widget" + + w_template = fields.Char(default="cms_form.field_widget_char") + w_input_type = fields.Char(default="text") + w_valid_pattern = fields.Char(help="Used to validate inputs with `pattern` attr") class CharWidget(models.AbstractModel): _name = "cms.form.widget.char" - _inherit = "cms.form.widget.mixin" + _inherit = "cms.form.widget.char.mixin" _description = "CMS Form char widget" - _w_template = "cms_form.field_widget_char" + + @property + def html_value(self): + return self.w_field_value.strip() if self.w_field_value else "" class TextWidget(models.AbstractModel): _name = "cms.form.widget.text" - _inherit = "cms.form.widget.mixin" - _w_template = "cms_form.field_widget_text" + _inherit = "cms.form.widget.char.mixin" _description = "CMS Form text widget" - w_maxlength = None + + w_template = fields.Char(default="cms_form.field_widget_text") + w_maxlength = fields.Integer() + + @property + def html_value(self): + return self.w_field_value.strip() if self.w_field_value else "" def widget_init(self, form, fname, field, **kw): widget = super().widget_init(form, fname, field, **kw) diff --git a/cms_form/models/widgets/widget_x2many.py b/cms_form/models/widgets/widget_x2many.py index 47aac4c7..79d4087c 100644 --- a/cms_form/models/widgets/widget_x2many.py +++ b/cms_form/models/widgets/widget_x2many.py @@ -3,21 +3,18 @@ import json -from odoo import models +from odoo import fields, models + +from ..fields import Serialized class X2MWidget(models.AbstractModel): _name = "cms.form.widget.x2m.mixin" - _inherit = "cms.form.widget.mixin" + _inherit = "cms.form.widget.rel.mixin" _description = "CMS Form X2M widget" - _w_template = "cms_form.field_widget_x2m" - w_diplay_field = "display_name" - def widget_init(self, form, fname, field, **kw): - widget = super().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 + w_template = fields.Char(default="cms_form.field_widget_x2m") + w_field_value = Serialized(default=[]) # TODO: rename all widget-specific methods like: # `x2many_to_form` -> `_w_orm_to_form` @@ -50,7 +47,7 @@ def x2many_to_form(self, value, **req_values): # request value take precedence ids = req_val[:] read_fields = [ - self.w_diplay_field, + self.w_display_field, ] if "name" in self.w_comodel: read_fields.append("name") @@ -62,7 +59,7 @@ def w_extract(self, **req_values): def form_to_x2many(self, value, **req_values): _value = False - if self.w_form._form_extract_value_mode == "write": + if self.w_form.form_extract_value_mode == "write": if value: _value = [(6, False, self.w_ids_from_input(value))] else: diff --git a/cms_form/static/src/js/ajax.js b/cms_form/static/src/js/ajax.js index c9ea0f57..a429e91f 100644 --- a/cms_form/static/src/js/ajax.js +++ b/cms_form/static/src/js/ajax.js @@ -1,6 +1,7 @@ -odoo.define("cms_form.ajax", function(require) { +odoo.define("cms_form.ajax", function (require) { "use strict"; + // FIXME: website dep var core = require("web.core"), animation = require("website.content.snippets.animation"); @@ -9,7 +10,7 @@ odoo.define("cms_form.ajax", function(require) { events: { submit: "submit_form", }, - start: function() { + start: function () { this.data = this.$el.data("form"); if (this.$el.data("ajax-onchange")) { this.$el.on("change", this.proxy("submit_form")); @@ -20,7 +21,7 @@ odoo.define("cms_form.ajax", function(require) { .find(".pagination a[href]") .on("click", this.proxy("pager")); }, - ajax_submit: function(additional_data) { + ajax_submit: function (additional_data) { return jQuery.ajax(_.str.sprintf("/cms/ajax/search/%s", this.data.model), { data: this.$el.serialize() + @@ -32,7 +33,7 @@ odoo.define("cms_form.ajax", function(require) { error: this.proxy("error"), }); }, - submit_form: function(ev) { + submit_form: function (ev) { var $container = this.$container(); jQuery.blockUI(); @@ -41,17 +42,17 @@ odoo.define("cms_form.ajax", function(require) { return this.ajax_submit(); }, - success: function(data) { + success: function (data) { jQuery.unblockUI(); var $container = this.$container(); $container.html(data.content); $container.find(".pagination a[href]").on("click", this.proxy("pager")); }, - error: function() { + error: function () { jQuery.unblockUI(); }, - pager: function(ev) { + pager: function (ev) { var $a = jQuery(ev.currentTarget); jQuery.blockUI(); @@ -59,7 +60,7 @@ odoo.define("cms_form.ajax", function(require) { return this.ajax_submit("&page=" + $a.data("page")); }, - $container: function() { + $container: function () { return this.$el .parents(".cms_form_wrapper") .find(this.data.form_content_selector); diff --git a/cms_form/static/src/js/date_widget.js b/cms_form/static/src/js/date_widget.js index 0057e6c7..f1c6db33 100644 --- a/cms_form/static/src/js/date_widget.js +++ b/cms_form/static/src/js/date_widget.js @@ -1,12 +1,13 @@ -odoo.define("cms_form.date_widget", function(require) { +odoo.define("cms_form.date_widget", function (require) { "use strict"; + // FIXME: website dep 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() { + 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( @@ -15,7 +16,7 @@ odoo.define("cms_form.date_widget", function(require) { this.load_options(); this.setup_datepicker(); }, - load_options: function() { + load_options: function () { // global options this.options = _.omit(this.$el.data("params"), "dp"); // Datepicker specific ones @@ -37,7 +38,7 @@ odoo.define("cms_form.date_widget", function(require) { } ); }, - setup_datepicker: function() { + setup_datepicker: function () { var self = this; var placeholder = this.$el.attr("placeholder"); // Placeholder empty: set default via lang format @@ -56,7 +57,7 @@ odoo.define("cms_form.date_widget", function(require) { // Init bootstrap-datetimepicker this.$el.datetimepicker(this.picker_options); this.picker = this.$el.data("DateTimePicker"); - this.$el.on("dp.change", function(e) { + 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 = ""; @@ -71,11 +72,11 @@ odoo.define("cms_form.date_widget", function(require) { this.$el .closest(".input-group") .find(".js_datepicker_trigger") - .click(function() { + .click(function () { self.picker.show(); }); }, - _init_date: function() { + _init_date: function () { var self = this; // Retrieve current date from real field var defaultDate = self.$realField.val(); @@ -88,7 +89,7 @@ odoo.define("cms_form.date_widget", function(require) { this.picker.date(defaultDate); } }, - destroy: function() { + destroy: function () { this.picker.destroy(); this._super.apply(this, arguments); }, diff --git a/cms_form/static/src/js/lock_copy_paste.js b/cms_form/static/src/js/lock_copy_paste.js index 512a9c97..e5661371 100644 --- a/cms_form/static/src/js/lock_copy_paste.js +++ b/cms_form/static/src/js/lock_copy_paste.js @@ -1,18 +1,19 @@ -odoo.define("cms_form.lock_copy_paste", function(require) { +odoo.define("cms_form.lock_copy_paste", function (require) { "use strict"; + // FIXME: website dep var sAnimation = require("website.content.snippets.animation"); sAnimation.registry.CMSFormLockCopyPaste = sAnimation.Class.extend({ selector: ".cms_form_wrapper form .lock_copy_paste", - start: function() { + start: function () { this.setup_handlers(); }, - setup_handlers: function() { - this.$el.bind("cut copy paste", function(e) { + setup_handlers: function () { + this.$el.bind("cut copy paste", function (e) { e.preventDefault(); }); - this.$el.on("contextmenu", function() { + this.$el.on("contextmenu", function () { return false; }); }, diff --git a/cms_form/static/src/js/master_slave.js b/cms_form/static/src/js/master_slave.js index dde4cc00..3cb7f2b6 100644 --- a/cms_form/static/src/js/master_slave.js +++ b/cms_form/static/src/js/master_slave.js @@ -1,4 +1,4 @@ -odoo.define("cms_form.master_slave", function(require) { +odoo.define("cms_form.master_slave", function (require) { "use strict"; /* Handle master / slave fields automatically. @@ -7,16 +7,17 @@ odoo.define("cms_form.master_slave", function(require) { // TODO: this does not work ATM :( // var pyeval = require('web.pyeval'); + // FIXME: website dep var sAnimation = require("website.content.snippets.animation"); sAnimation.registry.CMSFormMasterSlave = sAnimation.Class.extend({ selector: ".cms_form_wrapper form", - start: function() { + start: function () { this.data = this.$el.data("form"); this.setup_handlers(); this.load_master_slave(); }, - setup_handlers: function() { + setup_handlers: function () { this.handlers = { hide: $.proxy(this.handle_hide, this), show: $.proxy(this.handle_show, this), @@ -26,22 +27,22 @@ odoo.define("cms_form.master_slave", function(require) { no_required: $.proxy(this.handle_no_required, this), }; }, - load_master_slave: function() { + load_master_slave: function () { var self = this; - $.each(this.data.master_slave, function(master, slaves) { + $.each(this.data.master_slave, function (master, slaves) { var $master_input = $('[name="' + master + '"]'); - $.each(slaves, function(action, mapping) { + $.each(slaves, function (action, mapping) { var handler = self.handlers[action]; if (handler) { $master_input - .on("change", function() { + .on("change", function () { var $input = $(this), val = $input.val(); if ($input.is(":checkbox")) { // Value == 'on' => true/false val = $input.is(":checked"); } - $.each(mapping, function(slave_fname, values) { + $.each(mapping, function (slave_fname, values) { if (_.contains(values, val)) { handler(slave_fname); } @@ -56,33 +57,35 @@ odoo.define("cms_form.master_slave", function(require) { }); }); }, - handle_hide: function(slave_fname) { - $('[name="' + slave_fname + '"]') - .closest(".form-group") - .hide(); + handle_hide: function (slave_fname) { + $(".field-" + slave_fname).hide(); }, - handle_show: function(slave_fname) { - $('[name="' + slave_fname + '"]') - .closest(".form-group") - .show(); + handle_show: function (slave_fname) { + $(".field-" + slave_fname).show(); }, - handle_readonly: function(slave_fname) { + handle_readonly: function (slave_fname) { $('[name="' + slave_fname + '"]') .attr("disabled", "disabled") .closest(".form-group") .addClass("disabled"); }, - handle_no_readonly: function(slave_fname) { + handle_no_readonly: function (slave_fname) { $('[name="' + slave_fname + '"]') .attr("disabled", null) .closest(".form-group") .removeClass("disabled"); }, - handle_required: function(slave_fname) { - $('[name="' + slave_fname + '"]').attr("required", "required"); + handle_required: function (slave_fname) { + $('[name="' + slave_fname + '"]') + .attr("required", "required") + .closest(".form-group") + .addClass("field-required"); }, - handle_no_required: function(slave_fname) { - $('[name="' + slave_fname + '"]').attr("required", null); + handle_no_required: function (slave_fname) { + $('[name="' + slave_fname + '"]') + .attr("required", null) + .closest(".form-group") + .removeClass("field-required"); }, }); }); diff --git a/cms_form/static/src/js/select2widgets.js b/cms_form/static/src/js/select2widgets.js index 3a1c0f30..caf30bbf 100644 --- a/cms_form/static/src/js/select2widgets.js +++ b/cms_form/static/src/js/select2widgets.js @@ -1,4 +1,4 @@ -odoo.define("cms_form.select2widgets", function(require) { +odoo.define("cms_form.select2widgets", function (require) { "use strict"; var ajax = require("web.ajax"); @@ -10,14 +10,14 @@ odoo.define("cms_form.select2widgets", function(require) { return $.Deferred().reject("DOM doesn't contain '.js_select2_m2m_widget'"); } - $(document).ready(function() { - $("input.js_select2_m2m_widget").each(function() { + $(document).ready(function () { + $("input.js_select2_m2m_widget").each(function () { var $input = $(this); $input.select2({ multiple: true, tags: true, tokenSeparators: [",", " ", "_"], - formatResult: function(term) { + formatResult: function (term) { var formatted = _.escape(term.text); if (term.isNew) { formatted = @@ -25,7 +25,7 @@ odoo.define("cms_form.select2widgets", function(require) { } return formatted; }, - query: function(options) { + query: function (options) { var domain = []; if (options.term) { domain.push([ @@ -46,15 +46,15 @@ odoo.define("cms_form.select2widgets", function(require) { fields: $input.data("fields"), context: weContext.get(), }, - }).then(function(data) { + }).then(function (data) { var display_name = $input.data("display_name"); - data.sort(function(a, b) { + data.sort(function (a, b) { return a[display_name].localeCompare(b[display_name]); }); var res = { results: [], }; - _.each(data, function(x) { + _.each(data, function (x) { res.results.push({ id: x.id, text: x[display_name], @@ -65,9 +65,9 @@ odoo.define("cms_form.select2widgets", function(require) { }); }, // Default tags from the input value - initSelection: function(element, callback) { + initSelection: function (element, callback) { var data = []; - _.each(element.data("init-value"), function(x) { + _.each(element.data("init-value"), function (x) { data.push({ id: x.id, text: x.name, diff --git a/cms_form/static/src/js/textarea_widget.js b/cms_form/static/src/js/textarea_widget.js index 92424201..c3b6e075 100644 --- a/cms_form/static/src/js/textarea_widget.js +++ b/cms_form/static/src/js/textarea_widget.js @@ -1,11 +1,11 @@ -odoo.define("cms_form.textarea_widget", function(require) { +odoo.define("cms_form.textarea_widget", function (require) { "use strict"; require("web.dom_ready"); - $(document).ready(function() { + $(document).ready(function () { $("textarea[maxlength]") - .bind("input propertychange", function() { + .bind("input propertychange", function () { var $self = $(this), maxlength = parseInt($self.attr("maxlength"), 10), length = $self.val().length, diff --git a/cms_form/templates/assets.xml b/cms_form/templates/assets.xml deleted file mode 100644 index 2fc66be2..00000000 --- a/cms_form/templates/assets.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - -