From 0fe31993d7b0609b23a32a2f9539e35b5862c8e7 Mon Sep 17 00:00:00 2001 From: Sayaka Yamada Date: Mon, 29 Sep 2025 14:27:05 +0000 Subject: [PATCH 1/2] [5756][ADD] web_form_banner --- .../odoo/addons/web_form_banner | 1 + setup/web_form_banner/setup.py | 6 + web_form_banner/README.rst | 271 ++++++++ web_form_banner/__init__.py | 1 + web_form_banner/__manifest__.py | 23 + .../demo/web_form_banner_rule_demo.xml | 64 ++ web_form_banner/i18n/ja.po | 348 ++++++++++ web_form_banner/models/__init__.py | 2 + web_form_banner/models/ir_model.py | 69 ++ .../models/web_form_banner_rule.py | 288 ++++++++ web_form_banner/readme/CONTRIBUTORS.rst | 4 + web_form_banner/readme/DESCRIPTION.rst | 8 + web_form_banner/readme/ROADMAP.rst | 17 + web_form_banner/readme/USAGE.rst | 160 +++++ web_form_banner/security/ir.model.access.csv | 3 + web_form_banner/static/description/index.html | 619 ++++++++++++++++++ .../static/src/js/web_form_banner.esm.js | 198 ++++++ .../static/src/scss/web_form_banner.scss | 16 + web_form_banner/tests/__init__.py | 1 + web_form_banner/tests/test_web_form_banner.py | 177 +++++ .../views/web_form_banner_rule_views.xml | 211 ++++++ 21 files changed, 2487 insertions(+) create mode 120000 setup/web_form_banner/odoo/addons/web_form_banner create mode 100644 setup/web_form_banner/setup.py create mode 100644 web_form_banner/README.rst create mode 100644 web_form_banner/__init__.py create mode 100644 web_form_banner/__manifest__.py create mode 100644 web_form_banner/demo/web_form_banner_rule_demo.xml create mode 100644 web_form_banner/i18n/ja.po create mode 100644 web_form_banner/models/__init__.py create mode 100644 web_form_banner/models/ir_model.py create mode 100644 web_form_banner/models/web_form_banner_rule.py create mode 100644 web_form_banner/readme/CONTRIBUTORS.rst create mode 100644 web_form_banner/readme/DESCRIPTION.rst create mode 100644 web_form_banner/readme/ROADMAP.rst create mode 100644 web_form_banner/readme/USAGE.rst create mode 100644 web_form_banner/security/ir.model.access.csv create mode 100644 web_form_banner/static/description/index.html create mode 100644 web_form_banner/static/src/js/web_form_banner.esm.js create mode 100644 web_form_banner/static/src/scss/web_form_banner.scss create mode 100644 web_form_banner/tests/__init__.py create mode 100644 web_form_banner/tests/test_web_form_banner.py create mode 100644 web_form_banner/views/web_form_banner_rule_views.xml diff --git a/setup/web_form_banner/odoo/addons/web_form_banner b/setup/web_form_banner/odoo/addons/web_form_banner new file mode 120000 index 0000000..160f172 --- /dev/null +++ b/setup/web_form_banner/odoo/addons/web_form_banner @@ -0,0 +1 @@ +../../../../web_form_banner \ No newline at end of file diff --git a/setup/web_form_banner/setup.py b/setup/web_form_banner/setup.py new file mode 100644 index 0000000..28c57bb --- /dev/null +++ b/setup/web_form_banner/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/web_form_banner/README.rst b/web_form_banner/README.rst new file mode 100644 index 0000000..559ce48 --- /dev/null +++ b/web_form_banner/README.rst @@ -0,0 +1,271 @@ +=============== +Web Form Banner +=============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:abc8ad462d827f2f1b07f5fed63fe06f86ee2a9121f5a0c0e77f1ae8607ef8f1 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github + :target: https://github.com/OCA/web/tree/15.0/web_form_banner + :alt: OCA/web +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/web-15-0/web-15-0-web_form_banner + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/web&target_branch=15.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +The module adds configurable banners for backend **form** views. Define rules per model +(and optionally per view) to show context-aware alerts with a chosen severity (info/warning/danger). + +Messages can be plain text with ${placeholders} or fully custom HTML; visibility, +severity, and values are computed server-side via a safe Python expression. + +Banners are injected just before or after a target node (default: //sheet) and refresh +on form load/save/reload. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +#. Go to *Settings > Technical > User Interface > Form Banner Rules* and create a rule. +#. Choose Model, select Trigger Fields (optional), set Default Severity, select Views + (optional), update Target XPath (insertion point) and Position, and configure the + message. +#. Save. Open any matching form record—the banner will appear and auto-refresh after + load/save/reload. + +Usage of message fields: +~~~~~~~~~~~~~~~~~~~~~~~~ + +* **Message** (message): Text shown in the banner. Supports `${placeholders}` filled + from values returned by message_value_code. Ignored if message_value_code returns an + `html` value. +* **HTML** (message_is_html): If enabled, the message string is rendered as HTML; + otherwise it's treated as plain text. +* **Message Value Code** (message_value_code): Safe Python expression evaluated per + record. Return a dict such as `{"visible": True, "severity": "warning", "values": {"name": record.name}}`. + Use either message or `html` (from this code), not both. Several evaluation context + variables are available. + +Evaluation context variables available in Message Value Code: +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* `env`: Odoo environment for ORM access. +* `user`: Current user (`env.user`). +* `ctx`: Copy of the current context (`dict(env.context)`). +* `record`: Current record (the form's record). +* `draft`: The persisted field values of the ORM record (before applying the current + form's unsaved changes) + the current unsaved changes on trigger fields. + Should be used instead of `record` when your rule is triggered dynamically by an + update to a trigger field. It doesn't include any values from complex fields + (one2many/reference, etc). +* `record_id`: Integer id of the record being edited, or `False` if the form + is creating a new record. +* `model`: Shortcut to the current model (`env[record._name]`). +* `url_for(obj)`: Helper that returns a backend form URL for `obj`. +* `context_today(ts=None)`: User-timezone “today” (date) for reliable date comparisons. +* `time`, `datetime`: Standard Python time/datetime modules. +* `dateutil`: `{ "parser": dateutil.parser, "relativedelta": dateutil.relativedelta }` +* `timezone`: `pytz.timezone` for TZ handling. +* `float_compare`, `float_is_zero`, `float_round`: Odoo float utils for precision-safe + comparisons/rounding. + +All of the above are injected by the module to the safe_eval locals. + +Trigger Fields +~~~~~~~~~~~~~~ + +*Trigger Fields* is an optional list of model fields that, when changed in the open +form, cause the banner to **recompute live**. If left empty, the banner does **not** +auto-refresh as the user edits the form. + +When a trigger fires, the module sends the current draft values to the server, sanitizes +them, builds an evaluation record, and re-runs your `message_value_code`. + +You should use `draft` instead of `record` to access the current form values if your +rule is triggered based on an update to a trigger field. + +Message setting examples: +~~~~~~~~~~~~~~~~~~~~~~~~~ + +**A) Missing email on contact (warning)** + +* Model: `res.partner` +* Message: `This contact has no email.` +* Message Value Code: + +.. code-block:: python + + {"visible": not bool(record.email)} + +**B) Show partner comment if available** + +* Model: `purchase.order` +* Message: `Vendor Comments: ${comment}` +* Message Value Code (single expression): + +.. code-block:: python + + { + "visible": bool(record.partner_id.comment), + "values": {"comment": record.partner_id.comment}, + } + +It is also possible to use "convenience placeholders" without an explicit `values` key: + +.. code-block:: python + + { + "visible": bool(record.partner_id.comment), + "comment": record.partner_id.comment, + } + +**C) High-value sale order (dynamic severity)** + +* Model: `sale.order` +* Message: `High-value order: ${amount_total}` +* Message Value Code: + +.. code-block:: python + + { + "visible": record.amount_total >= 30000, + "severity": "danger" if record.amount_total >= 100000 else "warning", + "values": {"amount_total": record.amount_total}, + } + +**D) Quotation past validity date** + +* Model: `sale.order` +* Message: `This quotation is past its validity date (${validity_date}).` +* Message Value Code: + +.. code-block:: python + + { + "visible": bool(record.validity_date and context_today() > record.validity_date and record.state in ["draft", "sent"]), + "values": {"validity_date": record.validity_date}, + } + +**E) Pending activities on a task (uses `env`)** + +* Model: `project.task` +* Message: `There are ${cnt} pending activities.` +* Message Value Code (multi-line with `result`): + +.. code-block:: python + + cnt = env["mail.activity"].search_count([("res_model","=",record._name),("res_id","=",record.id)]) + result = {"visible": cnt > 0, "values": {"cnt": cnt}} + +**F) Product is missing internal reference (uses trigger fields)** + +* Model: `product.template` +* Trigger Fields: `default_code` +* Message: `Make sure to set an internal reference!` +* Message Value Code: + +.. code-block:: python + + {"visible": not bool(draft.default_code)} + +**G) HTML banner linking to the customer's last sales order (uses trigger fields)** + +* Model: `sale.order` +* Trigger Fields: `partner_id` +* Message: (leave blank; `html` provided by Message Value Code) +* Message Value Code (multi-line with `result`): + +.. code-block:: python + + domain = [("partner_id", "=", draft.partner_id.id)] + if record_id: + domain += [("id", "<", record_id)] + last = model.search(domain, order="date_order desc, id desc", limit=1) + if last: + html = "Previous order: %s" % (url_for(last), last.name) + result = {"visible": True, "html": html} + else: + result = {"visible": False} + +Known issues / Roadmap +====================== + +Banner presentation inside `` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Placing a full-width inline banner inside `` is currently not supported. The +presentation of the banner and the child fields will be distorted. + +Limitations of `draft` eval context variable +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* `draft` is always available in the eval context, but for new records (`record_id` = + `False`) it only contains the trigger fields from the banner rules. +* For existing records, `draft` overlays the trigger field values on top of the + persisted record; all other fields come from `Model.new` defaults rather than the + database. +* Only simple field types are included: `char`, `text`, `html`, `selection`, `boolean`, + `integer`, `float`, `monetary`, `date`, `datetime`, `many2one`, and `many2many`. + **one2many/reference/other types are omitted.** + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Quartile + +Contributors +~~~~~~~~~~~~ + +* `Quartile `_: + + * Yoshi Tashiro + * Aung Ko Ko Lin + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/web `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/web_form_banner/__init__.py b/web_form_banner/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/web_form_banner/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/web_form_banner/__manifest__.py b/web_form_banner/__manifest__.py new file mode 100644 index 0000000..fca46c4 --- /dev/null +++ b/web_form_banner/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2025 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Web Form Banner", + "version": "15.0.1.0.0", + "category": "Web", + "author": "Quartile, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/web", + "license": "AGPL-3", + "depends": ["web"], + "data": [ + "security/ir.model.access.csv", + "views/web_form_banner_rule_views.xml", + ], + "assets": { + "web.assets_backend": [ + "web_form_banner/static/src/js/*.esm.js", + "web_form_banner/static/src/scss/*.scss", + ], + }, + "demo": ["demo/web_form_banner_rule_demo.xml"], + "installable": True, +} diff --git a/web_form_banner/demo/web_form_banner_rule_demo.xml b/web_form_banner/demo/web_form_banner_rule_demo.xml new file mode 100644 index 0000000..6fdb7bf --- /dev/null +++ b/web_form_banner/demo/web_form_banner_rule_demo.xml @@ -0,0 +1,64 @@ + + + + Partner name length notice + + warning + //sheet + before + 20: + result = { + "visible": True, + "severity": "danger", + "html": "This partner's name is very long! (length: %d)" % n, + } +elif n > 10: + result = { + "visible": True, + "severity": "warning", + "html": "This partner's name is a bit long. (length: %d)" % n, + } +else: + result = {"visible": False} + ]]> + + + Partner email missing notice (dynamic) + + + warning + //sheet + before + This partner is missing email! + + + + Partner tag missing notice (dynamic) + + + warning + //sheet + before + This partner is missing a tag! + + + diff --git a/web_form_banner/i18n/ja.po b/web_form_banner/i18n/ja.po new file mode 100644 index 0000000..2e075ec --- /dev/null +++ b/web_form_banner/i18n/ja.po @@ -0,0 +1,348 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * web_form_banner +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-09-20 09:53+0000\n" +"PO-Revision-Date: 2025-09-20 09:53+0000\n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "//sheet" +msgstr "" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "\n" +"domain = [(\"partner_id\", \"=\", draft.partner_id.id)]\n" +"if record_id:\n" +" domain += [(\"id\", \"&lt;\", record_id)]\n" +"last = model.search(domain, order=\"date_order desc, id desc\", limit=1)\n" +"if last:\n" +" html = \"&lt;strong&gt;Previous order:&lt;/strong&gt; &lt;a href='%s'&gt;%s&lt;/a&gt;\" % (url_for(last), last.name)\n" +" result = {\"visible\": True, \"html\": html}\n" +"else:\n" +" result = {\"visible\": False}\n" +"" +msgstr "" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "context_today(ts=None): User-timezone “today” (date) for reliable date comparisons." +msgstr "" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "ctx: Copy of the current context (dict(env.context))." +msgstr "" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "current_id: Integer id of the record being edited, or False if the form\n" +" is creating a new record." +msgstr "" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "dateutil: { \"parser\": dateutil.parser, \"relativedelta\": dateutil.relativedelta }" +msgstr "" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "draft: The persisted field values of the ORM record (before applying the current\n" +" form's unsaved changes) + the current unsaved changes on trigger fields.\n" +" Should be used instead of record when your rule is triggered dynamically by an\n" +" update to a trigger field. It doesn't include any values from complex fields\n" +" (x2many/reference, etc)." +msgstr "" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "env: Odoo environment for ORM access." +msgstr "" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "float_compare, float_is_zero, float_round: Odoo float utils for precision-safe comparisons/rounding." +msgstr "" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "model: Shortcut to the current model (env[record._name])." +msgstr "" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "record: Current record (the form's record)." +msgstr "" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "time, datetime: Standard Python time/datetime modules." +msgstr "" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "timezone: pytz.timezone for TZ handling." +msgstr "" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "url_for(obj): Helper that returns a backend form URL for obj." +msgstr "" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "user: Current user (env.user)." +msgstr "" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__active +msgid "Active" +msgstr "有効" + +#. module: web_form_banner +#: selection:web.form.banner.rule,position:0 +msgid "After target" +msgstr "ターゲットの後" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_search +msgid "Archived" +msgstr "アーカイブ済" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "Available evaluation context variables are as follows:" +msgstr "" + +#. module: web_form_banner +#: model:ir.model,name:web_form_banner.model_base +msgid "Base" +msgstr "ベース" + +#. module: web_form_banner +#: selection:web.form.banner.rule,position:0 +msgid "Before target" +msgstr "ターゲットの前" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__create_uid +msgid "Created by" +msgstr "作成者" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__create_date +msgid "Created on" +msgstr "作成日" + +#. module: web_form_banner +#: selection:web.form.banner.rule,severity:0 +msgid "Danger" +msgstr "危険" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__severity +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_search +msgid "Default Severity" +msgstr "デフォルト重要度" + +#. module: web_form_banner +#: model:ir.model.fields,help:web_form_banner.field_web_form_banner_rule__severity +msgid "Default severity level, can be overridden per-record." +msgstr "デフォルト重要度。レコード毎に上書き可" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__display_name +msgid "Display Name" +msgstr "表示名" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "Example of Message Value Code (model: sale.order)" +msgstr "メッセージ値コードの例 (モデル: sale.order)" + +#. module: web_form_banner +#: model:ir.model,name:web_form_banner.model_web_form_banner_rule +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "Form Banner Rule" +msgstr "フォームバナー規則" + +#. module: web_form_banner +#: model:ir.actions.act_window,name:web_form_banner.action_web_form_banner_rule +#: model:ir.ui.menu,name:web_form_banner.menu_web_form_banner_rules +msgid "Form Banner Rules" +msgstr "フォームバナー規則" + +#. module: web_form_banner +#: model:ir.model.fields,help:web_form_banner.field_web_form_banner_rule__view_ids +msgid "Form view where the banner should be injected." +msgstr "バナーを表示するフォームビュー" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_search +msgid "Group By" +msgstr "グループ化" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__message_is_html +msgid "HTML" +msgstr "" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "Help" +msgstr "ヘルプ" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "Help for Message Valude Code" +msgstr "メッセージ値コードのヘルプ" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__id +msgid "ID" +msgstr "" + +#. module: web_form_banner +#: model:ir.model.fields,help:web_form_banner.field_web_form_banner_rule__message_is_html +msgid "If checked, 'message' is treated as raw HTML (no escaping). If not checked, the rendered text is escaped and newlines become
." +msgstr "" + +#. module: web_form_banner +#: model:ir.model.fields,help:web_form_banner.field_web_form_banner_rule__trigger_field_ids +msgid "If set, the banner recomputes live when any of these fields change." +msgstr "" + +#. module: web_form_banner +#: selection:web.form.banner.rule,severity:0 +msgid "Info" +msgstr "情報" + +#. module: web_form_banner +#: code:addons/web_form_banner/models/web_form_banner_rule.py:121 +#, python-format +msgid "Invalid XPath:\n" +"%s" +msgstr "無効なXPath:\n" +"%s" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule____last_update +msgid "Last Modified on" +msgstr "最終更新日" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__write_uid +msgid "Last Updated by" +msgstr "最終更新者" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__write_date +msgid "Last Updated on" +msgstr "最終更新日" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__message +msgid "Message" +msgstr "メッセージ" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__message_value_code +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "Message Value Code" +msgstr "メッセージ値コード" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__model_id +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__model_name +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_search +msgid "Model" +msgstr "モデル" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__name +msgid "Name" +msgstr "名称" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__position +msgid "Position" +msgstr "位置" + +#. module: web_form_banner +#: model:ir.model.fields,help:web_form_banner.field_web_form_banner_rule__message_value_code +msgid "Python expression evaluated server-side. Must return a dict.\n" +"Keys: visible(bool, default True), severity(str), values(dict for ${...} in \n" +"message), and/or html(str) to override template rendering." +msgstr "" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "Recompute on change (new forms)" +msgstr "" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__sequence +msgid "Sequence" +msgstr "付番" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__target_xpath +msgid "Target XPath" +msgstr "ターゲットXPath" + +#. module: web_form_banner +#: model:ir.model.fields,help:web_form_banner.field_web_form_banner_rule__message +msgid "Template with ${placeholders}. If not HTML, it will be escaped." +msgstr "${placeholders} が使えるテンプレート。HTMLでない場合はエスケープされます。" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "This is a ${severity} message." +msgstr "これは ${severity} メッセージです。" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__trigger_field_ids +msgid "Trigger Fields" +msgstr "トリガ項目" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__view_ids +msgid "Views" +msgstr "ビュー" + +#. module: web_form_banner +#: selection:web.form.banner.rule,severity:0 +msgid "Warning" +msgstr "警告" + +#. module: web_form_banner +#: model:ir.model.fields,help:web_form_banner.field_web_form_banner_rule__position +msgid "Where to insert the placeholder relative to the first matched node." +msgstr "マッチした1つ目のノードへのプレイスホルダーの相対挿入位置" + +#. module: web_form_banner +#: model:ir.model.fields,help:web_form_banner.field_web_form_banner_rule__target_xpath +msgid "XPath of the node to insert the banner." +msgstr "バナー挿入対象ノードのXPath" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "e.g. Warning on dangeours customers" +msgstr "例: 危険な顧客の警告" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_tree +msgid "return {'visible': True, 'values': {'title': '...'}}" +msgstr "" + diff --git a/web_form_banner/models/__init__.py b/web_form_banner/models/__init__.py new file mode 100644 index 0000000..96e1670 --- /dev/null +++ b/web_form_banner/models/__init__.py @@ -0,0 +1,2 @@ +from . import ir_model +from . import web_form_banner_rule diff --git a/web_form_banner/models/ir_model.py b/web_form_banner/models/ir_model.py new file mode 100644 index 0000000..26fb8d1 --- /dev/null +++ b/web_form_banner/models/ir_model.py @@ -0,0 +1,69 @@ +# Copyright 2025 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from lxml import etree + +from odoo import api, models + + +class Base(models.AbstractModel): + _inherit = "base" + + @api.model + def fields_view_get( + self, view_id=None, view_type="form", toolbar=False, submenu=False + ): + res = super().fields_view_get( + view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu + ) + if view_type != "form" or not res.get("arch"): + return res + current_view_id = view_id or res.get("view_id") + if not current_view_id: + return res + rules = ( + self.env["web.form.banner.rule"] + .sudo() + .search( + [ + ("model_name", "=", self._name), + "|", + ("view_ids", "in", current_view_id), + ("view_ids", "=", False), + ] + ) + ) + if not rules: + return res + try: + root = etree.fromstring(res["arch"]) + except Exception: + return res + for rule in rules: + target = root.xpath(rule.target_xpath or "//sheet") + if not target: + continue + # Lightweight placeholder; JS will fill and toggle visibility + css = "o_form_banner alert alert-%s" % (rule.severity or "danger") + trigger_fields = ",".join(rule.trigger_field_ids.mapped("name")) + node = etree.Element( + "div", + { + "class": css, + "role": "alert", + "data-rule-id": str(rule.id), + "data-model": self._name, + "data-default-severity": (rule.severity or "danger"), + "data-trigger-fields": trigger_fields, + "style": "display:none;", + }, + ) + parent = target[0].getparent() + if parent is None: + continue + if rule.position == "before": + parent.insert(parent.index(target[0]), node) + else: + target[0].addnext(node) + res["arch"] = etree.tostring(root, encoding="unicode") + return res diff --git a/web_form_banner/models/web_form_banner_rule.py b/web_form_banner/models/web_form_banner_rule.py new file mode 100644 index 0000000..a499509 --- /dev/null +++ b/web_form_banner/models/web_form_banner_rule.py @@ -0,0 +1,288 @@ +# Copyright 2025 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +from functools import lru_cache +from string import Template + +from dateutil import parser as dateparse +from dateutil.relativedelta import relativedelta +from lxml import etree +from pytz import timezone + +from odoo import _, api, fields, models, tools +from odoo.exceptions import ValidationError +from odoo.tools import html_escape +from odoo.tools.float_utils import float_compare, float_is_zero, float_round +from odoo.tools.safe_eval import safe_eval + +_logger = logging.getLogger(__name__) + + +_SIMPLE_FIELD_TYPES = frozenset( + { + "char", + "text", + "html", + "selection", + "boolean", + "integer", + "float", + "monetary", + "date", + "datetime", + } +) + + +def _extract_m2o_id(v): + """Normalize many2one values to an integer id or False.""" + if isinstance(v, int): + return v + if isinstance(v, (list, tuple)) and v and isinstance(v[0], int): + return v[0] + if isinstance(v, dict): + m2o_id = v.get("res_id") or v.get("id") + if isinstance(m2o_id, int): + return m2o_id + return False + + +def _m2m_items(value): + if isinstance(value, (list, tuple)): + return value + if isinstance(value, dict): + if isinstance(value.get("res_ids"), (list, tuple)): + return value["res_ids"] + if isinstance(value.get("data"), (list, tuple)): + return value["data"] + return None + + +def _to_int_id(e): + if isinstance(e, int): + return e + if isinstance(e, str) and e.isdigit(): + return int(e) + if isinstance(e, dict): + rid = e.get("res_id") + if isinstance(rid, int): + return rid + iid = e.get("id") + if isinstance(iid, int): + return iid + return None + + +def _sanitize_field(field, value): + """Return sanitized value for a single field, or None to skip.""" + if not field: + return None + if field.type == "many2one": + return _extract_m2o_id(value) + if field.type == "many2many": + items = _m2m_items(value) + if items is None: + return None + ids = [i for i in (_to_int_id(e) for e in items) if i is not None] + # Always return a command, even when empty, to reflect clearing the relation + return [(6, 0, ids)] + if field.type in _SIMPLE_FIELD_TYPES: + return value + return None # skip one2many/reference/others + + +class WebFormBannerRule(models.Model): + _name = "web.form.banner.rule" + _description = "Form Banner Rule" + _order = "sequence, id" + + name = fields.Char(required=True) + model_id = fields.Many2one("ir.model", ondelete="cascade", required=True) + model_name = fields.Char(related="model_id.model", store=True, readonly=True) + view_ids = fields.Many2many( + "ir.ui.view", + string="Views", + domain="[('type', '=', 'form'), ('model', '=', model_name)]", + help="Form view where the banner should be injected.", + ) + target_xpath = fields.Char( + "Target XPath", + default="//sheet", + help="XPath of the node to insert the banner.", + ) + position = fields.Selection( + [("before", "Before target"), ("after", "After target")], + default="before", + required=True, + help="Where to insert the placeholder relative to the first matched node.", + ) + severity = fields.Selection( + [("info", "Info"), ("warning", "Warning"), ("danger", "Danger")], + string="Default Severity", + default="danger", + required=True, + help="Default severity level, can be overridden per-record.", + ) + message = fields.Text( + translate=True, + help="Template with ${placeholders}. If not HTML, it will be escaped.", + ) + message_is_html = fields.Boolean( + "HTML", + help="If checked, 'message' is treated as raw HTML (no escaping). " + "If not checked, the rendered text is escaped and newlines become
.", + ) + message_value_code = fields.Text( + help="Python expression evaluated server-side. Must return a dict.\n" + "Keys: visible(bool, default True), severity(str), values(dict for ${...} in \n" + "message), and/or html(str) to override template rendering.", + ) + sequence = fields.Integer(default=10) + active = fields.Boolean(default=True) + trigger_field_ids = fields.Many2many( + "ir.model.fields", + "web_form_banner_rule_trigger_field_rel", + domain="[('model', '=', model_name)]", + string="Trigger Fields", + help="If set, the banner recomputes live when any of these fields change.", + ) + + @api.constrains("target_xpath") + def _check_target_xpath(self): + for rec in self: + xp = (rec.target_xpath or "").strip() + try: + etree.XPath(xp or "//sheet") + except (etree.XPathSyntaxError, etree.XPathEvalError) as e: + raise ValidationError(_("Invalid XPath:\n%s") % e) from e + + @api.model + def _build_form_url(self, rec): + try: + if not rec or not getattr(rec, "id", None): + return "" + base = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("web.base.url", default="") + ) + return "%s/web#id=%d&model=%s&view_type=form" % (base, rec.id, rec._name) + except Exception: + _logger.exception("Failed building form URL for %s", rec) + return "" + + @lru_cache(maxsize=1) + def _base_eval_ctx_static(self): + # Only static, import-heavy items + return { + "time": tools.safe_eval.time, + "datetime": tools.safe_eval.datetime, + "dateutil": { + "parser": dateparse, + "relativedelta": relativedelta, + }, + "timezone": timezone, + "float_compare": float_compare, + "float_is_zero": float_is_zero, + "float_round": float_round, + } + + @api.model + def _get_eval_context(self, record): + eval_ctx = dict(self._base_eval_ctx_static()) + eval_ctx.update( + { + "env": record.env, + "user": record.env.user, + "ctx": dict(record.env.context), + "model": record.env[record._name], + "record": record, + "context_today": lambda ts=None: fields.Date.context_today( + record, timestamp=ts + ), + "url_for": self._build_form_url, + } + ) + return eval_ctx + + @api.model + def _sanitize_values(self, model, form_vals): + """Return a sanitized dict of simple field values safe for new()/eval.""" + flds = self.env[model]._fields + out = {} + for name, value in (form_vals or {}).items(): + sv = _sanitize_field(flds.get(name), value) + if sv is not None: + out[name] = sv + return out + + @api.model + def _build_eval_record(self, model, res_id, vals): + """Return (draft, persisted, record_id) for eval context.""" + Model = self.env[model] + vals = vals or {} + if res_id: + persisted = Model.browse(int(res_id)) + base_vals = persisted.read(list(vals.keys()))[0] if vals else {} + draft = Model.new({**base_vals, **vals}) + return draft, persisted, persisted.id + # new record (no res_id yet): persisted is an empty recordset, not None + return Model.new(vals), Model, False + + @api.model + def _run_rule_code(self, rule, eval_ctx): + """Execute message_value_code and return a dict or {}.""" + if not rule.message_value_code: + return {} + code = rule.message_value_code.strip() + try: + out = safe_eval(code, eval_ctx, mode="eval") or {} + except Exception: + safe_eval(code, eval_ctx, mode="exec", nocopy=True) + out = eval_ctx.get("result") or {} + return out if isinstance(out, dict) else {} + + @api.model + def _render_html(self, rule, values, html): + """Render final HTML from template if not already provided.""" + if html: + return html + tpl = Template(rule.message or "") + try: + rendered = tpl.safe_substitute(values) + except Exception: + rendered = rule.message or "" + if rule.message_is_html: + return rendered + return html_escape(rendered).replace("\n", "
") + + @api.model + def compute_message(self, rule_id, model, res_id, form_vals=None): + """Return {visible, severity, html} for the given rule and record.""" + lang = self._context.get("lang") or self.env.user.lang + self = self.with_context(lang=lang) + rule = self.browse(int(rule_id)).sudo() + if not rule.exists() or not rule.active: + return {"visible": False} + values = self._sanitize_values(model, form_vals) + draft, record, record_id = self._build_eval_record(model, res_id, values) + eval_ctx = self._get_eval_context(record) + eval_ctx.update( + { + "draft": draft, # DB base + simple field overrides + "record_id": record_id, + } + ) + out = self._run_rule_code(rule, eval_ctx) or {} + severity = out.get("severity", rule.severity or "danger") + visible = out.get("visible", True) + if not visible: + return {"visible": False} + values = out.get("values") or { + k: v + for k, v in out.items() + if k not in {"visible", "severity", "values", "html"} + } + html = self._render_html(rule, values, out.get("html")) + return {"visible": True, "severity": severity, "html": html} diff --git a/web_form_banner/readme/CONTRIBUTORS.rst b/web_form_banner/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..0ec4343 --- /dev/null +++ b/web_form_banner/readme/CONTRIBUTORS.rst @@ -0,0 +1,4 @@ +* `Quartile `_: + + * Yoshi Tashiro + * Aung Ko Ko Lin diff --git a/web_form_banner/readme/DESCRIPTION.rst b/web_form_banner/readme/DESCRIPTION.rst new file mode 100644 index 0000000..9f0a3c2 --- /dev/null +++ b/web_form_banner/readme/DESCRIPTION.rst @@ -0,0 +1,8 @@ +The module adds configurable banners for backend **form** views. Define rules per model +(and optionally per view) to show context-aware alerts with a chosen severity (info/warning/danger). + +Messages can be plain text with ${placeholders} or fully custom HTML; visibility, +severity, and values are computed server-side via a safe Python expression. + +Banners are injected just before or after a target node (default: //sheet) and refresh +on form load/save/reload. diff --git a/web_form_banner/readme/ROADMAP.rst b/web_form_banner/readme/ROADMAP.rst new file mode 100644 index 0000000..fe7335d --- /dev/null +++ b/web_form_banner/readme/ROADMAP.rst @@ -0,0 +1,17 @@ +Banner presentation inside `` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Placing a full-width inline banner inside `` is currently not supported. The +presentation of the banner and the child fields will be distorted. + +Limitations of `draft` eval context variable +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* `draft` is always available in the eval context, but for new records (`record_id` = + `False`) it only contains the trigger fields from the banner rules. +* For existing records, `draft` overlays the trigger field values on top of the + persisted record; all other fields come from `Model.new` defaults rather than the + database. +* Only simple field types are included: `char`, `text`, `html`, `selection`, `boolean`, + `integer`, `float`, `monetary`, `date`, `datetime`, `many2one`, and `many2many`. + **one2many/reference/other types are omitted.** diff --git a/web_form_banner/readme/USAGE.rst b/web_form_banner/readme/USAGE.rst new file mode 100644 index 0000000..5f1d49c --- /dev/null +++ b/web_form_banner/readme/USAGE.rst @@ -0,0 +1,160 @@ +#. Go to *Settings > Technical > User Interface > Form Banner Rules* and create a rule. +#. Choose Model, select Trigger Fields (optional), set Default Severity, select Views + (optional), update Target XPath (insertion point) and Position, and configure the + message. +#. Save. Open any matching form record—the banner will appear and auto-refresh after + load/save/reload. + +Usage of message fields: +~~~~~~~~~~~~~~~~~~~~~~~~ + +* **Message** (message): Text shown in the banner. Supports `${placeholders}` filled + from values returned by message_value_code. Ignored if message_value_code returns an + `html` value. +* **HTML** (message_is_html): If enabled, the message string is rendered as HTML; + otherwise it's treated as plain text. +* **Message Value Code** (message_value_code): Safe Python expression evaluated per + record. Return a dict such as `{"visible": True, "severity": "warning", "values": {"name": record.name}}`. + Use either message or `html` (from this code), not both. Several evaluation context + variables are available. + +Evaluation context variables available in Message Value Code: +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* `env`: Odoo environment for ORM access. +* `user`: Current user (`env.user`). +* `ctx`: Copy of the current context (`dict(env.context)`). +* `record`: Current record (the form's record). +* `draft`: The persisted field values of the ORM record (before applying the current + form's unsaved changes) + the current unsaved changes on trigger fields. + Should be used instead of `record` when your rule is triggered dynamically by an + update to a trigger field. It doesn't include any values from complex fields + (one2many/reference, etc). +* `record_id`: Integer id of the record being edited, or `False` if the form + is creating a new record. +* `model`: Shortcut to the current model (`env[record._name]`). +* `url_for(obj)`: Helper that returns a backend form URL for `obj`. +* `context_today(ts=None)`: User-timezone “today” (date) for reliable date comparisons. +* `time`, `datetime`: Standard Python time/datetime modules. +* `dateutil`: `{ "parser": dateutil.parser, "relativedelta": dateutil.relativedelta }` +* `timezone`: `pytz.timezone` for TZ handling. +* `float_compare`, `float_is_zero`, `float_round`: Odoo float utils for precision-safe + comparisons/rounding. + +All of the above are injected by the module to the safe_eval locals. + +Trigger Fields +~~~~~~~~~~~~~~ + +*Trigger Fields* is an optional list of model fields that, when changed in the open +form, cause the banner to **recompute live**. If left empty, the banner does **not** +auto-refresh as the user edits the form. + +When a trigger fires, the module sends the current draft values to the server, sanitizes +them, builds an evaluation record, and re-runs your `message_value_code`. + +You should use `draft` instead of `record` to access the current form values if your +rule is triggered based on an update to a trigger field. + +Message setting examples: +~~~~~~~~~~~~~~~~~~~~~~~~~ + +**A) Missing email on contact (warning)** + +* Model: `res.partner` +* Message: `This contact has no email.` +* Message Value Code: + +.. code-block:: python + + {"visible": not bool(record.email)} + +**B) Show partner comment if available** + +* Model: `purchase.order` +* Message: `Vendor Comments: ${comment}` +* Message Value Code (single expression): + +.. code-block:: python + + { + "visible": bool(record.partner_id.comment), + "values": {"comment": record.partner_id.comment}, + } + +It is also possible to use "convenience placeholders" without an explicit `values` key: + +.. code-block:: python + + { + "visible": bool(record.partner_id.comment), + "comment": record.partner_id.comment, + } + +**C) High-value sale order (dynamic severity)** + +* Model: `sale.order` +* Message: `High-value order: ${amount_total}` +* Message Value Code: + +.. code-block:: python + + { + "visible": record.amount_total >= 30000, + "severity": "danger" if record.amount_total >= 100000 else "warning", + "values": {"amount_total": record.amount_total}, + } + +**D) Quotation past validity date** + +* Model: `sale.order` +* Message: `This quotation is past its validity date (${validity_date}).` +* Message Value Code: + +.. code-block:: python + + { + "visible": bool(record.validity_date and context_today() > record.validity_date and record.state in ["draft", "sent"]), + "values": {"validity_date": record.validity_date}, + } + +**E) Pending activities on a task (uses `env`)** + +* Model: `project.task` +* Message: `There are ${cnt} pending activities.` +* Message Value Code (multi-line with `result`): + +.. code-block:: python + + cnt = env["mail.activity"].search_count([("res_model","=",record._name),("res_id","=",record.id)]) + result = {"visible": cnt > 0, "values": {"cnt": cnt}} + +**F) Product is missing internal reference (uses trigger fields)** + +* Model: `product.template` +* Trigger Fields: `default_code` +* Message: `Make sure to set an internal reference!` +* Message Value Code: + +.. code-block:: python + + {"visible": not bool(draft.default_code)} + +**G) HTML banner linking to the customer's last sales order (uses trigger fields)** + +* Model: `sale.order` +* Trigger Fields: `partner_id` +* Message: (leave blank; `html` provided by Message Value Code) +* Message Value Code (multi-line with `result`): + +.. code-block:: python + + domain = [("partner_id", "=", draft.partner_id.id)] + if record_id: + domain += [("id", "<", record_id)] + last = model.search(domain, order="date_order desc, id desc", limit=1) + if last: + html = "Previous order: %s" % (url_for(last), last.name) + result = {"visible": True, "html": html} + else: + result = {"visible": False} diff --git a/web_form_banner/security/ir.model.access.csv b/web_form_banner/security/ir.model.access.csv new file mode 100644 index 0000000..b360f9e --- /dev/null +++ b/web_form_banner/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_web_form_banner_rule_all,web_form_banner_rule_all,model_web_form_banner_rule,,1,0,0,0 +access_web_form_banner_rule_group_system,web_form_banner_rule_group_system,model_web_form_banner_rule,base.group_system,1,1,1,1 diff --git a/web_form_banner/static/description/index.html b/web_form_banner/static/description/index.html new file mode 100644 index 0000000..5189a3e --- /dev/null +++ b/web_form_banner/static/description/index.html @@ -0,0 +1,619 @@ + + + + + +Web Form Banner + + + +
+

Web Form Banner

+ + +

Beta License: AGPL-3 OCA/web Translate me on Weblate Try me on Runboat

+

The module adds configurable banners for backend form views. Define rules per model +(and optionally per view) to show context-aware alerts with a chosen severity (info/warning/danger).

+

Messages can be plain text with ${placeholders} or fully custom HTML; visibility, +severity, and values are computed server-side via a safe Python expression.

+

Banners are injected just before or after a target node (default: //sheet) and refresh +on form load/save/reload.

+

Table of contents

+ +
+

Usage

+
    +
  1. Go to Settings > Technical > User Interface > Form Banner Rules and create a rule.
  2. +
  3. Choose Model, select Trigger Fields (optional), set Default Severity, select Views +(optional), update Target XPath (insertion point) and Position, and configure the +message.
  4. +
  5. Save. Open any matching form record—the banner will appear and auto-refresh after +load/save/reload.
  6. +
+
+

Usage of message fields:

+
    +
  • Message (message): Text shown in the banner. Supports ${placeholders} filled +from values returned by message_value_code. Ignored if message_value_code returns an +html value.
  • +
  • HTML (message_is_html): If enabled, the message string is rendered as HTML; +otherwise it’s treated as plain text.
  • +
  • Message Value Code (message_value_code): Safe Python expression evaluated per +record. Return a dict such as {“visible”: True, “severity”: “warning”, “values”: {“name”: record.name}}. +Use either message or html (from this code), not both. Several evaluation context +variables are available.
  • +
+
+
+

Evaluation context variables available in Message Value Code:

+
    +
  • env: Odoo environment for ORM access.
  • +
  • user: Current user (env.user).
  • +
  • ctx: Copy of the current context (dict(env.context)).
  • +
  • record: Current record (the form’s record).
  • +
  • draft: The persisted field values of the ORM record (before applying the current +form’s unsaved changes) + the current unsaved changes on trigger fields. +Should be used instead of record when your rule is triggered dynamically by an +update to a trigger field. It doesn’t include any values from complex fields +(one2many/reference, etc).
  • +
  • record_id: Integer id of the record being edited, or False if the form +is creating a new record.
  • +
  • model: Shortcut to the current model (env[record._name]).
  • +
  • url_for(obj): Helper that returns a backend form URL for obj.
  • +
  • context_today(ts=None): User-timezone “today” (date) for reliable date comparisons.
  • +
  • time, datetime: Standard Python time/datetime modules.
  • +
  • dateutil: { “parser”: dateutil.parser, “relativedelta”: dateutil.relativedelta }
  • +
  • timezone: pytz.timezone for TZ handling.
  • +
  • float_compare, float_is_zero, float_round: Odoo float utils for precision-safe +comparisons/rounding.
  • +
+

All of the above are injected by the module to the safe_eval locals.

+
+
+

Trigger Fields

+

Trigger Fields is an optional list of model fields that, when changed in the open +form, cause the banner to recompute live. If left empty, the banner does not +auto-refresh as the user edits the form.

+

When a trigger fires, the module sends the current draft values to the server, sanitizes +them, builds an evaluation record, and re-runs your message_value_code.

+

You should use draft instead of record to access the current form values if your +rule is triggered based on an update to a trigger field.

+
+
+

Message setting examples:

+

A) Missing email on contact (warning)

+
    +
  • Model: res.partner
  • +
  • Message: This contact has no email.
  • +
  • Message Value Code:
  • +
+
+{"visible": not bool(record.email)}
+
+

B) Show partner comment if available

+
    +
  • Model: purchase.order
  • +
  • Message: Vendor Comments: ${comment}
  • +
  • Message Value Code (single expression):
  • +
+
+{
+  "visible": bool(record.partner_id.comment),
+  "values": {"comment": record.partner_id.comment},
+}
+
+

It is also possible to use “convenience placeholders” without an explicit values key:

+
+{
+  "visible": bool(record.partner_id.comment),
+  "comment": record.partner_id.comment,
+}
+
+

C) High-value sale order (dynamic severity)

+
    +
  • Model: sale.order
  • +
  • Message: High-value order: ${amount_total}
  • +
  • Message Value Code:
  • +
+
+{
+  "visible": record.amount_total >= 30000,
+  "severity": "danger" if record.amount_total >= 100000 else "warning",
+  "values": {"amount_total": record.amount_total},
+}
+
+

D) Quotation past validity date

+
    +
  • Model: sale.order
  • +
  • Message: This quotation is past its validity date (${validity_date}).
  • +
  • Message Value Code:
  • +
+
+{
+  "visible": bool(record.validity_date and context_today() > record.validity_date and record.state in ["draft", "sent"]),
+  "values": {"validity_date": record.validity_date},
+}
+
+

E) Pending activities on a task (uses `env`)

+
    +
  • Model: project.task
  • +
  • Message: There are ${cnt} pending activities.
  • +
  • Message Value Code (multi-line with result):
  • +
+
+cnt = env["mail.activity"].search_count([("res_model","=",record._name),("res_id","=",record.id)])
+result = {"visible": cnt > 0, "values": {"cnt": cnt}}
+
+

F) Product is missing internal reference (uses trigger fields)

+
    +
  • Model: product.template
  • +
  • Trigger Fields: default_code
  • +
  • Message: Make sure to set an internal reference!
  • +
  • Message Value Code:
  • +
+
+{"visible": not bool(draft.default_code)}
+
+

G) HTML banner linking to the customer’s last sales order (uses trigger fields)

+
    +
  • Model: sale.order
  • +
  • Trigger Fields: partner_id
  • +
  • Message: (leave blank; html provided by Message Value Code)
  • +
  • Message Value Code (multi-line with result):
  • +
+
+domain = [("partner_id", "=", draft.partner_id.id)]
+if record_id:
+  domain += [("id", "<", record_id)]
+last = model.search(domain, order="date_order desc, id desc", limit=1)
+if last:
+  html = "<strong>Previous order:</strong> <a href='%s'>%s</a>" % (url_for(last), last.name)
+  result = {"visible": True, "html": html}
+else:
+  result = {"visible": False}
+
+
+
+
+

Known issues / Roadmap

+ +
+

Limitations of draft eval context variable

+
    +
  • draft is always available in the eval context, but for new records (record_id = +False) it only contains the trigger fields from the banner rules.
  • +
  • For existing records, draft overlays the trigger field values on top of the +persisted record; all other fields come from Model.new defaults rather than the +database.
  • +
  • Only simple field types are included: char, text, html, selection, boolean, +integer, float, monetary, date, datetime, many2one, and many2many. +one2many/reference/other types are omitted.
  • +
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Quartile
  • +
+
+
+

Contributors

+
    +
  • Quartile:
      +
    • Yoshi Tashiro
    • +
    • Aung Ko Ko Lin
    • +
    +
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/web project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/web_form_banner/static/src/js/web_form_banner.esm.js b/web_form_banner/static/src/js/web_form_banner.esm.js new file mode 100644 index 0000000..5267cb0 --- /dev/null +++ b/web_form_banner/static/src/js/web_form_banner.esm.js @@ -0,0 +1,198 @@ +/** @odoo-module **/ + +// Copyright 2025 Quartile (https://www.quartile.co) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import FormController from "web.FormController"; +import rpc from "web.rpc"; + +const root = (ctrl) => (ctrl && (ctrl.el || (ctrl.$el && ctrl.$el[0]))) || null; + +const alive = (ctrl) => { + const r = root(ctrl); + return ( + r && + r.isConnected && + !(typeof ctrl.isDestroyed === "function" && ctrl.isDestroyed()) + ); +}; + +const qsa = (el, sel) => Array.from(el ? el.querySelectorAll(sel) : []); + +const first = (...args) => { + for (let i = 0; i < args.length; i++) { + const v = args[i]; + if (v !== null && v !== undefined && v !== "") return v; + } + return null; +}; + +const childSpan = (el) => { + if (!el) return null; + if (el.querySelector) { + return el.querySelector(":scope > span") || null; + } + const c = el.firstElementChild; + return c && c.tagName === "SPAN" ? c : null; +}; + +const after = (p, fn) => { + if (p && typeof p.always === "function") { + p.always(fn); + return p; + } + return Promise.resolve(p).finally(fn); +}; + +const shrinkDraft = (d) => + Object.entries(d || {}).reduce((o, [k, v]) => { + const t = typeof v; + const isNullish = (x) => x === null || x === undefined; + if (isNullish(v) || t === "string" || t === "number" || t === "boolean") { + o[k] = v; + } else if ( + (v && v.type === "record" && typeof v.res_id === "number") || + (v && typeof v.res_id === "number") + ) { + // Many2one (data snapshot shape) + o[k] = v.res_id; + } else if (v && typeof v === "object" && typeof v.id === "number") { + // Many2one (pending change as {id, display_name}) + o[k] = v.id; + } else if (Array.isArray(v) && v.length === 2 && typeof v[0] === "number") { + // Many2one (pending change as [id, name]) + o[k] = v[0]; + } else if ( + Array.isArray(v) || + (v && (Array.isArray(v.data) || Array.isArray(v.res_ids))) + ) { + // Many2many (and possibly other x2many) values; let Python decide + o[k] = v; + } + return o; + }, {}); + +const bannersIn = (ctrl) => + qsa(root(ctrl), '.o_form_view div[role="alert"][data-rule-id]'); + +const hasBanners = (ctrl) => bannersIn(ctrl).length > 0; + +const triggerSet = (ctrl) => { + const set = Object.create(null); + const els = bannersIn(ctrl); + for (let i = 0; i < els.length; i++) { + const el = els[i]; + const raw = first(el.dataset.triggerFields, ""); + (raw || "").split(",").forEach((n) => { + if (n) set[n.trim()] = true; + }); + } + return set; +}; + +// Pick only keys in `set` from `src` +const pickKeys = (src, set) => { + const out = {}; + if (!src) return out; + Object.keys(src).forEach((k) => { + if (set[k]) out[k] = src[k]; + }); + return out; +}; + +async function refreshBanners(ctrl, extraChanges) { + if (!alive(ctrl)) return; + const st = ctrl.model && ctrl.handle ? ctrl.model.get(ctrl.handle) : null; + const resId = st && st.res_id; + const base = shrinkDraft(st && st.data) || {}; + const latest = shrinkDraft(extraChanges || {}); + const snap = Object.assign({}, base, latest); + const tset = triggerSet(ctrl); + const hasTriggers = Object.keys(tset).length > 0; + const formVals = resId ? (hasTriggers ? pickKeys(snap, tset) : {}) : snap; + + const hideBanner = (el) => { + el.style.display = "none"; + const sp = childSpan(el); + if (sp) sp.innerHTML = ""; + else el.innerHTML = ""; + }; + + const showBanner = (el, res) => { + const sev = first(res.severity, el.dataset.defaultSeverity, "danger"); + const html = res.html || ""; + el.className = "o_form_banner alert alert-" + sev; + const sp = childSpan(el); + if (sp) sp.innerHTML = html; + else el.innerHTML = html; + el.style.display = ""; + }; + + const updateEl = async (el) => { + const ruleId = parseInt(first(el.dataset.ruleId, el.dataset.wfbRuleId), 10); + const model = first(el.dataset.model, el.dataset.wfbModel, ctrl.modelName); + const res = + (await rpc.query({ + model: "web.form.banner.rule", + method: "compute_message", + args: [ruleId, model, resId, formVals], + })) || {}; + if (!alive(ctrl)) return; + if (!res.visible) return hideBanner(el); + showBanner(el, res); + }; + + // Fire requests in parallel; resolve when all done + await Promise.all(bannersIn(ctrl).map(updateEl)); +} + +function withRefresh(ctrl, superFn, args) { + const p = superFn.apply(ctrl, args); + return after(p, function () { + refreshBanners(ctrl); + }); +} + +FormController.include({ + start: function () { + const p = this._super.apply(this, arguments); + // Keep original Deferred/Promise for Odoo callers + if (p && typeof p.always === "function") { + p.always(() => refreshBanners(this)); + } else { + Promise.resolve(p).then(() => refreshBanners(this)); + } + return p; + }, + reload: function () { + return withRefresh(this, this._super, arguments); + }, + saveRecord: function () { + return withRefresh(this, this._super, arguments); + }, + update: function () { + return withRefresh(this, this._super, arguments); + }, + // Onchange: refresh only when a declared trigger actually changed + _onFieldChanged: function (ev) { + const res = this._super.apply(this, arguments); + if (!alive(this) || !hasBanners(this)) return res; + const tset = triggerSet(this); + if (!Object.keys(tset).length) return res; + const changed = (ev && ev.data && ev.data.changes) || {}; + const names = Object.keys(changed); + if (!names.some((n) => tset[n])) return res; + // Defer one tick so x2many widgets commit their in-memory value first + after(res, () => setTimeout(() => refreshBanners(this, changed), 0)); + return res; + }, + activate: function () { + const res = this._super.apply(this, arguments); + if (hasBanners(this)) after(res, () => refreshBanners(this)); + return res; + }, + on_attach_callback: function () { + this._super.apply(this, arguments); + setTimeout(() => refreshBanners(this)); + }, +}); diff --git a/web_form_banner/static/src/scss/web_form_banner.scss b/web_form_banner/static/src/scss/web_form_banner.scss new file mode 100644 index 0000000..6724b0a --- /dev/null +++ b/web_form_banner/static/src/scss/web_form_banner.scss @@ -0,0 +1,16 @@ +// tighten vertical rhythm for stacked banners in form views +.o_form_view .o_form_banner.alert { + margin: 4px 0; // Bootstrap's default is ~1rem bottom; shrink it + padding-top: 6px; // slightly tighter padding + padding-bottom: 6px; +} + +// keep only a tiny gap between banners +.o_form_view .o_form_banner.alert + .o_form_banner.alert { + margin-top: 4px; +} + +// avoid extra gaps from paragraphs inside the banner +.o_form_view .o_form_banner.alert p:last-child { + margin-bottom: 0; +} diff --git a/web_form_banner/tests/__init__.py b/web_form_banner/tests/__init__.py new file mode 100644 index 0000000..383a4dd --- /dev/null +++ b/web_form_banner/tests/__init__.py @@ -0,0 +1 @@ +from . import test_web_form_banner diff --git a/web_form_banner/tests/test_web_form_banner.py b/web_form_banner/tests/test_web_form_banner.py new file mode 100644 index 0000000..adad308 --- /dev/null +++ b/web_form_banner/tests/test_web_form_banner.py @@ -0,0 +1,177 @@ +# Copyright 2025 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from lxml import etree + +from odoo.tests.common import SavepointCase, tagged + + +@tagged("post_install", "-at_install") +class TestFieldsViewGetPartnerBanner(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Partner = cls.env["res.partner"] + cls.Rule = cls.env["web.form.banner.rule"] + cls.rule_name = cls.env.ref("web_form_banner.demo_rule_partner_name_length") + cls.rule_email = cls.env.ref("web_form_banner.demo_rule_partner_email_missing") + cls.rule_tag = cls.env.ref("web_form_banner.demo_rule_partner_tag_missing") + # Disable the email and tag rules to avoid interference in most tests + cls.rule_email.active = False + cls.rule_tag.active = False + cls.partner_form_view = cls.env.ref("base.view_partner_form") + cls.p_len3 = cls.Partner.create({"name": "Bob"}) # 3 + cls.p_len12 = cls.Partner.create({"name": "Yoshi Tashiro"}) # 12 + cls.p_len22 = cls.Partner.create({"name": "Professor Charles Xavier"}) # 22 + + def _get_arch_tree(self, model, view): + res = model.fields_view_get( + view_id=view.id, + view_type="form", + toolbar=False, + submenu=False, + ) + return etree.fromstring(res["arch"]) + + def _find_banner_node(self, tree, rule): + """Find the injected placeholder node for the rule.""" + xpath = ( + "//div[@data-rule-id='%s' and contains(@class,'o_form_banner')]" % rule.id + ) # noqa: E501 + nodes = tree.xpath(xpath) + self.assertTrue(nodes, "Expected banner node injected in the form arch.") + return nodes[0] + + def _get_sibling_indexes(self): + tree = self._get_arch_tree(self.Partner, self.partner_form_view) + banner_node = self._find_banner_node(tree, self.rule_name) + targets = tree.xpath(self.rule_name.target_xpath) + self.assertTrue(targets) + target = targets[0] + parent = target.getparent() + self.assertIsNotNone(parent) + # Banner and sheet should share the same parent + self.assertIs(parent, banner_node.getparent()) + siblings = list(parent) + return siblings.index(target), siblings.index(banner_node) + + def _code(self, rule): + return (rule.message_value_code or "").strip() + + def test_injected_once_with_expected_attrs(self): + tree = self._get_arch_tree(self.Partner, self.partner_form_view) + banner_node = self._find_banner_node(tree, self.rule_name) + # Basic attributes from the server injection + self.assertEqual(banner_node.get("data-model"), "res.partner") + self.assertEqual( + banner_node.get("data-default-severity"), self.rule_name.severity + ) + self.assertEqual(banner_node.get("role"), "alert") + self.assertEqual(banner_node.get("style"), "display:none;") + # Class list includes the expected CSS classes + classes = (banner_node.get("class") or "").split() + for required in ( + "o_form_banner", + "alert", + "alert-%s" % (self.rule_name.severity), + ): + self.assertIn(required, classes) + # Ensure it's not duplicated + all_banners = tree.xpath("//div[contains(@class,'o_form_banner')]") + self.assertEqual(len(all_banners), 1) + + def test_position_relative_to_sheet(self): + self.rule_name.position = "before" + i_target, i_banner_node = self._get_sibling_indexes() + self.assertEqual( + i_banner_node, + i_target - 1, + "Banner should be inserted immediately before ", + ) + self.rule_name.position = "after" + i_target, i_banner_node = self._get_sibling_indexes() + self.assertEqual( + i_banner_node, + i_target + 1, + "Banner should be inserted immediately after ", + ) + + def test_not_injected_on_unrelated_model(self): + Company = self.env["res.company"] + view = self.env.ref("base.view_company_form") + res = Company.fields_view_get(view_id=view.id, view_type="form") + tree = etree.fromstring(res["arch"]) + self.assertFalse(tree.xpath("//div[contains(@class,'o_form_banner')]")) + + def test_contains_expected_messages_and_severities(self): + code = (self.rule_name.message_value_code or "").strip() + self.assertIn("This partner's name is very long!", code) + self.assertIn("This partner's name is a bit long.", code) + self.assertRegex(code, r"['\"]danger['\"]", "Missing 'danger' literal") + self.assertRegex(code, r"['\"]warning['\"]", "Missing 'warning' literal") + + def test_banner_visibility_and_content(self): + # Short name: no banner + out = self.Rule.compute_message( + self.rule_name.id, "res.partner", self.p_len3.id + ) + self.assertFalse(out.get("visible")) + # Medium name: warning banner + out = self.Rule.compute_message( + self.rule_name.id, "res.partner", self.p_len12.id + ) + self.assertTrue(out.get("visible")) + self.assertEqual(out.get("severity"), "warning") + self.assertIn("bit long", out.get("html", "")) + # Long name: danger banner + out = self.Rule.compute_message( + self.rule_name.id, "res.partner", self.p_len22.id + ) + self.assertTrue(out.get("visible")) + self.assertEqual(out.get("severity"), "danger") + self.assertIn("very long", out.get("html", "")) + + def test_inactive_rule_returns_hidden(self): + # Flip active off just for this check + self.rule_name.active = False + try: + out = self.Rule.compute_message( + self.rule_name.id, "res.partner", self.p_len22.id + ) + self.assertFalse(out.get("visible")) + finally: + self.rule_name.active = True + + def test_compute_message_dynamic_simple_field(self): + self.rule_email.active = True + out = self.Rule.compute_message( + self.rule_email.id, "res.partner", self.p_len3.id, form_vals={"email": ""} + ) + self.assertTrue(out.get("visible")) + self.assertIn("This partner is missing email!", out.get("html")) + out = self.Rule.compute_message( + self.rule_email.id, + "res.partner", + self.p_len3.id, + form_vals={"email": "test@example.com"}, + ) + self.assertFalse(out.get("visible")) + + def test_compute_message_dynamic_m2m(self): + self.rule_tag.active = True + tag = self.env["res.partner.category"].create({"name": "test tag"}) + out = self.Rule.compute_message( + self.rule_tag.id, + "res.partner", + self.p_len3.id, + form_vals={"category_id": []}, + ) + self.assertTrue(out.get("visible")) + self.assertIn("This partner is missing a tag!", out.get("html")) + out = self.Rule.compute_message( + self.rule_tag.id, + "res.partner", + self.p_len3.id, + form_vals={"category_id": [tag.id]}, + ) + self.assertFalse(out.get("visible")) diff --git a/web_form_banner/views/web_form_banner_rule_views.xml b/web_form_banner/views/web_form_banner_rule_views.xml new file mode 100644 index 0000000..ced8fe7 --- /dev/null +++ b/web_form_banner/views/web_form_banner_rule_views.xml @@ -0,0 +1,211 @@ + + + + web.form.banner.rule.tree + web.form.banner.rule + + + + + + + + + + + + + + + + + + web.form.banner.rule.form + web.form.banner.rule + +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+

Help for Message Valude Code

+

Available evaluation context variables are as follows:

+
    +
  • env: Odoo environment for ORM access.
  • +
  • user: Current user (env.user).
  • +
  • ctx: Copy of the current context (dict(env.context)).
  • +
  • record: Current record (the form's record).
  • +
  • draft: The persisted field values of the ORM record (before applying the current + form's unsaved changes) + the current unsaved changes on trigger fields. + Should be used instead of record when your rule is triggered dynamically by an + update to a trigger field. It doesn't include any values from complex fields + (x2many/reference, etc). +
  • +
  • current_id: Integer id of the record being edited, or False if the form + is creating a new record. +
  • +
  • model: Shortcut to the current model (env[record._name]).
  • +
  • url_for(obj): Helper that returns a backend form URL for obj.
  • +
  • context_today(ts=None): User-timezone “today” (date) for reliable date comparisons.
  • +
  • time, datetime: Standard Python time/datetime modules.
  • +
  • dateutil: { "parser": dateutil.parser, "relativedelta": dateutil.relativedelta }
  • +
  • timezone: pytz.timezone for TZ handling.
  • +
  • float_compare, float_is_zero, float_round: Odoo float utils for precision-safe comparisons/rounding.
  • +
+

Example of Message Value Code (model: sale.order)

+ +domain = [("partner_id", "=", draft.partner_id.id)] +if record_id: + domain += [("id", "&lt;", record_id)] +last = model.search(domain, order="date_order desc, id desc", limit=1) +if last: + html = "&lt;strong&gt;Previous order:&lt;/strong&gt; &lt;a href='%s'&gt;%s&lt;/a&gt;" % (url_for(last), last.name) + result = {"visible": True, "html": html} +else: + result = {"visible": False} + +
+
+
+
+
+
+
+
+ + web.form.banner.rule.search + web.form.banner.rule + + + + + + + + + + + + + + + + + + + Form Banner Rules + web.form.banner.rule + tree,form + + +
From 752b04b16423a22ad39bd62039c1bc0db4564f34 Mon Sep 17 00:00:00 2001 From: Sayaka Yamada Date: Tue, 30 Sep 2025 05:30:32 +0000 Subject: [PATCH 2/2] Fix: web_form_banner/models/web_form_banner_rule.py --- web_form_banner/models/web_form_banner_rule.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web_form_banner/models/web_form_banner_rule.py b/web_form_banner/models/web_form_banner_rule.py index a499509..04234aa 100644 --- a/web_form_banner/models/web_form_banner_rule.py +++ b/web_form_banner/models/web_form_banner_rule.py @@ -8,11 +8,11 @@ from dateutil import parser as dateparse from dateutil.relativedelta import relativedelta from lxml import etree +from markupsafe import escape from pytz import timezone from odoo import _, api, fields, models, tools from odoo.exceptions import ValidationError -from odoo.tools import html_escape from odoo.tools.float_utils import float_compare, float_is_zero, float_round from odoo.tools.safe_eval import safe_eval @@ -255,7 +255,9 @@ def _render_html(self, rule, values, html): rendered = rule.message or "" if rule.message_is_html: return rendered - return html_escape(rendered).replace("\n", "
") + lines = rendered.split("\n") + escaped_lines = [escape(line) for line in lines] + return "
".join(escaped_lines) @api.model def compute_message(self, rule_id, model, res_id, form_vals=None):