Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
200 changes: 200 additions & 0 deletions base_field_length_constraint/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
============================
Base Field Length Constraint
============================

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

.. |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%2Fserver--ux-lightgray.png?logo=github
:target: https://github.com/OCA/server-ux/tree/19.0/base_field_length_constraint
:alt: OCA/server-ux
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/server-ux-19-0/server-ux-19-0-base_field_length_constraint
: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/server-ux&target_branch=19.0
:alt: Try me on Runboat

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

This module enforces a maximum length on ``char``, ``text`` and ``html``
fields, defined as configuration data rather than in code.

A rule declares the limit of one field of one model, counted in
characters or in the bytes of a given encoding, and optionally
restricted to a company or to the records matching a domain. An
over-long value is refused when the record is saved, or only reported if
the rule is set to warn.

**Table of contents**

.. contents::
:local:

Configuration
=============

Go to *Settings > Technical > Database Structure > Field Length Rules*
and create a rule.

- **Name**: shown in the error message. Use it to identify where the
limit comes from, so that a violation points at the document to
consult.
- **Model** and **Field**: the field to measure. ``char``, ``text`` and
``html`` fields can be selected. An ``html`` field is measured on the
text it renders to, not on its markup.
- **Maximum Length** and **Measure**: the limit, counted in characters
or in bytes. Measure in bytes whenever the receiving side counts
bytes - a fixed-width record layout, a column with byte semantics -
and set the **Encoding** to the one that side uses, such as
``cp932``. The two only differ once the value stops being pure ASCII,
so a character limit can pass every test and still overflow in
production.
- **Condition**: an optional domain. The rule only applies to the
records that match it. The value is measured again when a record
moves into the scope of the rule, so turning a partner into a company
checks the reference it was allowed to keep while it was a person.
- **Company**: if set, the rule only applies to the records of that
company and of its branches. A record that carries no company of its
own is evaluated against the active company.
- **Enforcement**: ``Error`` refuses the save. ``Warning`` lets it
through and reports it instead, with a dialog as the value is
entered.
- **Custom Message**: replaces the default error message when set.

Several rules may target the same field, so the tightest limit is the
effective one. A value that overruns more than one of them is reported
against each, so that the message always names every rule left to
satisfy.

Rolling out on live data
------------------------

A rule only checks what is written after it exists, so a record that
already breaches it stays as it is and reports nothing. Create the rule,
press **Check Existing Records**, and correct the values it lists.

The button scans the whole table, which is worth knowing before pressing
it on a model holding millions of rows, and it reports the first 1000
violations. Its title says so when the list is cut short: correct those,
press it again, and repeat until it comes back clean.

Usage
=====

Once a rule is active it works on its own, with no code to call. Each
enforcement reports itself once:

- An ``Error`` rule refuses the save, with one validation error listing
every violation of that write.
- A ``Warning`` rule shows a dialog as soon as the value is entered,
then lets the save through, notifies the user and writes a line to
the log.

The **Check Existing Records** button on the rule form lists the stored
records that already violate it. This is how the records predating a
rule are found, since a field is only revalidated when it is written, or
when the record moves into the scope of the rule.

Development
===========

Values built at serialization time - a concatenation, a split, a
converted code - never reach a stored field, so no ORM constraint can
see them. Check them against the rules of the field whose limit applies:

.. code:: python

self.env["base.field.length.rule"].check_value(
"res.partner", "ref", derived_value, record=partner
)

The string is measured exactly as given, with no html extraction.

Pass the record whenever there is one. It is what the company of a rule
is resolved against, and a rule carrying a condition is **skipped
entirely** without it, since there is nothing to evaluate the condition
on.

``validate_records(records, field_names=None)`` does the same for the
stored values of existing records.

Both raise a ``ValidationError`` by default, and return the list of
violations instead when ``raise_on_error=False``. Neither logs nor
notifies anyone: they inspect values, so a warning-enforcement rule is
only ever returned to the caller.

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

- A rule on a translated field is checked in the language of the user
performing the write. Its other translations are not checked.
- A rule scoped to a company judges a record that carries no company of
its own by the active company of whoever writes it, so the same value
can be refused for one user and accepted for another, and a cron or a
``sudo()`` write is judged by the superuser's company.
- The notification of a warning enforcement is addressed to the user
the write runs as, so a write made by a cron, a server action or a
``sudo()`` call notifies the superuser and nobody sees it. The log
entry remains.

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

Bugs are tracked on `GitHub Issues <https://github.com/OCA/server-ux/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/server-ux/issues/new?body=module:%20base_field_length_constraint%0Aversion:%2019.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>`__:

- 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.

.. |maintainer-AungKoKoLin1997| image:: https://github.com/AungKoKoLin1997.png?size=40px
:target: https://github.com/AungKoKoLin1997
:alt: AungKoKoLin1997

Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:

|maintainer-AungKoKoLin1997|

This module is part of the `OCA/server-ux <https://github.com/OCA/server-ux/tree/19.0/base_field_length_constraint>`_ 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 base_field_length_constraint/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
21 changes: 21 additions & 0 deletions base_field_length_constraint/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Copyright 2026 Quartile (https://www.quartile.co)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

{
"name": "Base Field Length Constraint",
"summary": "Enforce configurable length limits on text fields, "
"in characters or bytes",
"version": "19.0.1.0.0",
"category": "Tools",
"author": "Quartile, Odoo Community Association (OCA)",
"maintainers": ["AungKoKoLin1997"],
"website": "https://github.com/OCA/server-ux",
"license": "AGPL-3",
"depends": ["bus"],
"data": [
"security/ir.model.access.csv",
"security/base_field_length_rule_security.xml",
"views/base_field_length_rule_views.xml",
],
"installable": True,
}
2 changes: 2 additions & 0 deletions base_field_length_constraint/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import base_field_length_rule
from . import base
138 changes: 138 additions & 0 deletions base_field_length_constraint/models/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Copyright 2026 Quartile (https://www.quartile.co)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from odoo import api, models

RULE_MODEL = "base.field.length.rule"
# Context key marking the model whose create is still running, see create().
DEFERRED_KEY = "base_field_length_deferred_model"


class Base(models.AbstractModel):
_inherit = "base"

@api.model_create_multi
def create(self, vals_list):
"""Check the conditional rules once the record is complete.

The ORM validates the stored fields from within ``_create``, before the
inverse of a field like ``company_type`` has run, so a condition read
there is read against the defaults the record is about to leave behind:
a contact created from a menu carrying ``default_is_company`` is still
a company at that point, whatever the user picked. Only the rules whose
scope depends on it are held back - a plain limit is unambiguous from
the first write, and reporting it as early as the ORM does keeps the
error on the field the user is looking at.
"""
specs = tuple(
spec for spec in self._get_field_length_specs() if spec.condition_domain
)
if not specs:
return super().create(vals_list)
records = super(Base, self.with_context(**{DEFERRED_KEY: self._name})).create(
vals_list
)
# Back to the caller's context, which the marker must not outlive.
records = records.with_env(self.env)
self.env[RULE_MODEL]._check_records(records.sudo(), specs)
return records

def _validate_fields(self, field_names, excluded_names=()):
specs = self._get_field_length_specs()
if self.env.context.get(DEFERRED_KEY) == self._name:
specs = tuple(spec for spec in specs if not spec.condition_domain)
if not specs:
return super()._validate_fields(field_names, excluded_names)
field_names = set(field_names)
excluded_names = set(excluded_names)
super()._validate_fields(field_names, excluded_names)
rule_model = self.env[RULE_MODEL]
# sudo: a condition that reaches through a relation reads a record the
# writer was never promised access to. Without this, a rule conditioned
# on, say, the country of a partner turns every write of that partner
# into an access error for anyone not allowed to read countries.
rule_model._check_records(self.sudo(), specs, field_names, excluded_names)

def _get_field_length_specs(self):
"""Return the length rules of this model, or an empty tuple."""
if RULE_MODEL not in self.env.registry.models:
return ()
return self.env[RULE_MODEL]._get_rules(self._name)

def _get_field_length_warning_specs(self):
"""Return the rules that warrant an onchange dialog.

Only the non-blocking ones. An ``error`` rule already reports itself by
refusing the save, so warning about it here would show the same message
twice for a single edit - and twice in a row when the user saves
straight from the field, since the client sends the onchange first.
"""
return tuple(
spec
for spec in self._get_field_length_specs()
if spec.enforcement == "warning"
)

def _has_onchange(self, field, other_fields):
# The web client only sends an onchange request for the fields the view
# marks with on_change="1", and _postprocess_on_change relies on this
# method to decide. Without this, the warning below would never be
# requested for a field that has no other reason to trigger an onchange.
if super()._has_onchange(field, other_fields):
return True
return any(
spec.field_name == field.name
for spec in self._get_field_length_warning_specs()
)

def onchange(self, values, field_names, fields_spec):
result = super().onchange(values, field_names, fields_spec)
# Core answers a request naming a field the model no longer has with an
# empty result rather than an error, so that a client holding a stale
# view degrades quietly. Building a record out of those same values
# below would undo that and raise on the missing field instead.
if not result:
return result
# ``field_names`` is empty when the client asks for the default values
# of a new record. Nothing has been entered yet, and the response then
# carries every default, which the derived values below would otherwise
# all report on.
if not field_names:
return result
specs = self._get_field_length_warning_specs()
if not specs:
return result
# A limit is just as often reached by a value the record derives - a
# name pulled from a product, a reference built from a partner - as by
# one that is typed. Those arrive in the response rather than in
# ``values``, and the field they land on is not one the client says it
# modified, so both have to be added for the dialog to appear at all.
# Keyed on the fields the rules watch rather than on the type of the
# value, so that a field the onchange has *cleared* comes through as the
# False it now is. Filtering those out would leave the record holding
# the over-long string the client sent and warn about a value the save
# would never have stored.
watched = {spec.field_name for spec in specs}
derived = {
name: value
for name, value in (result.get("value") or {}).items()
if name in watched
}
warning = self.env[RULE_MODEL]._get_onchange_warning(
self.new({**values, **derived}, origin=self).sudo(),
specs,
set(field_names) | set(derived),
)
if not warning:
return result
# Do not drop a warning raised by another module on the same request,
# but do not let its title or type demote ours either: a "notification"
# would turn the dialog into a toast that is easy to miss.
previous = result.get("warning")
if previous:
warning = dict(
warning,
message="\n".join([previous.get("message", ""), warning["message"]]),
)
result["warning"] = warning
return result
Loading
Loading