From 6cc294c7cdffbc9acac1639d9c8b9fbbca9f234c Mon Sep 17 00:00:00 2001 From: yostashiro Date: Mon, 15 Sep 2025 15:21:52 +0000 Subject: [PATCH 01/15] [ADD] web_form_banner --- web_form_banner/README.rst | 268 ++++++++ web_form_banner/__init__.py | 1 + web_form_banner/__manifest__.py | 18 + .../demo/web_form_banner_rule_demo.xml | 52 ++ web_form_banner/i18n/ja.po | 348 ++++++++++ web_form_banner/models/__init__.py | 2 + web_form_banner/models/ir_model.py | 65 ++ .../models/web_form_banner_rule.py | 280 ++++++++ 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 | 615 ++++++++++++++++++ .../static/src/js/web_form_banner.js | 176 +++++ .../static/src/scss/web_form_banner.scss | 16 + web_form_banner/tests/__init__.py | 1 + web_form_banner/tests/test_web_form_banner.py | 173 +++++ web_form_banner/views/assets.xml | 9 + .../views/web_form_banner_rule_views.xml | 158 +++++ 20 files changed, 2374 insertions(+) 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.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/assets.xml create mode 100644 web_form_banner/views/web_form_banner_rule_views.xml diff --git a/web_form_banner/README.rst b/web_form_banner/README.rst new file mode 100644 index 000000000000..2890adfdee77 --- /dev/null +++ b/web_form_banner/README.rst @@ -0,0 +1,268 @@ +=============== +Web Form Banner +=============== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/12.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-12-0/web-12-0-web_form_banner + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/162/12.0 + :alt: Try me on Runbot + +|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 > Tachnical > 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 smashing 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 000000000000..0650744f6bc6 --- /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 000000000000..749e8655d083 --- /dev/null +++ b/web_form_banner/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2025 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Web Form Banner", + "version": "12.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/assets.xml", + "views/web_form_banner_rule_views.xml", + ], + "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 000000000000..2b8a9f327874 --- /dev/null +++ b/web_form_banner/demo/web_form_banner_rule_demo.xml @@ -0,0 +1,52 @@ + + + + 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 000000000000..2e075ecc3b5a --- /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 000000000000..96e16706afae --- /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 000000000000..83fd7ea56bf3 --- /dev/null +++ b/web_form_banner/models/ir_model.py @@ -0,0 +1,65 @@ +# 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 000000000000..095ca0800e60 --- /dev/null +++ b/web_form_banner/models/web_form_banner_rule.py @@ -0,0 +1,280 @@ +# Copyright 2025 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +import time +import datetime as dt +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 +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")], + string="Position", + 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) + + @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": time, + "datetime": dt, + "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 000000000000..0ec4343483a8 --- /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 000000000000..9f0a3c24eb1a --- /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 000000000000..fe7335db878f --- /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 000000000000..83786729a854 --- /dev/null +++ b/web_form_banner/readme/USAGE.rst @@ -0,0 +1,160 @@ +#. Go to *Settings > Tachnical > 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 000000000000..b360f9eb645d --- /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 000000000000..10233d9414c7 --- /dev/null +++ b/web_form_banner/static/description/index.html @@ -0,0 +1,615 @@ + + + + + + +Web Form Banner + + + +
+

Web Form Banner

+ + +

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

+

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 > Tachnical > 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 smashing 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.js b/web_form_banner/static/src/js/web_form_banner.js new file mode 100644 index 000000000000..182fb6745b9c --- /dev/null +++ b/web_form_banner/static/src/js/web_form_banner.js @@ -0,0 +1,176 @@ +odoo.define("web_form_banner.save_plus_load", function (require) { + "use strict"; + var rpc = require("web.rpc"); + var FormController = require("web.FormController"); + + var root = function (ctrl) { + return (ctrl && (ctrl.el || (ctrl.$el && ctrl.$el[0]))) || null; + }; + var alive = function (ctrl) { + var r = root(ctrl); + return ( + r && r.isConnected && + !(typeof ctrl.isDestroyed === "function" && ctrl.isDestroyed()) + ); + }; + var qsa = function (el, sel) { + return Array.from(el ? el.querySelectorAll(sel) : []); + }; + var first = function () { + for (var i = 0; i < arguments.length; i++) { + var v = arguments[i]; + if (v != null && v !== "") return v; + } + return null; + }; + var childSpan = function (el) { + if (!el) return null; + if (el.querySelector) { + return el.querySelector(":scope > span") || null; + } + var c = el.firstElementChild; + return c && c.tagName === "SPAN" ? c : null; + }; + var after = function (p, fn) { + if (p && typeof p.always === "function") { p.always(fn); return p; } + return Promise.resolve(p).finally(fn); + }; + var shrinkDraft = function (d) { + return Object.entries(d || {}).reduce(function (o, kv) { + var k = kv[0], v = kv[1], t = typeof v; + if (v == null || t === "string" || t === "number" || t === "boolean") { + o[k] = v; + } else if (v && v.type === "record" && typeof v.res_id === "number") { + o[k] = v.res_id; + } 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; + }, {}); + }; + var bannersIn = function (ctrl) { + return qsa( + root(ctrl), + '.o_form_view div[role="alert"][data-rule-id]' + ); + }; + var hasBanners = function (ctrl) { return bannersIn(ctrl).length > 0; }; + + var triggerSet = function (ctrl) { + var set = Object.create(null); + var els = bannersIn(ctrl); + for (var i = 0; i < els.length; i++) { + var el = els[i]; + var raw = first(el.dataset.triggerFields, el.dataset.wfbTriggerFields, ""); + (raw || "").split(",").forEach(function (n) { + if (n) set[n.trim()] = true; + }); + } + return set; + }; + + // pick only keys in `set` from `src` + var pickKeys = function (src, set) { + var out = {}; + if (!src) return out; + Object.keys(src).forEach(function (k) { if (set[k]) out[k] = src[k]; }); + return out; + }; + + function refreshBanners(ctrl) { + if (!alive(ctrl)) return; + var st = (ctrl.model && ctrl.handle) ? ctrl.model.get(ctrl.handle) : null; + var resId = st && st.res_id; + + // sanitize snapshots + var snap = shrinkDraft(st && st.data) || {}; + var diff = resId ? (shrinkDraft(st && st.changes) || {}) : {}; + + // for existing: include current values for trigger fields, then overlay diffs + var tset = triggerSet(ctrl); + var formVals = !resId + ? snap + : (Object.keys(tset).length ? pickKeys(snap, tset) : {}); + Object.keys(diff).forEach(function (k) { formVals[k] = diff[k]; }); + + var els = bannersIn(ctrl); + for (var i = 0; i < els.length; i++) { + var el = els[i]; + var ruleId = parseInt(first(el.dataset.ruleId, el.dataset.wfbRuleId), 10); + var model = first(el.dataset.model, el.dataset.wfbModel, ctrl.modelName); + + (function (elRef) { + rpc.query({ + model: "web.form.banner.rule", + method: "compute_message", + args: [ruleId, model, resId, formVals], + }).then(function (res) { + if (!alive(ctrl)) return; + res = res || {}; + if (!res.visible) { + elRef.style.display = "none"; + var sp0 = childSpan(elRef); + if (sp0) sp0.innerHTML = ""; else elRef.innerHTML = ""; + return; + } + var sev = first( + res.severity, elRef.dataset.defaultSeverity, "danger" + ); + var html = res.html || ""; + elRef.className = "o_form_banner alert alert-" + sev; + var sp = childSpan(elRef); + if (sp) sp.innerHTML = html; else elRef.innerHTML = html; + elRef.style.display = ""; + }); + })(el); + } + } + + function withRefresh(ctrl, superFn, args) { + var p = superFn.apply(ctrl, args); + return after(p, function () { refreshBanners(ctrl); }); + } + + FormController.include({ + start: function () { + var p = this._super.apply(this, arguments); + if (p && typeof p.always === "function") { + p.always(() => refreshBanners(this)); + } else { + Promise.resolve(p).then(() => refreshBanners(this)); + } + return p; // keep original Deferred/Promise for Odoo callers + }, + 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) { + var res = this._super.apply(this, arguments); + if (!alive(this) || !hasBanners(this)) return res; + var tset = triggerSet(this); + if (!Object.keys(tset).length) return res; + var changed = (ev && ev.data && ev.data.changes) || {}; + var names = Object.keys(changed); + if (!names.some(function (n) { return tset[n]; })) return res; + after(res, () => refreshBanners(this)); + return res; + }, + activate: function () { + var 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 000000000000..8e3dedd89a73 --- /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 000000000000..383a4dd50d20 --- /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 000000000000..bafda1ee999d --- /dev/null +++ b/web_form_banner/tests/test_web_form_banner.py @@ -0,0 +1,173 @@ +# 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/assets.xml b/web_form_banner/views/assets.xml new file mode 100644 index 000000000000..7f347c2422ab --- /dev/null +++ b/web_form_banner/views/assets.xml @@ -0,0 +1,9 @@ + + +