Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions setup/web_form_banner/odoo/addons/web_form_banner
6 changes: 6 additions & 0 deletions setup/web_form_banner/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import setuptools

setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)
271 changes: 271 additions & 0 deletions web_form_banner/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
===============
Web Form Banner
===============

..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:abc8ad462d827f2f1b07f5fed63fe06f86ee2a9121f5a0c0e77f1ae8607ef8f1
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github
:target: https://github.com/OCA/web/tree/15.0/web_form_banner
:alt: OCA/web
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/web-15-0/web-15-0-web_form_banner
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/web&target_branch=15.0
:alt: Try me on Runboat

|badge1| |badge2| |badge3| |badge4| |badge5|

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

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

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

**Table of contents**

.. contents::
:local:

Usage
=====

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

Usage of message fields:
~~~~~~~~~~~~~~~~~~~~~~~~

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

Evaluation context variables available in Message Value Code:
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

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

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

Trigger Fields
~~~~~~~~~~~~~~

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

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

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

Message setting examples:
~~~~~~~~~~~~~~~~~~~~~~~~~

**A) Missing email on contact (warning)**

* Model: `res.partner`
* Message: `This contact has no email.`
* Message Value Code:

.. code-block:: python

{"visible": not bool(record.email)}

**B) Show partner comment if available**

* Model: `purchase.order`
* Message: `Vendor Comments: ${comment}`
* Message Value Code (single expression):

.. code-block:: python

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

It is also possible to use "convenience placeholders" without an explicit `values` key:

.. code-block:: python

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

**C) High-value sale order (dynamic severity)**

* Model: `sale.order`
* Message: `High-value order: ${amount_total}`
* Message Value Code:

.. code-block:: python

{
"visible": record.amount_total >= 30000,
"severity": "danger" if record.amount_total >= 100000 else "warning",
"values": {"amount_total": record.amount_total},
}

**D) Quotation past validity date**

* Model: `sale.order`
* Message: `This quotation is past its validity date (${validity_date}).`
* Message Value Code:

.. code-block:: python

{
"visible": bool(record.validity_date and context_today() > record.validity_date and record.state in ["draft", "sent"]),
"values": {"validity_date": record.validity_date},
}

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

* Model: `project.task`
* Message: `There are ${cnt} pending activities.`
* Message Value Code (multi-line with `result`):

.. code-block:: python

cnt = env["mail.activity"].search_count([("res_model","=",record._name),("res_id","=",record.id)])
result = {"visible": cnt > 0, "values": {"cnt": cnt}}

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

* Model: `product.template`
* Trigger Fields: `default_code`
* Message: `Make sure to set an internal reference!`
* Message Value Code:

.. code-block:: python

{"visible": not bool(draft.default_code)}

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

* Model: `sale.order`
* Trigger Fields: `partner_id`
* Message: (leave blank; `html` provided by Message Value Code)
* Message Value Code (multi-line with `result`):

.. code-block:: python

domain = [("partner_id", "=", draft.partner_id.id)]
if record_id:
domain += [("id", "<", record_id)]
last = model.search(domain, order="date_order desc, id desc", limit=1)
if last:
html = "<strong>Previous order:</strong> <a href='%s'>%s</a>" % (url_for(last), last.name)
result = {"visible": True, "html": html}
else:
result = {"visible": False}

Known issues / Roadmap
======================

Banner presentation inside `<group>`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Placing a full-width inline banner inside `<group>` is currently not supported. The
presentation of the banner and the child fields will be distorted.

Limitations of `draft` eval context variable
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

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

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/OCA/web/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 <https://github.com/OCA/web/issues/new?body=module:%20web_form_banner%0Aversion:%2015.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

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

Credits
=======

Authors
~~~~~~~

* Quartile

Contributors
~~~~~~~~~~~~

* `Quartile <https://www.quartile.co>`_:

* 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 <https://github.com/OCA/web/tree/15.0/web_form_banner>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
1 change: 1 addition & 0 deletions web_form_banner/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
23 changes: 23 additions & 0 deletions web_form_banner/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Copyright 2025 Quartile (https://www.quartile.co)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Web Form Banner",
"version": "15.0.1.0.0",
"category": "Web",
"author": "Quartile, Odoo Community Association (OCA)",
"website": "https://github.com/OCA/web",
"license": "AGPL-3",
"depends": ["web"],
"data": [
"security/ir.model.access.csv",
"views/web_form_banner_rule_views.xml",
],
"assets": {
"web.assets_backend": [
"web_form_banner/static/src/js/*.esm.js",
"web_form_banner/static/src/scss/*.scss",
],
},
"demo": ["demo/web_form_banner_rule_demo.xml"],
"installable": True,
}
64 changes: 64 additions & 0 deletions web_form_banner/demo/web_form_banner_rule_demo.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<record id="demo_rule_partner_name_length" model="web.form.banner.rule">
<field name="name">Partner name length notice</field>
<field name="model_id" ref="base.model_res_partner" />
<field name="severity">warning</field>
<field name="target_xpath">//sheet</field>
<field name="position">before</field>
<field
name="message_value_code"
><![CDATA[
name = (record.name or "").strip()
n = len(name)
if n > 20:
result = {
"visible": True,
"severity": "danger",
"html": "<strong>This partner's name is very long!</strong> (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}
]]></field>
</record>
<record id="demo_rule_partner_email_missing" model="web.form.banner.rule">
<field name="name">Partner email missing notice (dynamic)</field>
<field name="model_id" ref="base.model_res_partner" />
<field
name="trigger_field_ids"
eval="[(6, 0, [ref('base.field_res_partner__email')])]"
/>
<field name="severity">warning</field>
<field name="target_xpath">//sheet</field>
<field name="position">before</field>
<field name="message">This partner is missing email!</field>
<field
name="message_value_code"
><![CDATA[
{"visible": not bool(draft.email)}
]]></field>
</record>
<record id="demo_rule_partner_tag_missing" model="web.form.banner.rule">
<field name="name">Partner tag missing notice (dynamic)</field>
<field name="model_id" ref="base.model_res_partner" />
<field
name="trigger_field_ids"
eval="[(6, 0, [ref('base.field_res_partner__category_id')])]"
/>
<field name="severity">warning</field>
<field name="target_xpath">//sheet</field>
<field name="position">before</field>
<field name="message">This partner is missing a tag!</field>
<field
name="message_value_code"
><![CDATA[
{"visible": not bool(draft.category_id)}
]]></field>
</record>
</odoo>
Loading