From 28005b58f352e204f52fc25547bf01b2e4b4c87f Mon Sep 17 00:00:00 2001 From: Sayaka Yamada Date: Fri, 24 Oct 2025 06:45:01 +0000 Subject: [PATCH] [5809][ADD] web_form_banner --- .../odoo/addons/web_form_banner | 1 + setup/web_form_banner/setup.py | 6 + web_form_banner/README.rst | 276 ++++++++ web_form_banner/__init__.py | 1 + web_form_banner/__manifest__.py | 23 + .../demo/web_form_banner_rule_demo.xml | 64 ++ web_form_banner/i18n/it.po | 426 ++++++++++++ web_form_banner/i18n/ja.po | 388 +++++++++++ web_form_banner/i18n/web_form_banner.pot | 376 +++++++++++ web_form_banner/models/__init__.py | 2 + web_form_banner/models/ir_model.py | 63 ++ .../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 | 18 + web_form_banner/readme/USAGE.rst | 160 +++++ web_form_banner/security/ir.model.access.csv | 3 + web_form_banner/static/description/icon.png | Bin 0 -> 10254 bytes web_form_banner/static/description/index.html | 626 ++++++++++++++++++ .../static/src/js/web_form_banner.esm.js | 145 ++++ .../static/src/scss/web_form_banner.scss | 11 + web_form_banner/tests/__init__.py | 1 + web_form_banner/tests/test_web_form_banner.py | 164 +++++ .../views/web_form_banner_rule_views.xml | 211 ++++++ 24 files changed, 3265 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/it.po create mode 100644 web_form_banner/i18n/ja.po create mode 100644 web_form_banner/i18n/web_form_banner.pot 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/icon.png 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..f89bf79 --- /dev/null +++ b/web_form_banner/README.rst @@ -0,0 +1,276 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +=============== +Web Form Banner +=============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:c457f652637980cd876778c38cde80e2997fc49edf396b7928dde05b15fd5d8c + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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/16.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-16-0/web-16-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=16.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 a `` is only partially supported. +Depending on the target XPath (especially when targeting a `` rendered by +certain widgets), the banner or surrounding fields may render 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..7226df3 --- /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": "16.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/it.po b/web_form_banner/i18n/it.po new file mode 100644 index 0000000..64a6de8 --- /dev/null +++ b/web_form_banner/i18n/it.po @@ -0,0 +1,426 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * web_form_banner +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2025-10-16 12:42+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.10.4\n" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "//sheet" +msgstr "//foglio" + +#. 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 "" +"\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;Ordine precedente:&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" +"" + +#. 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 "" +"context_today(ts=None): Fuso orario utente “oggi” (data) per " +"comparazione data affidabile." + +#. 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 "" +"ctx: Copia del contesto attuale " +"(dict(env.context))." + +#. 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 "" +"current_id: ID intero del record in modifica o False se il modulo\n" +" sta creando un nuovo rerord." + +#. 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 "" +"dateutil: { \"parser\": dateutil.parser, \"relativedelta\"" +": dateutil.relativedelta }" + +#. 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 "" +"draft: i valori persistenti del campo del record ORM (prima di " +"applicare le attuali\n" +" modifiche non salvate del modulo)" +" + le modifiche attuali non salvate nei campi attivati.\n" +" Dovrebbero essere usati al posto " +"di record quando la regola è attivata dinamicamente da un\n" +" aggiornamento di un campo " +"attivato. Non include nessun valore da campi complessi\n" +" (x2many/reference, ecc.)." + +#. 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 "env: ambiente Odoo per accesso ORM." + +#. 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 "" +"float_compare, float_is_zero, " +"float_round: utilità decimali Odoo per comparazione sicura / " +"arrotondamento." + +#. 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 "" +"model: scorciatoia al modello attuale (env[record._name]" +")." + +#. 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 "record: record attuale (il record della maschera)." + +#. 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 "time, datetime: moduli orario/dataoraraio Python standard." + +#. 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 "timezone: pytz.timezone per gestione FO." + +#. 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 "" +"url_for(obj): aiuto che restituisce una maschera backend per " +"obj." + +#. 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 "user: utente attuale (env.user)." + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__active +msgid "Active" +msgstr "Attiva" + +#. module: web_form_banner +#: model:ir.model.fields.selection,name:web_form_banner.selection__web_form_banner_rule__position__after +msgid "After target" +msgstr "Dopo l'obiettivo" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_search +msgid "Archived" +msgstr "In archivio" + +#. 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 "Le valutazioni delle variabili di contesto disponibili sono come segue:" + +#. module: web_form_banner +#: model:ir.model,name:web_form_banner.model_base +msgid "Base" +msgstr "Base" + +#. module: web_form_banner +#: model:ir.model.fields.selection,name:web_form_banner.selection__web_form_banner_rule__position__before +msgid "Before target" +msgstr "Prima dell'obiettivo" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__create_uid +msgid "Created by" +msgstr "Creato da" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__create_date +msgid "Created on" +msgstr "Creato il" + +#. module: web_form_banner +#: model:ir.model.fields.selection,name:web_form_banner.selection__web_form_banner_rule__severity__danger +msgid "Danger" +msgstr "Pericolo" + +#. 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 "Gravità predefinita" + +#. 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 "Il livello di gravità predefinito può essere sovrascritto per record." + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__display_name +msgid "Display Name" +msgstr "Nome visualizzato" + +#. 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 "Esempio di codice valore messaggio (model: 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 "Regola banner maschera" + +#. 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 "Regole banner maschera" + +#. 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 "Vista maschera dove deve essere iniettato il banner." + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_search +msgid "Group By" +msgstr "Raggruppa per" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__message_is_html +msgid "HTML" +msgstr "HTML" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +msgid "Help" +msgstr "Aiuto" + +#. 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 "Aiuto per codice valore messaggio" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__id +msgid "ID" +msgstr "ID" + +#. 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 "" +"Se selezionata, 'message' è trattato come HTML grezzo (senza escape). Se non " +"selezionata, il testo reso usa gli escape e i newline diventano
." + +#. 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 "" +"Se impostato, il banner ricalcola al volo quando cambia uno di questi campi." + +#. module: web_form_banner +#: model:ir.model.fields.selection,name:web_form_banner.selection__web_form_banner_rule__severity__info +msgid "Info" +msgstr "Informazioni" + +#. module: web_form_banner +#. odoo-python +#: code:addons/web_form_banner/models/web_form_banner_rule.py:0 +#, python-format +msgid "" +"Invalid XPath:\n" +"%s" +msgstr "" +"XPath non valido:\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 "Ultima modifica il" + +#. 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 "Ultimo aggiornamento di" + +#. 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 "Ultimo aggiornamento il" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__message +msgid "Message" +msgstr "Messaggio" + +#. 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 "Codice valore messaggio" + +#. 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 "Modello" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__name +msgid "Name" +msgstr "Nome" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__position +msgid "Position" +msgstr "Posizione" + +#. 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 "" +"Espressione Python valutata lato server. Deve restituire un dizionario.\n" +"Chiavi: visible(bool, predefinito True), severity(str), values(dict per " +"${...} in\n" +"message) e/o html(str) per sovrascrivere il rendering del template." + +#. 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 "Ricalcola alla modifica (nuove maschere)" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__sequence +msgid "Sequence" +msgstr "Sequenza" + +#. 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 obiettivo" + +#. 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 "Modello con ${placeholders}. Se non HTML, si attiverà l'escape." + +#. 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 "Questo è un messaggio ${severity}." + +#. module: web_form_banner +#: model:web.form.banner.rule,message:web_form_banner.demo_rule_partner_tag_missing +msgid "This partner is missing a tag!" +msgstr "A questo partner manca una etichette!" + +#. module: web_form_banner +#: model:web.form.banner.rule,message:web_form_banner.demo_rule_partner_email_missing +msgid "This partner is missing email!" +msgstr "A questo partner manca l'e-mail!" + +#. 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 "Campi attivazione" + +#. module: web_form_banner +#: model:ir.model.fields,field_description:web_form_banner.field_web_form_banner_rule__view_ids +msgid "Views" +msgstr "Viste" + +#. module: web_form_banner +#: model:ir.model.fields.selection,name:web_form_banner.selection__web_form_banner_rule__severity__warning +msgid "Warning" +msgstr "Attenzione" + +#. 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 "Dove inserire il segnaposto relativo al primo nodo corrispondente." + +#. 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 del nodo in cui inserire il banner." + +#. 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 "es. Attenzione su clienti pericolosi" + +#. 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 "return {'visible': True, 'values': {'title': '...'}}" diff --git a/web_form_banner/i18n/ja.po b/web_form_banner/i18n/ja.po new file mode 100644 index 0000000..250dd68 --- /dev/null +++ b/web_form_banner/i18n/ja.po @@ -0,0 +1,388 @@ +# 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" +"Language: \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 +#: model:ir.model.fields.selection,name:web_form_banner.selection__web_form_banner_rule__position__after +msgid "After target" +msgstr "ターゲットの後" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +#: 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 +#: model:ir.model.fields.selection,name:web_form_banner.selection__web_form_banner_rule__position__before +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 +#: model:ir.model.fields.selection,name:web_form_banner.selection__web_form_banner_rule__severity__danger +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 +#: model:ir.model.fields.selection,name:web_form_banner.selection__web_form_banner_rule__severity__info +msgid "Info" +msgstr "情報" + +#. module: web_form_banner +#. odoo-python +#: code:addons/web_form_banner/models/web_form_banner_rule.py:0 +#, 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:web.form.banner.rule,message:web_form_banner.demo_rule_partner_tag_missing +msgid "This partner is missing a tag!" +msgstr "" + +#. module: web_form_banner +#: model:web.form.banner.rule,message:web_form_banner.demo_rule_partner_email_missing +msgid "This partner is missing email!" +msgstr "" + +#. 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 +#: model:ir.model.fields.selection,name:web_form_banner.selection__web_form_banner_rule__severity__warning +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/i18n/web_form_banner.pot b/web_form_banner/i18n/web_form_banner.pot new file mode 100644 index 0000000..1ec6463 --- /dev/null +++ b/web_form_banner/i18n/web_form_banner.pot @@ -0,0 +1,376 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * web_form_banner +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: 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 +#: model:ir.model.fields.selection,name:web_form_banner.selection__web_form_banner_rule__position__after +msgid "After target" +msgstr "" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_form +#: 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 +#: model:ir.model.fields.selection,name:web_form_banner.selection__web_form_banner_rule__position__before +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 +#: model:ir.model.fields.selection,name:web_form_banner.selection__web_form_banner_rule__severity__danger +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 "" + +#. 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 +#: model:ir.model.fields.selection,name:web_form_banner.selection__web_form_banner_rule__severity__info +msgid "Info" +msgstr "" + +#. module: web_form_banner +#. odoo-python +#: code:addons/web_form_banner/models/web_form_banner_rule.py:0 +#, python-format +msgid "" +"Invalid XPath:\n" +"%s" +msgstr "" + +#. 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 "" + +#. 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 "" + +#. 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 "" + +#. module: web_form_banner +#: model:web.form.banner.rule,message:web_form_banner.demo_rule_partner_tag_missing +msgid "This partner is missing a tag!" +msgstr "" + +#. module: web_form_banner +#: model:web.form.banner.rule,message:web_form_banner.demo_rule_partner_email_missing +msgid "This partner is missing email!" +msgstr "" + +#. 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 +#: model:ir.model.fields.selection,name:web_form_banner.selection__web_form_banner_rule__severity__warning +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 "" + +#. 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 "" + +#. 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..4203b0a --- /dev/null +++ b/web_form_banner/models/ir_model.py @@ -0,0 +1,63 @@ +# 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 get_view(self, view_id=None, view_type="form", **options): + res = super().get_view(view_id=view_id, view_type=view_type, **options) + if view_type != "form" or not res.get("arch"): + return res + current_view_id = view_id or res.get("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: + targets = root.xpath(rule.target_xpath or "//sheet") + if not targets: + continue + target = targets[0] + trigger_fields = ",".join(rule.trigger_field_ids.mapped("name")) + banner = etree.Element( + "div", + { + "class": "o_form_banner alert o_invisible_modifier", + "role": "status", + "data-rule-id": str(rule.id), + "data-model": self._name, + "data-trigger-fields": trigger_fields, + }, + ) + in_group = any(a.tag == "group" for a in target.iterancestors()) + if in_group: + # To avoid the layout distortion issue when the target is inside a group + banner.set("colspan", "2") + if rule.position == "before": + target.addprevious(banner) + else: + target.addnext(banner) + 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..ff5cd1e --- /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 string import Template + +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.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("resIds"), (list, tuple)): + return value["resIds"] + 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 "" + + @api.model + def _base_eval_ctx_static(self): + 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 isinstance(html, str): + 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 + 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): + """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..53b9042 --- /dev/null +++ b/web_form_banner/readme/ROADMAP.rst @@ -0,0 +1,18 @@ +Banner presentation inside `` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Placing a full-width inline banner inside a `` is only partially supported. +Depending on the target XPath (especially when targeting a `` rendered by +certain widgets), the banner or surrounding fields may render 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/icon.png b/web_form_banner/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1dcc49c24f364e9adf0afbc6fc0bac6dbecdeb11 GIT binary patch literal 10254 zcmbt)WmufcvhH9Zc!C8B?l8#UE&&o;gF7=g3=D(IAOS+K1lK^25Zv7%L4sRw_uvvF z*qyAk?>c**=lnR&y+1yw{;I3Hy6Ua2{<d0kcR+VvBo; zA_X`>;1;xAPL9rQqFxd#f5{a^zW*uaW+r3+U{|fRunu`GZhy$X z8_|Zi{zd#vIokczl8Xh*4Wi@i0+C?Rg1AB5VOEg8B>buLFCi~r5DPd2ED7QP2>^LO zKpr7+?*I1bPaFSLLEa0l2$tj*;u8Qtc=&(RUc*VK@ zjIN{I--GfO@vl+&r^eqy_BZ3dndN_PDzMc*W^!?dIsWAWU@LBjBg6^f4F6*!-hUYh zY$Xb}gF8b0%S1Ac@c%Rs()UCiEu3v6SiFE>h_!{gBb-H2{e=wB5o!YkT0>#LKZFw$ z?CuD0Gvfsb(|XbVxx0AL0%`gG2X+6|f;jiTHU9shtjoW-{2!| zMN*WuOj6elhD4zqgjNpX>F#JP{)hAbenX<+FPr>7jXM&q{|x+pbj8cU<=>Ej zWE1_%qoFVzDAZB%g@v<+1ud%<#2E~ML11jOV5pUZoXktGmzB38%te^i-3o9i$lge>z>tBcK|P2K0H9w{l#|i%$~egM)Ys{q>p<9yaE*%v2cy1wXE{AXqG1_b znfyg@Fq*e@yC)^(@$R*j^E;skyEM6pmL$1ctg*mWiWM&q1{nj>E^)Odw$RPr zhjesSk}k}@-e_%uZTy0t_*TJD&6%*HV0KH>xE@oBex6CL@`Ty3nH_2OF#M?6j(j|9 znRKGSfp3Q2i+|>}w?>8g$>r`|OcvG5r;p)z8DO8+O>EvYQ=_~`p}9!ReUEjUnNL@6 z+C*aoo67(sd|7QgW54@V9Y8PnBW$Q+7ZsRFA}Vj*viA!yWUfb!s*yJi6JKsXZCH4j z*B%nJpad-DDvJ8d>xrxkkh6A}i7V3nULqHCiG~|)YY6{NE3M}c^s#PQhzhsJUf^QW zR+F;up-dN*!)M1ZYl@d0HoqfVD2PNiQcPdzq4NDKO!8mUl{!t*ntBg_+-+lRlI0~Lr>5v!PiQj|hD7B-YFIs~6hIY*R6USZA zlb}=UxqxpSzIsL3pPmiuixCN|3LFBd?0Ih8Y6GWQ;U>dkdXtQaQ&8H|TGAQbuHY=F z_R83&B{1_hP7L#$^eAe?GPB_83y#HZKTwD>e-@E2P>Gk$BBb9|Ivfmdp za~s>3=aj(;xmz8n)sI}uFO$|C>0CZbcTY$Bq6~L-Bc9=vl@X#0S~Q@j8iKzuPeQE_ zQSI)wNz~CvJ>!%QszoCfUm9}h^DL!WYAN|FtMO#kpDXq74sYC87(uvv*jiCjV?Ta& zgO1D0OP3TEN3YnBpD6GnmsEolzEbGM{&VlTz_)J(o{nl0+TmNt{xL%L6G&UR$^aYC zQOA#W7R%9JsC5oTZJE>_?!Ci}mNH{0ObyUd%Q!k%5J8Z`8sR!m`~|Taje`(bLD7=a z-{-=d7w;k@DIrgU{I@K}eN`>S**Lg<@ChAf$M(&kV9TLUixqFQ>YoYHrI!K#R6`S> z%?d5hQ@&;Gje<|uRQZb%Hhibocl9(buI?=0aZW{JYXx?ZS@Lr%G8L<d+riEi2~+{HfHK{K^VrGYNi{2-WJOiC>Pz?f*)cxKCl>1H1=$jb!^ zpmYw>eoiM0Hy7$xbbX_e5o*+{7T2&-t%-h4i7MMo;k|tSqQAeNkwHS9hWY#EV7r3| zTmOmN{;b9OUZpp`LP(I9Wo%R#$b6YdH7GD4*p6>a2N2A04pQ*n;INQMh%+mj;x7>S z_(H?uJ^n!r1)kJH1*s+%$al#?C^Cw{H@RA^QGB=Dubyc)XUaY>f`(VKTlIO-YNCp{1n zOl*>jT?Dtf5fD$DY-j&B*Xmn|2-u2OB zBL@-lFs5lhcQKXBR*cIXmi%~EJcc^5#Xpg!E^A6sXf1#$qJGRpmU~A zcdj-cvBfx(fIRAMU(1obztJR%I7v3R-%$#~r!0sS^I(iC*5i6296*88A7I=_JhU3p zya!aCti0R5*RFT%LW0R|;u&oJ6=P-c$le4J0bi}u!!@;xzao|l6fJ{;Mld9hGhrJg zr_B)=4yktp)yPB@tCC_L9h1>GzXD6DA!W7xt{1)8!07~gONkEWC8@y%lciB{9ojy) zWm$drJ_9uVJ>Q$-`@q%OM7_S>(K=__CGYB~@@mE^Z=eT|x0Rv?Z-N)LLWR zod*Zy3v)iMX@usPX-OKBDgC8yq?fMhqf8H)A&C)Hi29YFn!NVf5!J0-F{wC&L5-3`#id=4?=2>Zp6Pdu4N6#bG&atu7 z8IET&ciXy_Tp4YjMx3yIAbw#_e2#jgGJ~ogkv-|M7|%Gio%2@mnS89NKUOM#Bzg4_ z9e9oN;^m>G*#?)AawODi6YckRPmkSKD_4b4WFpj|@|eS!B0WN@?QscYzTH`~6e%iz z!z1>ps)CG37%(E=kZ_>re)@ODv^0^=rWU^*m;6M&gD10EYImO98JVabRe5{#wrogYUKPB@_(#e7Ej9_x;n1oHDj5GawU)A&1hWj|HzJB(q{vMTX>jOW;Jz zBsW&SqTaR7!NXXg_A}$XnFpg_n)Zi;{e9eb*k|b(y$a}12boJ7rqQXQpVhU8HxHTl zt8Ln!KLFyfq!%}hdMXle^qajw2g6S{z&7tQ6J(w9 z3+!HTO{_TqM{9o$RR~lKFf4b4(xLUP?QG;McNFQc_Yd_mig9Ejy9%q~Ye>rIn3};U z)w&1@QCK;cC(;x0G&YuSad+>{c@ZsFJcUdcs@PP-x{mrO)|6_#CjMlXsMJx;Cr?FF zVFrlt@$Z-Ll^*7d0#`5Uez@bb{Xn(BQLhScBhF!6+aIso0=l{PP7P(6-ru>nVy%AP z+|eZpY(ooMU7rtG$l#14v=Z?@ebOjm(A2)5k_${|wAA$oq+;42wiS78ezjgWWnTrF z`1!i2h{fM91aD8uxz?tZpE(PsL37e3$*I6%un5Bzzpn10p`j72R;3=Oaug_|Z(y)@ z9$SJN@-5d1tNIy0=7|d&_HAnDx!yDd-u#qmfuDh)0a_CVje{hvQz9rDFHJTpQ0Dg@ zGQ3t*gZlcFSXfx%OG@Cds&NDROxd^osY_)abmo^dKMUY!R~kGH%*;rutPF@Mx$zrv z6Q1soKnYYRW#;Bi-!H)>Br0<`y+Wy~p7_<>{ljuG`Dpje=v1x}-ND<)bWBr|<}v6B zkDTUZ^@VsH>CyR}ml4j2rB{}0q8eGwX>ExkI9yZN0)(P}$N(yi$AxmBY#Xj`(7zs{ zJbn2&jE`-*0lww_r;|fNaWm_xp;c9JHIv|RExZGKP%18qjgYa);`N-^VqXNVz{~)~ z?^&D;ouy!pKPy?%@xH`A zSR z7x%N3@o&{YEjfa|1;*eW_4TU{ zt;qCcY3Hj(<0DJuny*QL!y!StcG{>bhpUP%eVMq=1xcR>yZT8X9)1;rXOmQjPcANs zr>&Qb{rr66;s|4v3iGmQlMjr9j;G6pqNs%;TsyVNd3{i~hpDX8ugdcnd&UQJzj)rH zh>S6#n`cCJ9CwHv<2Ht$o`R5(h#r||VB?%J?s5W48;^o)b`Pi1^~}5{Y19lg{&W@LfHt*gc1`w$RfLrK{~H?A1$5 z;5v?AIhpN%gQsR6+Act9-3y z8>jCTMnWQq-^s3#Lb|WalgB$k3F>}lyCxs<2&A;LS0}s#<|hPx9kM#B+Lu2DiD_3P zelg;N!80(j@HNc2pXs}re%sHi+{aqBt~qUOy86?zN>7)yiCEJqy@2Gh#gzJE6j6Rx zBQK{77zW?gLWtQ20Dzntu16k9^N>DQ@Nmbx*mOg=F=k)8VJfM%y(Xu41;8YCz+@K| z9u7vhlT`BOnk_oMTeC;u@OhhoTeA`^34^iMihCLM_uVD>rI-9@4l7ocZl@DJ8FWZU zB0lRBIqkHj4#pE&mD(X!e!~;G$`7f47k* zOznM2@`&KM(|f5}sz)z%2}yJ5YmMj5Zwzr-W?v3R&@KuJ+l0zo==N@)nsbMHqHV}w z7#_ntMGCNM21RuH^SYG+RH0sHUsF2z7ams57@2xbPj0y5)8h+caqv@P^q!do+}>+X zzUBx|mikTawzXWYzJ4(AqAJpBF4ObmD_@gyg->oFGB6`k(8+?rFRV5P1yDkFM=8(c z%RI)iG(rKtq-^V%B_(R9;tk6WIzA?x@cESTXg zWYDBxkoNB5v6J8BP&n@HVtBNb@r+XYpjgub zR4oE*$ffXJuh2g8TCaLnpNoSxJ~Jx@ayx9z5Osa)=AI#bg^5eQb<6gpR%c+Qs#N*e z@XE4pAmjdI#0%pV7sIN>mNa^jTkd=<==2_#t-}9Ju&Z^|Lp$%B92@eN%=MRc)LK$% z@!XAg;dQ8bt=@ZNey7+a(dy^o;QKGP@Rb5NJYQRrGEC{J=FB(Irw-MAfoP(9RK;)&jlxSCT=W;ODCf($WqRFhqN#LR^qVhK zWhEp4`{Nnk;n0FHj}eNCZpRM`Y-@MIM&pvr7zQOZ3Ik5;CmZbR99b&22(!-07YNF) z$o0MKej-jnvQV39{TH4r2R5univa1{ASc|VOTi4c@`t2FId|xkh5typ-rdU;1j){adk@*+( zkHj{5B~eSy&HrPOOvl_FJ98)0V;^d`0-u0FTslgiLBQVGSTiSyu zgMGAu&R}SbNa-DgKJb?;fe3Qys$?=;5?V`eRiq*Kj$I`}Z*x4rC~eNM=DsOq(=nUW>(+7o@O8K-_U(X? zTyg032nXKax5W~SF5|eBj%r8Fa>i!ejC72*sd}zJ)t7Xy!gFvM`c4@*Iw>z$u)j_l zR-Uqxymg}>Ti>i%9j*4kwfC33i~kyIQ``n)r(L z!|H2*)Mwj4dk%e*L0tgFdW185>j4<7YwLXwcOsed`%6mS{+=&d@d!B}GkbDV*0 zNIWzW^|trz!&;qeI&mPiVDOUL70xpqVv0fpN9tjpu)@1LD9D<9}9{57j9!W$`zC6&i zl9lKkmPh`x)5+h>>JtiRNNBW5$_)%-)#+SVSGsjX2T=+SRX05>yJZd`1hyk<@{%1+ zDu^k>J$d*Qz6BZMwHx!@O**^Tx&fsHDw%$@J0nfj^je^Ihy*aIx{B(hkBvSvh46Z9 zRO)BjjXL_IHXKo~$4es=8Wxk;Y+&nVBCXA;=MVuLgVn8Mk(*y^+kP3f?Pr~4^A}hXj9UHS}qeI%XKD3KhHnkrNH0(Y20BWl&!Kfm`EVh2;i5C zpirU^K0nc2-I{cqvjZKVx z=&hH#-d=gDWjVE}cMNAPJf;#NYdQ=h`twjX6yquXuCNgGx1~uk{YHAmFpQF`ZLGC=~ukEyj?cFDI zH=@XvV#AY1EY4qb`y*;Ki>KuFB|2|toL7__Cr0S1Dl{s#y0=~7HSq~&7lpBc*VLua zvv3r&-LM*{hq%IYP7<@)dG-G$kMrZaqs(MYoZ zugEeJ@u(ip9rMoVtoFe;dF`^Br5x7v!rr5`hb5mJ#ocGqXHnm9m`yILjd0>UQSMv) z^v}l5^bM6RZ6M%{mkI) zHOoSp&dX)*xUt+kXscna#a`XxI;Ul2Sxa^i5sZc=(Q)oA^2-_;!pfYHAul+oA@Ilelm;rw@FYR+SIaWS?;_ zUdw<|qqaYq(nqu>rG48E9dYAoT6GH;QRuBYK1}W#C_Z_?7~k*pJ3?MzVt&rhZTsBy zw?nN$_Z>kimtwWcy`0?G#!)&7GjOcxCQps@p&ml8>~z(t=sjhR$6aFh!Vw5GA(lTh z5GM)jCwloa6a}7mdfqNYE7oi`Jv$m5>5qR%9eZ=)=a z+K4j5NpcDHHdepCS+P*{@o=yNp&TE(Sd4b0Notqso-Kt_mhDk1<-fa>T4KdY2N`U) zxu41vD%T&k$Gl?CW81%7r#-o1TZ0&PCcy}L4TPiV;sz`|S!&w8-s$rLdM zF&)>@`7=)65PWn#oi|8tXNb|((2ojf9d0fNZ^l7xY~dX~%*Xf-v2W-2n$i~s!4?H; z2qbQscFN21tqB{|x1+(^G~xQSrvX&Y;V-%?b1}zjBQX{GOFcVYTcwm>>}>6^HA=$x zn+z^Biv_5}0!#@7z1~YXJFCT2?D^jm+kH7jAqBo?M@ZdMl|2|66oLnSJXUOJtVLxe z0vH)N^t*qrjq=eFRMV>BFEfS)-2RzKlt973;d3D}4edwIE>kGc5-o=JV56ird)RlS z{Jg@0t-b#Ife80%!E~(7`qkZ8O~Q-8_{j7G&tqwX&&>^tm-#*{v7j-f1n0}mCR#7P z-4FkajD2$9?4Fc7-C_|0Z_G^bxIs%tWk|aFgSQ(qkM+5PRh=g&ZeAZg35$-kn~}_;~&fP-dCNCzg>{gyW!~LZpn?aZ~Va3~H0Ta)z z<4XPVk@;#%1S@fq<(2#8T04#8$mz>vM;(jek0>Qh!K%t5*4tU(fVYwD3Ri~=D!AmI zV$Dt#TEDX7{lpW%tF&DOlTO)vZodn_%wYu~)ZQ}Qo^cBbDHd{YajkzNxttQW>ST<^ z2~^xhB_y1sjIF5;xchvCn{QVugIE2eYZDZ!-Y-4lJdb34*k({@M zJ5!9Di^||~(IZ4iOoAbtggao+CaYvJynmB^;4r-tY2gS_*P!?U?hlEX;l+^*{%B2n z)|1j9wOHQQ^5Xha>{Cu8_w^8=#6;Dz7kU~RgTqn;ynDm6{xdlkf2vk0UK^oS3yVy4 zE+v&qnlYtPHBk#X&2}r7`@K`J@^e~Qm?iRJ*tbAaZDZTmB&mWMkZp7Kj7^kth#_uX z5z>gC(8Xz|Ie(+#&wiF3;Aey|Db(R*-U)!6;l_5@u?-$>j0SgEl5+c}Lfe-$p-dFH zB_$bC<)x6#A_2Uuo8=^l1@}vK!gvbF#b&MoH8ac3xMxUz$LFb8KU(x$YhtHanM_sw zYOFMBX2iNNSe&a}!;G9nv(tsW4@%3iQcqczOCF*JOBQ@4Orw=o?_vc(9$hfO`>U6& zyY_CUa9pASiJpmv`@oR!k;&$`h8!)$uS=}d-fPddfIdMDUW@%3y1LI(1Q=e$)sz(QC*E;Nfl99YTgk+|@jl`+iF?<_D?4YqV0Zl)lO8YWC@1ZWW^mi{5ePQN<~FQ2NMG$|K{py5akJa zkezmqhN)>MGMp$7=sOo2(7ppv``dCIwf&MaQQis7S596kkiw8Do(jO?EY4iJ4Hec6 z4Hymzu`w)cI9Pbq6GPtTP)x&Lmk;FT=ZCB4>(5}c0?;2l`p&?>&<;2(P8a3lOTNP# zdEzF5qDpkRR&PZC&cS{7xD@qV;(g5X%xI?m$9Q + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

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..dce9176 --- /dev/null +++ b/web_form_banner/static/src/js/web_form_banner.esm.js @@ -0,0 +1,145 @@ +/** @odoo-module **/ +// Copyright 2025 Quartile (https://www.quartile.co) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import {patch} from "@web/core/utils/patch"; +import {onMounted, onPatched, onWillUnmount} from "@odoo/owl"; +import {FormController} from "@web/views/form/form_controller"; + +const recRoot = (c) => (c && c.model && c.model.root) || null; +const childSpan = (el) => { + try { + return (el && el.querySelector(":scope > span")) || null; + } catch {} + const f = el && el.firstElementChild; + return f && f.tagName === "SPAN" ? f : null; +}; +const setHtml = (el, html) => { + const s = childSpan(el); + (s || el).innerHTML = html; +}; +const safe = async (fn, fb) => { + try { + return await fn(); + } catch {} + return fb; +}; + +function normalizeValue(v) { + if (v === null || v === undefined) return v; // Null/undefined + const t = typeof v; + if (t === "string" || t === "number" || t === "boolean") return v; + if (Array.isArray(v) && v.length === 2 && typeof v[1] === "string") return v[0]; // M2o [id, name] + if (t === "object") { + if (typeof v.res_id === "number") return v.res_id; // M2o snapshot + if (typeof v.id === "number") return v.id; // M2o env + if (Array.isArray(v.resIds)) return v.resIds; // M2m + } + return undefined; // Ignore others (e.g., command lists) +} +function shrink(data) { + const out = {}; + for (const [k, v] of Object.entries(data || {})) { + const nv = normalizeValue(v); + if (nv !== undefined) out[k] = nv; + } + return out; +} +const sliceBy = (obj, keys) => + keys.reduce((o, k) => (k in obj ? ((o[k] = obj[k]), o) : o), {}); + +const bannersIn = (ctrl) => { + const r = recRoot(ctrl), + m = r && r.resModel; + if (!m) return []; + return Array.from(document.querySelectorAll(".o_form_view [data-rule-id]")).filter( + (el) => el.dataset.model === m + ); +}; +const triggerNames = (ctrl) => { + const s = new Set(); + for (const el of bannersIn(ctrl)) + for (const p of el.dataset.triggerFields.split(",")) { + const k = p.trim(); + if (k) s.add(k); + } + return Array.from(s); +}; + +async function refreshBanners(ctrl, extraChanges) { + const seq = (ctrl.__wfbSeq = (ctrl.__wfbSeq || 0) + 1); + const rec = recRoot(ctrl); + if (!rec) return; + await safe(() => rec.askChanges()); + const nodes = bannersIn(ctrl); + if (!nodes.length) return; + const snap = {...shrink(rec.data), ...shrink(extraChanges)}; + const names = triggerNames(ctrl); + const vals = !rec.resId ? snap : names.length ? sliceBy(snap, names) : {}; + const orm = ctrl.env.services.orm; + for (const el of nodes) { + const ruleId = parseInt(el.dataset.ruleId, 10); + const args = [ruleId, el.dataset.model, rec.resId, vals]; + const r = await safe( + () => orm.call("web.form.banner.rule", "compute_message", args), + null + ); + if ((ctrl.__wfbSeq || 0) !== seq) return; + // Replace only the alert class + el.classList.remove("alert-info", "alert-warning", "alert-danger"); + el.classList.add("alert-" + r.severity); + el.classList.toggle("o_invisible_modifier", !(r && r.visible)); + setHtml(el, r.html); + } +} + +function scheduleRefresh(ctrl) { + if (ctrl.__wfbSched) return; + ctrl.__wfbSched = true; + requestAnimationFrame(() => ((ctrl.__wfbSched = false), refreshBanners(ctrl))); +} + +function tick(ctrl) { + clearTimeout(ctrl.__wfbTimer); + ctrl.__wfbTimer = setTimeout(() => { + const rec = recRoot(ctrl); + if (!rec) return; + const names = triggerNames(ctrl); + if (!names.length) return; + console.log("Inside"); + const slice = sliceBy(shrink(rec.data), names), + prev = ctrl.__wfbPrev || {}; + let changed = null; + for (const k of names) { + const a = k in prev ? JSON.stringify(prev[k]) : undefined; + const b = JSON.stringify(slice[k]); + if (a !== b) (changed || (changed = {}))[k] = slice[k]; + } + ctrl.__wfbPrev = slice; + if (changed) setTimeout(() => refreshBanners(ctrl, changed), 0); + }, 180); +} + +patch(FormController.prototype, "web_form_banner.lean", { + setup() { + this._super(...arguments); + onMounted(() => scheduleRefresh(this)); + onPatched(() => tick(this)); + onWillUnmount(() => clearTimeout(this.__wfbTimer)); + }, + async edit() { + const r = await this._super(...arguments); + scheduleRefresh(this); + return r; + }, + async discard() { + const r = await this._super(...arguments); + scheduleRefresh(this); + return r; + }, + async saveButtonClicked(p = {}) { + const ok = await this._super(p); + if (ok) scheduleRefresh(this); + return ok; + }, +}); 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..0f8699c --- /dev/null +++ b/web_form_banner/static/src/scss/web_form_banner.scss @@ -0,0 +1,11 @@ +// 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; +} + +// 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..55a36b6 --- /dev/null +++ b/web_form_banner/tests/test_web_form_banner.py @@ -0,0 +1,164 @@ +# 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 TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestFieldsViewGetPartnerBanner(TransactionCase): + @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.get_view(view_id=view.id, view_type="form") + 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("role"), "status") + # Class list includes the expected CSS classes + classes = (banner_node.get("class") or "").split() + for required in ("o_form_banner", "alert", "o_invisible_modifier"): + 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.get_view(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 + + +