diff --git a/README.md b/README.md index 795d3e432914..14b24469a06a 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ addon | version | maintainers | summary [web_excel_export_dynamic_expand](web_excel_export_dynamic_expand/) | 18.0.1.0.0 | | Export collapsed groups or the full tree, based on its view. [web_favicon](web_favicon/) | 18.0.1.0.0 | | Allows to set a custom shortcut icon (aka favicon) [web_filter_header_button](web_filter_header_button/) | 18.0.1.0.0 | | Show selected filters as buttons in the control panel +[web_form_banner](web_form_banner/) | 18.0.1.0.0 | | Web Form Banner [web_group_expand](web_group_expand/) | 18.0.1.0.1 | | Group Expand Buttons [web_ir_actions_act_multi](web_ir_actions_act_multi/) | 18.0.1.0.0 | | Enables triggering of more than one action on ActionManager [web_ir_actions_act_window_message](web_ir_actions_act_window_message/) | 18.0.1.0.0 | | Show a message box to users diff --git a/setup/_metapackage/pyproject.toml b/setup/_metapackage/pyproject.toml index 3e534d8d9674..59f91211c923 100644 --- a/setup/_metapackage/pyproject.toml +++ b/setup/_metapackage/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "odoo-addons-oca-web" -version = "18.0.20251021.0" +version = "18.0.20251023.0" dependencies = [ "odoo-addon-web_calendar_slot_duration==18.0.*", "odoo-addon-web_chatter_position==18.0.*", @@ -15,6 +15,7 @@ dependencies = [ "odoo-addon-web_excel_export_dynamic_expand==18.0.*", "odoo-addon-web_favicon==18.0.*", "odoo-addon-web_filter_header_button==18.0.*", + "odoo-addon-web_form_banner==18.0.*", "odoo-addon-web_group_expand==18.0.*", "odoo-addon-web_ir_actions_act_multi==18.0.*", "odoo-addon-web_ir_actions_act_window_message==18.0.*", diff --git a/web_form_banner/README.rst b/web_form_banner/README.rst new file mode 100644 index 000000000000..54d2f8e456ba --- /dev/null +++ b/web_form_banner/README.rst @@ -0,0 +1,307 @@ +.. 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:1906dbf6835e99a279704d8927db5c2e74b3583721880d44069dc0e1f9cfe84c + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/18.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-18-0/web-18-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=18.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 +===== + +1. Go to *Settings > Technical > User Interface > Form Banner Rules* and + create a rule. +2. Choose Model, select Trigger Fields (optional), set Default Severity, + select Views (optional), update Target XPath (insertion point) and + Position, and configure the message. +3. 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:: 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:: 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:: 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:: 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:: 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:: 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:: 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:: 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} + +If we set up the rules for a partner record as shown below: + +|image1| + +|image2| + +The banners will be displayed in the partner form view: + +|image3| + +Once the values are filled in, the banners will disappear: + +|image4| + +.. |image1| image:: https://raw.githubusercontent.com/OCA/web/18.0/web_form_banner/static/description/partner_email_rule.png +.. |image2| image:: https://raw.githubusercontent.com/OCA/web/18.0/web_form_banner/static/description/partner_tag_rule.png +.. |image3| image:: https://raw.githubusercontent.com/OCA/web/18.0/web_form_banner/static/description/partner_layout_banner.png +.. |image4| image:: https://raw.githubusercontent.com/OCA/web/18.0/web_form_banner/static/description/partner_layout_no_banner.png + +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 000000000000..0650744f6bc6 --- /dev/null +++ b/web_form_banner/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/web_form_banner/__manifest__.py b/web_form_banner/__manifest__.py new file mode 100644 index 000000000000..5b070aa4ab15 --- /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": "18.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 000000000000..64598480b727 --- /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) + + + danger + //field[@name='category_id'] + before + Tag is missing! + + + diff --git a/web_form_banner/i18n/it.po b/web_form_banner/i18n/it.po new file mode 100644 index 000000000000..98a1d186dbeb --- /dev/null +++ b/web_form_banner/i18n/it.po @@ -0,0 +1,373 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * web_form_banner +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\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" + +#. 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 "" +"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" +" (one2many/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 +msgid "" +"Invalid XPath:\n" +"%s" +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_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__model_name +msgid "Model Name" +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:web.form.banner.rule,message:web_form_banner.demo_rule_partner_tag_missing +msgid "Tag is missing!" +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_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 "" +"domain = [(\"partner_id\", \"=\", draft.partner_id.id)]\n" +"if record_id:\n" +" domain += [(\"id\", \"<\", record_id)]\n" +"last = model.search(domain, order=\"date_order desc, id desc\", limit=1)\n" +"if last:\n" +" html = \"<strong>Previous order:</strong> <a href='%s'>%s</a>\" % (url_for(last), last.name)\n" +" result = {\"visible\": True, \"html\": html}\n" +"else:\n" +" result = {\"visible\": False}" +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 dangerous 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/ja.po b/web_form_banner/i18n/ja.po new file mode 100644 index 000000000000..4e85c5bfbbbe --- /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 "" +"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" +" (one2many/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 +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__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_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__model_name +#, fuzzy +msgid "Model Name" +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:web.form.banner.rule,message:web_form_banner.demo_rule_partner_tag_missing +msgid "Tag is missing!" +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_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 "" +"domain = [(\"partner_id\", \"=\", draft.partner_id.id)]\n" +"if record_id:\n" +" domain += [(\"id\", \"<\", record_id)]\n" +"last = model.search(domain, order=\"date_order desc, id desc\", limit=1)\n" +"if last:\n" +" html = \"<strong>Previous order:</strong> <a href='%s'>" +"%s</a>\" % (url_for(last), last.name)\n" +" result = {\"visible\": True, \"html\": html}\n" +"else:\n" +" result = {\"visible\": False}" +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 dangerous 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 "" + +#~ msgid "Last Modified on" +#~ 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 000000000000..86594a656c0c --- /dev/null +++ b/web_form_banner/i18n/web_form_banner.pot @@ -0,0 +1,372 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * web_form_banner +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.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 "" +"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" +" (one2many/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 +msgid "" +"Invalid XPath:\n" +"%s" +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_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__model_name +msgid "Model Name" +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:web.form.banner.rule,message:web_form_banner.demo_rule_partner_tag_missing +msgid "Tag is missing!" +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_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 "" +"domain = [(\"partner_id\", \"=\", draft.partner_id.id)]\n" +"if record_id:\n" +" domain += [(\"id\", \"<\", record_id)]\n" +"last = model.search(domain, order=\"date_order desc, id desc\", limit=1)\n" +"if last:\n" +" html = \"<strong>Previous order:</strong> <a href='%s'>%s</a>\" % (url_for(last), last.name)\n" +" result = {\"visible\": True, \"html\": html}\n" +"else:\n" +" result = {\"visible\": False}" +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 dangerous customers" +msgstr "" + +#. module: web_form_banner +#: model_terms:ir.ui.view,arch_db:web_form_banner.view_web_form_banner_rule_tree +msgid "return {'visible': True, 'values': {'title': '...'}}" +msgstr "" diff --git a/web_form_banner/models/__init__.py b/web_form_banner/models/__init__.py new file mode 100644 index 000000000000..96e16706afae --- /dev/null +++ b/web_form_banner/models/__init__.py @@ -0,0 +1,2 @@ +from . import ir_model +from . import web_form_banner_rule diff --git a/web_form_banner/models/ir_model.py b/web_form_banner/models/ir_model.py new file mode 100644 index 000000000000..4203b0acf111 --- /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 000000000000..626315198fb9 --- /dev/null +++ b/web_form_banner/models/web_form_banner_rule.py @@ -0,0 +1,290 @@ +# 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", string="Model Name", 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/pyproject.toml b/web_form_banner/pyproject.toml new file mode 100644 index 000000000000..4231d0cccb3d --- /dev/null +++ b/web_form_banner/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/web_form_banner/readme/CONTRIBUTORS.md b/web_form_banner/readme/CONTRIBUTORS.md new file mode 100644 index 000000000000..4d16bdc25a52 --- /dev/null +++ b/web_form_banner/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- [Quartile](https://www.quartile.co): + - Yoshi Tashiro + - Aung Ko Ko Lin diff --git a/web_form_banner/readme/DESCRIPTION.md b/web_form_banner/readme/DESCRIPTION.md new file mode 100644 index 000000000000..9741aba5f7c4 --- /dev/null +++ b/web_form_banner/readme/DESCRIPTION.md @@ -0,0 +1,10 @@ +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.md b/web_form_banner/readme/ROADMAP.md new file mode 100644 index 000000000000..275f78178462 --- /dev/null +++ b/web_form_banner/readme/ROADMAP.md @@ -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.md b/web_form_banner/readme/USAGE.md new file mode 100644 index 000000000000..5e342026aad3 --- /dev/null +++ b/web_form_banner/readme/USAGE.md @@ -0,0 +1,178 @@ +1. Go to *Settings \> Technical \> User Interface \> Form Banner Rules* + and create a rule. +2. Choose Model, select Trigger Fields (optional), set Default + Severity, select Views (optional), update Target XPath (insertion + point) and Position, and configure the message. +3. 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: + +``` python +{"visible": not bool(record.email)} +``` + +**B) Show partner comment if available** + +- Model: purchase.order +- Message: Vendor Comments: \${comment} +- Message Value Code (single expression): + +``` 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: + +``` 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: + +``` 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: + +``` 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): + +``` 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: + +``` 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): + +``` 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} +``` + +If we set up the rules for a partner record as shown below: + +![](../static/description/partner_email_rule.png) + +![](../static/description/partner_tag_rule.png) + +The banners will be displayed in the partner form view: + +![](../static/description/partner_layout_banner.png) + +Once the values are filled in, the banners will disappear: + +![](../static/description/partner_layout_no_banner.png) diff --git a/web_form_banner/security/ir.model.access.csv b/web_form_banner/security/ir.model.access.csv new file mode 100644 index 000000000000..59ac26436524 --- /dev/null +++ b/web_form_banner/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_web_form_banner_rule_public,web_form_banner_rule_public,model_web_form_banner_rule,base.group_public,1,0,0,0 +access_web_form_banner_rule_portal,web_form_banner_rule_portal,model_web_form_banner_rule,base.group_portal,1,0,0,0 +access_web_form_banner_rule_user,web_form_banner_rule_user,model_web_form_banner_rule,base.group_user,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 000000000000..1dcc49c24f36 Binary files /dev/null and b/web_form_banner/static/description/icon.png differ diff --git a/web_form_banner/static/description/index.html b/web_form_banner/static/description/index.html new file mode 100644 index 000000000000..457e0339ace8 --- /dev/null +++ b/web_form_banner/static/description/index.html @@ -0,0 +1,645 @@ + + + + + +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}
+
+

If we set up the rules for a partner record as shown below:

+

image1

+

image2

+

The banners will be displayed in the partner form view:

+

image3

+

Once the values are filled in, the banners will disappear:

+

image4

+
+
+
+

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/description/partner_email_rule.png b/web_form_banner/static/description/partner_email_rule.png new file mode 100644 index 000000000000..e08e6dd93676 Binary files /dev/null and b/web_form_banner/static/description/partner_email_rule.png differ diff --git a/web_form_banner/static/description/partner_layout_banner.png b/web_form_banner/static/description/partner_layout_banner.png new file mode 100644 index 000000000000..70ac2285b741 Binary files /dev/null and b/web_form_banner/static/description/partner_layout_banner.png differ diff --git a/web_form_banner/static/description/partner_layout_no_banner.png b/web_form_banner/static/description/partner_layout_no_banner.png new file mode 100644 index 000000000000..97213980dd6d Binary files /dev/null and b/web_form_banner/static/description/partner_layout_no_banner.png differ diff --git a/web_form_banner/static/description/partner_tag_rule.png b/web_form_banner/static/description/partner_tag_rule.png new file mode 100644 index 000000000000..4f26ce86365d Binary files /dev/null and b/web_form_banner/static/description/partner_tag_rule.png differ 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 000000000000..c51144cd2476 --- /dev/null +++ b/web_form_banner/static/src/js/web_form_banner.esm.js @@ -0,0 +1,170 @@ +/** @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, onWillUnmount} from "@odoo/owl"; +import {FormController} from "@web/views/form/form_controller"; +import {Record} from "@web/model/relational_model/record"; + +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)) + return v.length === 2 && typeof v[1] === "string" ? v[0] : [...v]; // M2o id or cloned m2m ids + 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._currentIds)) return [...v._currentIds]; // 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; + 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, { + setup() { + super.setup(); + this.model.__controller = this; + onMounted(() => scheduleRefresh(this)); + onWillUnmount(() => clearTimeout(this.__wfbTimer)); + }, + async discard() { + const r = await super.discard(...arguments); + scheduleRefresh(this); + return r; + }, + async create() { + const r = await super.create(); + scheduleRefresh(this); + return r; + }, + async save(p = {}) { + const ok = await super.save(p); + if (ok) scheduleRefresh(this); + return ok; + }, + async duplicateRecord() { + const r = await super.duplicateRecord(); + scheduleRefresh(this); + return r; + }, + async onPagerUpdate(...args) { + const r = await super.onPagerUpdate(...args); + scheduleRefresh(this); + return r; + }, +}); + +const _superApplyChanges = Record.prototype._applyChanges; +patch(Record.prototype, { + _applyChanges(changes, serverChanges = {}) { + const res = _superApplyChanges.call(this, changes, serverChanges); + try { + if (this.model && this.model.root === this) { + const ctrl = this.model.__controller; + if (ctrl) tick(ctrl); + } + } catch {} + return res; + }, +}); diff --git a/web_form_banner/static/src/scss/web_form_banner.scss b/web_form_banner/static/src/scss/web_form_banner.scss new file mode 100644 index 000000000000..0f8699c4c8b3 --- /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 000000000000..383a4dd50d20 --- /dev/null +++ b/web_form_banner/tests/__init__.py @@ -0,0 +1 @@ +from . import test_web_form_banner diff --git a/web_form_banner/tests/test_web_form_banner.py b/web_form_banner/tests/test_web_form_banner.py new file mode 100644 index 000000000000..4754ddaa5fa3 --- /dev/null +++ b/web_form_banner/tests/test_web_form_banner.py @@ -0,0 +1,162 @@ +# 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 = f"//div[@data-rule-id='{rule.id}' and contains(@class,'o_form_banner')]" + 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("Tag is missing!", 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 000000000000..7db656156dfe --- /dev/null +++ b/web_form_banner/views/web_form_banner_rule_views.xml @@ -0,0 +1,204 @@ + + + + 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 + (one2many/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", "<", 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}
+
+
+
+
+
+
+
+ + web.form.banner.rule.search + web.form.banner.rule + + + + + + + + + + + + + + + + + + + Form Banner Rules + web.form.banner.rule + list,form + + +