diff --git a/base_field_length_constraint/README.rst b/base_field_length_constraint/README.rst new file mode 100644 index 0000000000..357684f7fd --- /dev/null +++ b/base_field_length_constraint/README.rst @@ -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 `_. +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 `__: + + - 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 `__: + +|maintainer-AungKoKoLin1997| + +This module is part of the `OCA/server-ux `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/base_field_length_constraint/__init__.py b/base_field_length_constraint/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/base_field_length_constraint/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/base_field_length_constraint/__manifest__.py b/base_field_length_constraint/__manifest__.py new file mode 100644 index 0000000000..c234bf8c42 --- /dev/null +++ b/base_field_length_constraint/__manifest__.py @@ -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, +} diff --git a/base_field_length_constraint/models/__init__.py b/base_field_length_constraint/models/__init__.py new file mode 100644 index 0000000000..9707437cad --- /dev/null +++ b/base_field_length_constraint/models/__init__.py @@ -0,0 +1,2 @@ +from . import base_field_length_rule +from . import base diff --git a/base_field_length_constraint/models/base.py b/base_field_length_constraint/models/base.py new file mode 100644 index 0000000000..d4444eaec4 --- /dev/null +++ b/base_field_length_constraint/models/base.py @@ -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 diff --git a/base_field_length_constraint/models/base_field_length_rule.py b/base_field_length_constraint/models/base_field_length_rule.py new file mode 100644 index 0000000000..f7e7c78ebe --- /dev/null +++ b/base_field_length_constraint/models/base_field_length_rule.py @@ -0,0 +1,736 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +from ast import literal_eval +from collections import namedtuple + +from odoo import api, fields, models, tools +from odoo.exceptions import AccessError, ValidationError +from odoo.tools.mail import html_to_inner_content +from odoo.tools.sql import table_exists + +_logger = logging.getLogger(__name__) + +MEASURABLE_TTYPES = ("char", "text", "html") +ANY_OPERATORS = ("any", "not any", "any!", "not any!") +CHECK_BATCH_SIZE = 1000 +MAX_VIOLATIONS = 1000 + +# Immutable projection of a rule, as stored in the ormcache. +RuleSpec = namedtuple( + "RuleSpec", + "id field_name ttype max_length measure encoding condition_domain enforcement " + "company_id trigger_names", +) + + +class BaseFieldLengthRule(models.Model): + _name = "base.field.length.rule" + _description = "Field Length Rule" + _order = "model, field_id, id" + + name = fields.Char( + required=True, + help="Label of the rule, shown in the error message. Use it to identify " + "where the limit comes from, so that a violation points at the " + "specification to fix.", + ) + model_id = fields.Many2one( + "ir.model", + string="Model", + required=True, + ondelete="cascade", + index=True, + ) + model = fields.Char( + related="model_id.model", string="Model Name", store=True, index=True + ) + field_id = fields.Many2one( + "ir.model.fields", + string="Field", + required=True, + ondelete="cascade", + domain=f"[('model_id', '=', model_id), ('store', '=', True), " + f"('ttype', 'in', {list(MEASURABLE_TTYPES)})]", + ) + max_length = fields.Integer( + required=True, + default=1, + help="Maximum length the value may reach, counted with the measure below.", + ) + measure = fields.Selection( + [("char", "Characters"), ("byte", "Bytes")], + required=True, + default="char", + help="Some systems state their limits in bytes of a given encoding " + "rather than in characters, which makes a difference as soon as the " + "value is not pure ASCII.", + ) + encoding = fields.Char( + default="utf-8", + help="Encoding used to measure the value when the measure is Bytes.", + ) + condition_domain = fields.Char( + string="Condition", + help="If set, the rule only applies to the records matching this domain.", + ) + enforcement = fields.Selection( + [("error", "Error"), ("warning", "Warning")], + required=True, + default="error", + help="Warning notifies the user and writes to the log instead of " + "blocking the write, which allows the rule to be rolled out on live " + "data before it starts refusing values.", + ) + message = fields.Text( + translate=True, + help="Custom error message. The default message is used when empty.", + ) + company_id = fields.Many2one( + "res.company", + string="Company", + ondelete="cascade", + help="If set, the rule only applies to the records of this company. " + "Leave empty to apply it to all companies.", + ) + active = fields.Boolean(default=True) + + _max_length_positive = models.Constraint( + "CHECK(max_length > 0)", + "The maximum length must be a positive number.", + ) + + @api.constrains("model_id", "field_id") + def _check_field_id(self): + for rule in self: + if rule.model_id.abstract: + raise ValidationError( + self.env._( + "Model '%s' is abstract, so it stores no record to check.", + rule.model_id.model, + ) + ) + if rule.field_id.model_id != rule.model_id: + raise ValidationError( + self.env._( + "Field '%(field)s' does not belong to model '%(model)s'.", + field=rule.field_id.name, + model=rule.model_id.model, + ) + ) + if rule.field_id.ttype not in MEASURABLE_TTYPES: + raise ValidationError( + self.env._( + "Field '%(field)s' is of type '%(ttype)s'. Only %(types)s " + "fields can be measured.", + field=rule.field_id.name, + ttype=rule.field_id.ttype, + types=", ".join(MEASURABLE_TTYPES), + ) + ) + if not rule.field_id.store: + # The ORM only validates around stored writes: create passes + # the stored names, and _compute_field_value skips a field + # that is not stored. A rule here would never fire at all, or + # - for the rare non-stored field carrying an inverse - fire + # only when someone writes that field directly and never when + # the value it derives from changes. Either way it cannot hold + # the limit, so refuse it rather than let it look active. + raise ValidationError( + self.env._( + "Field '%s' is not stored, so its value cannot be " + "reliably validated. Set the rule on a stored field.", + rule.field_id.name, + ) + ) + + @api.constrains("measure", "encoding") + def _check_encoding(self): + for rule in self.filtered(lambda rule: rule.measure == "byte"): + try: + "Aあ".encode(rule.encoding or "", "replace") + except (LookupError, UnicodeError) as error: + raise ValidationError( + self.env._("Unknown encoding '%s'.", rule.encoding) + ) from error + + @api.constrains("condition_domain", "model_id") + def _check_condition_domain(self): + for rule in self.filtered("condition_domain"): + try: + domain = fields.Domain(literal_eval(rule.condition_domain)) + domain.validate(self.env[rule.model_id.model]) + except Exception as error: + raise ValidationError( + self.env._( + "Invalid condition on rule '%(rule)s': %(error)s", + rule=rule.name, + error=error, + ) + ) from error + + @api.onchange("model_id") + def _onchange_model_id(self): + self.field_id = False + self.condition_domain = False + + def _clear_caches(self): + self.env.registry.clear_cache("default", "templates") + + @api.model_create_multi + def create(self, vals_list): + rules = super().create(vals_list) + self._clear_caches() + return rules + + def write(self, vals): + result = super().write(vals) + self._clear_caches() + return result + + def unlink(self): + result = super().unlink() + self._clear_caches() + return result + + def _to_spec(self): + """Return the immutable projection of this rule used by the checks.""" + self.ensure_one() + rule = self.sudo() + condition_domain = ( + fields.Domain(literal_eval(rule.condition_domain)) + if rule.condition_domain + else False + ) + return RuleSpec( + id=rule.id, + field_name=rule.field_id.name, + ttype=rule.field_id.ttype, + max_length=rule.max_length, + measure=rule.measure, + encoding=rule.encoding or "utf-8", + condition_domain=condition_domain, + enforcement=rule.enforcement, + company_id=rule.company_id.id, + trigger_names=self._get_trigger_names( + rule.model, rule.field_id.name, condition_domain, rule.company_id.id + ), + ) + + @api.model + def _get_trigger_names(self, model_name, field_name, condition_domain, company_id): + """Return the names whose write puts the rule back in question. + + The measured field is the obvious one, but a rule that only applies to + part of the records reaches its scope through other fields, and a value + that was out of scope when it was written has to be measured again the + moment the record moves into it - a reference that was allowed to be + long while the partner was a person is not, once it is turned into a + company. + + A field that is not stored but inverses back into one of those names + counts as well: the client writes ``company_type`` where the condition + reads ``is_company``, and the inverse is what carries one to the other. + """ + names = {field_name} + if condition_domain: + # Only the leading segment: what a path crosses into belongs to + # another record, whose own writes never reach this model's + # validation anyway. + names |= { + condition.field_expr.split(".")[0] + for condition in condition_domain.iter_conditions() + } + if company_id: + names.add("company_id") + model = self.env[model_name] + field_depends = self.env.registry.field_depends + names |= { + field.name + for field in model._fields.values() + if field.inverse + and not field.store + and not names.isdisjoint( + depends.split(".")[0] for depends in field_depends[field] + ) + } + return frozenset(names) + + @api.model + def _condition_names_a_live_field(self, model_name, domain): + """Whether every field the condition names still exists. + + The condition is checked when the rule is saved, and the schema moves + afterwards - a module is uninstalled, a custom field is deleted - with + nothing to run the constraint again. Classifying that drift here rather + than catching it later is what keeps the two apart: ``filtered_domain`` + reports a dead field with a bare ``ValueError``, which is also what a + bug in a field's search method raises, and an unenforced limit must + never be the way a bug reports itself. + """ + for condition in domain.iter_conditions(): + model = self.env[model_name] + for name in condition.field_expr.split("."): + field = model._fields.get(name) + if field is None: + return False + if not field.comodel_name: + break + model = self.env[field.comodel_name] + # An ``any`` carries a whole domain of its own, which iter_conditions + # yields nothing of while the evaluation does reach into it - so a + # field that has gone from there would surface as the very + # ValueError this exists to forestall. That domain is read against + # the comodel of a relation, and against this same model for ``id``, + # which is what the loop above has left in ``model`` either way. + if condition.operator in ANY_OPERATORS and isinstance( + condition.value, list | tuple | fields.Domain + ): + if not self._condition_names_a_live_field( + model._name, fields.Domain(condition.value) + ): + return False + return True + + @api.model + def _get_rule_spec(self, rule, model_name): + """Return the spec of ``rule``, or None when it cannot be enforced. + + This runs on every create and write of the model, so a rule that has + drifted away from what it was saved against has to be dropped rather + than raised: one unenforceable limit is a misconfiguration to correct, + while an exception here is every write on the model refused, with an + error naming neither the rule nor the field. Both are said out loud, and + only once per cache fill, since the caller is memoised. + """ + try: + spec = rule._to_spec() + usable = not spec.condition_domain or self._condition_names_a_live_field( + model_name, spec.condition_domain + ) + except (ValueError, SyntaxError, TypeError) as error: + _logger.warning( + "Skipping field length rule %s: its condition cannot be read " + "(%s). Fix or delete the rule.", + rule.id, + error, + ) + return None + if not usable: + _logger.warning( + "Skipping field length rule %s: its condition %s names a field " + "that no longer exists. Fix or delete the rule.", + rule.id, + spec.condition_domain, + ) + return None + return spec + + @api.model + @tools.ormcache("model_name") + def _get_rules(self, model_name): + """Return the active rules of ``model_name`` as immutable specs. + + This is consulted on every create and write of every model, so a model + without any rule must cost a single dictionary lookup. Never return a + recordset from here: the result outlives the environment it was built + with. + """ + if not table_exists(self.env.cr, self._table): + return () + rules = ( + self.sudo() + .with_context(active_test=True) + .search([("model", "=", model_name)]) + ) + specs = (self._get_rule_spec(rule, model_name) for rule in rules) + return tuple(spec for spec in specs if spec is not None) + + @api.model + def _get_measurable_value(self, value, spec): + """Return the string a rule applies to, for a stored field value. + + The stored value of an html field carries the markup, which no external + interface ever receives, so the text it renders to is measured instead. + The extraction approximates what the interface layer will build; + ``check_value`` measures a given string exactly and is the precise + answer when that matters. + """ + if spec.ttype == "html": + return html_to_inner_content(value) + return value + + @api.model + def _measure_length(self, value, measure, encoding): + if measure != "byte": + return len(value) + try: + # Unmappable characters are replaced rather than raising: reporting + # them is a charset concern, not a length one. + return len(value.encode(encoding, "replace")) + except (LookupError, UnicodeError) as error: + raise ValidationError( + self.env._("Cannot measure a value in '%s' bytes.", encoding) + ) from error + + @api.model + def _rule_applies_to_company(self, spec, record): + if not spec.company_id: + return True + company = record.company_id if "company_id" in record._fields else False + company = company or self.env.company + return spec.company_id in company.parent_ids.ids + + @api.model + def _get_violations(self, records, specs, field_names=None, excluded_names=()): + """Return the violations of ``specs`` on ``records`` as a list of dicts. + + ``field_names`` restricts the check to these measured fields, and + ``excluded_names`` drops them. Which rules a write puts back in + question is a wider question than which field it measures, and is + answered by ``_check_records``. + """ + violations = [] + for spec in specs: + field_name = spec.field_name + if field_name not in records._fields: + continue + if field_names is not None and field_name not in field_names: + continue + if field_name in excluded_names: + continue + targets = records + if spec.condition_domain: + targets = targets.filtered_domain(spec.condition_domain) + for record in targets: + if not self._rule_applies_to_company(spec, record): + continue + value = self._get_measurable_value(record[field_name], spec) + if not value: + continue + length = self._measure_length(value, spec.measure, spec.encoding) + if length <= spec.max_length: + continue + violations.append( + { + "rule_id": spec.id, + "record": record.with_env(self.env), + "field_name": field_name, + "length": length, + "max_length": spec.max_length, + "measure": spec.measure, + "enforcement": spec.enforcement, + } + ) + return violations + + @api.model + def _format_violation(self, violation): + rule = self.sudo().browse(violation["rule_id"]) + if rule.message: + return rule.message + unit = ( + self.env._("bytes") + if violation["measure"] == "byte" + else self.env._("characters") + ) + record = violation["record"] + return self.env._( + "%(record)s: '%(field)s' exceeds the limit of %(max_length)s %(unit)s " + "set by '%(rule)s' (actual length: %(length)s).", + # sudo: the reported record is deliberately bound to the caller's + # environment, but naming it in the message must not raise. + record=record.sudo().display_name if record else self.env._("Value"), + field=rule.field_id.field_description, + max_length=violation["max_length"], + unit=unit, + rule=rule.name, + length=violation["length"], + ) + + @api.model + def _notify_warnings(self, messages): + """Push the non-blocking violations to the web client of the user. + + The notification is queued until the transaction commits, which is the + wanted behaviour here since the value was accepted. It only reaches a + user with an open session, so the log entry stays the reliable trace. + """ + self.env.user._bus_send( + "simple_notification", + { + "type": "warning", + "title": self.env._("Field Length"), + "message": "\n".join(messages), + "sticky": True, + }, + ) + + @api.model + def _get_onchange_warning(self, record, specs, field_names): + """Return the onchange warning for the fields just edited, if any. + + This warns while the value is being entered, before the write is even + attempted, and it travels back in the onchange response rather than + over the bus. The caller passes the non-blocking rules only. + """ + violations = self._get_violations(record, specs, field_names) + if not violations: + return {} + return { + "title": self.env._("Field Length"), + "message": "\n".join( + self._format_violation(violation) for violation in violations + ), + "type": "dialog", + } + + @api.model + def _report_violations(self, violations, notify=True): + """Raise the blocking violations as one error, report the others. + + A warning is only emitted once nothing blocks: raising rolls the write + back, and a log line saying a value went through would then be false. + + :param notify: whether to log and notify the warnings. The explicit + check methods pass ``False``: they are called to inspect values, + and must not push a notification at the end user as a side effect + of a caller looking something up. + """ + errors = [] + warnings = [] + for violation in violations: + message = self._format_violation(violation) + if violation["enforcement"] == "error": + errors.append(message) + else: + warnings.append(message) + if errors: + raise ValidationError("\n".join(errors)) + if not notify or not warnings: + return + for message in warnings: + _logger.warning("Field length rule violated - %s", message) + self._notify_warnings(warnings) + + @api.model + def _check_records(self, records, specs, field_names=None, excluded_names=()): + """Check the rules that the fields just written put back in question. + + ``field_names`` are the fields the write covers and ``excluded_names`` + the ones the ORM is still inversing, both following the semantics of + ``_validate_fields``. A rule reached through a field still being + inversed is left to the validation that follows the inverse, since the + record does not hold its final value yet and the scope read now would + be the one it is leaving. + """ + if field_names is not None: + specs = tuple( + spec + for spec in specs + if not spec.trigger_names.isdisjoint(field_names) + and spec.trigger_names.isdisjoint(excluded_names) + ) + if not specs: + return + violations = self._get_violations(records, specs) + if violations: + self._report_violations(violations) + + @api.private + @api.model + def validate_records(self, records, field_names=None, raise_on_error=True): + """Validate ``records`` against the rules defined on their model. + + For the callers that need to check records outside of a write, + typically an interface layer about to serialize them. + + :param field_names: limit the check to these fields, all of them by default + :param raise_on_error: return the violations instead of raising + :return: the list of violations + """ + if not records: + return [] + specs = self._get_rules(records._name) + if not specs: + return [] + # sudo, as the write path does: evaluating a condition or naming a + # record in the message must not raise on a caller who happens not to + # be allowed to read what the rule looks at. + violations = self._get_violations(records.sudo(), specs, field_names) + if raise_on_error: + self._report_violations(violations, notify=False) + return violations + + @api.private + @api.model + def check_value( + self, model_name, field_name, value, record=None, raise_on_error=True + ): + """Validate a string against the rules registered for a field. + + Values derived at serialization time - a concatenation, a split, a + converted code - never reach a stored field, so no ORM constraint can + protect them. Call this right before handing the value to the external + interface, naming the field whose rules express that interface's limit. + + The string is measured exactly as given, with no html extraction: pass + what the interface will receive. + + :param record: the single record the value derives from, used to + evaluate the condition and the company of the rules. A rule + carrying a condition is skipped when no record is given, since + there is nothing to evaluate it against. + :param raise_on_error: return the violations instead of raising + :return: the list of violations + """ + if record is not None: + record.ensure_one() + # sudo for the same reason as validate_records; reported below in + # the caller's environment. + reported_record, record = record, record.sudo() + else: + reported_record = record + company_holder = self.env[model_name] if record is None else record + violations = [] + for spec in self._get_rules(model_name): + if spec.field_name != field_name or not value: + continue + if spec.condition_domain and ( + record is None or not record.filtered_domain(spec.condition_domain) + ): + continue + if not self._rule_applies_to_company(spec, company_holder): + continue + length = self._measure_length(value, spec.measure, spec.encoding) + if length <= spec.max_length: + continue + violations.append( + { + "rule_id": spec.id, + "record": reported_record, + "field_name": field_name, + "length": length, + "max_length": spec.max_length, + "measure": spec.measure, + "enforcement": spec.enforcement, + } + ) + if raise_on_error: + self._report_violations(violations, notify=False) + return violations + + def action_check_existing_records(self): + """List the stored records that already violate the rule. + + This is what makes a rollout on live data possible: run it in warning + mode, clean up what it returns, then switch the rule to error. + """ + self.ensure_one() + # Public methods are RPC-callable and call_kw enforces no ACL of its + # own, while _to_spec() below reads the rule with elevated rights. + self.check_access("read") + try: + spec = self._to_spec() + except (ValueError, SyntaxError, TypeError) as error: + raise self._unusable_condition_error(error) from error + condition = spec.condition_domain or fields.Domain.TRUE + # The condition is applied in SQL just below, so drop it from the spec + # rather than have _get_violations re-filter every batch in memory. + specs = (spec._replace(condition_domain=False),) + model = self.env[self.model].with_context(active_test=False) + try: + model.check_access("read") + except AccessError as error: + raise AccessError( + self.env._( + "You cannot audit '%(model)s', because you are not allowed " + "to read its records.", + model=self.model_id.display_name, + ) + ) from error + violating_ids = [] + last_id = 0 + while True: + try: + batch = model.search_fetch( + condition & fields.Domain("id", ">", last_id), + [spec.field_name], + limit=CHECK_BATCH_SIZE, + order="id", + ) + except ValueError as error: + raise self._unusable_condition_error(error) from error + if not batch: + break + last_id = batch[-1].id + violating_ids += [ + violation["record"].id + for violation in self._get_violations(batch, specs) + ] + batch.invalidate_recordset() + # One past the cap, so that a table holding exactly MAX_VIOLATIONS + # of them is reported as the complete answer it is rather than sent + # round a clean-up loop that never ends. + if len(violating_ids) > MAX_VIOLATIONS: + break + truncated = len(violating_ids) > MAX_VIOLATIONS + del violating_ids[MAX_VIOLATIONS:] + return self._get_audit_action(violating_ids, truncated) + + def _unusable_condition_error(self, error): + return ValidationError( + self.env._( + "The condition of rule '%(rule)s' can no longer be applied to " + "model '%(model)s': %(error)s", + rule=self.name, + model=self.model_id.display_name, + error=error, + ) + ) + + def _get_audit_action(self, violating_ids, truncated=False): + """Return the action listing ``violating_ids``, or a plain all-clear. + + Without the second branch the empty result opens the target model's own + list, whose nocontent helper invites the user to create a record - the + opposite of what "no record violates this rule" should read like. + """ + self.ensure_one() + if not violating_ids: + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "type": "success", + "message": self.env._("No stored record violates '%s'.", self.name), + }, + } + if truncated: + _logger.warning( + "Field length rule %s: the audit stopped at %s violations.", + self.id, + MAX_VIOLATIONS, + ) + name = ( + self.env._( + "First %(count)s records violating '%(rule)s'", + count=MAX_VIOLATIONS, + rule=self.name, + ) + if truncated + else self.env._("Records violating '%s'", self.name) + ) + return { + "type": "ir.actions.act_window", + "name": name, + "res_model": self.model, + "view_mode": "list,form", + "domain": [("id", "in", violating_ids)], + # active_test, because the scan deliberately reaches archived + # records: an archived one still holds a value the next write has to + # get past, and a list that silently dropped them would send the + # user round a clean-up loop with nothing left to clean up. + "context": {"create": False, "active_test": False}, + } diff --git a/base_field_length_constraint/pyproject.toml b/base_field_length_constraint/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/base_field_length_constraint/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/base_field_length_constraint/readme/CONFIGURE.md b/base_field_length_constraint/readme/CONFIGURE.md new file mode 100644 index 0000000000..74207adeb7 --- /dev/null +++ b/base_field_length_constraint/readme/CONFIGURE.md @@ -0,0 +1,39 @@ +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. diff --git a/base_field_length_constraint/readme/CONTRIBUTORS.md b/base_field_length_constraint/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..faae3280c4 --- /dev/null +++ b/base_field_length_constraint/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Quartile](https://www.quartile.co): + - Aung Ko Ko Lin diff --git a/base_field_length_constraint/readme/DESCRIPTION.md b/base_field_length_constraint/readme/DESCRIPTION.md new file mode 100644 index 0000000000..5ba586ee48 --- /dev/null +++ b/base_field_length_constraint/readme/DESCRIPTION.md @@ -0,0 +1,7 @@ +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. diff --git a/base_field_length_constraint/readme/DEVELOP.md b/base_field_length_constraint/readme/DEVELOP.md new file mode 100644 index 0000000000..f7577d51d5 --- /dev/null +++ b/base_field_length_constraint/readme/DEVELOP.md @@ -0,0 +1,23 @@ +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: + +```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. diff --git a/base_field_length_constraint/readme/ROADMAP.md b/base_field_length_constraint/readme/ROADMAP.md new file mode 100644 index 0000000000..55d584a775 --- /dev/null +++ b/base_field_length_constraint/readme/ROADMAP.md @@ -0,0 +1,9 @@ +- 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. diff --git a/base_field_length_constraint/readme/USAGE.md b/base_field_length_constraint/readme/USAGE.md new file mode 100644 index 0000000000..a31864b6d1 --- /dev/null +++ b/base_field_length_constraint/readme/USAGE.md @@ -0,0 +1,12 @@ +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. diff --git a/base_field_length_constraint/security/base_field_length_rule_security.xml b/base_field_length_constraint/security/base_field_length_rule_security.xml new file mode 100644 index 0000000000..fb29e9eece --- /dev/null +++ b/base_field_length_constraint/security/base_field_length_rule_security.xml @@ -0,0 +1,27 @@ + + + + + + Field Length Rule: multi-company (read) + + + + + ['|', ('company_id', '=', False), ('company_id', 'parent_of', company_ids)] + + + Field Length Rule: multi-company (write) + + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + diff --git a/base_field_length_constraint/security/ir.model.access.csv b/base_field_length_constraint/security/ir.model.access.csv new file mode 100644 index 0000000000..a5d7d75dce --- /dev/null +++ b/base_field_length_constraint/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_base_field_length_rule,base.field.length.rule,model_base_field_length_rule,base.group_system,1,1,1,1 diff --git a/base_field_length_constraint/static/description/index.html b/base_field_length_constraint/static/description/index.html new file mode 100644 index 0000000000..aa955010fb --- /dev/null +++ b/base_field_length_constraint/static/description/index.html @@ -0,0 +1,536 @@ + + + + + +Base Field Length Constraint + + + +
+

Base Field Length Constraint

+ + +

Beta License: AGPL-3 OCA/server-ux Translate me on Weblate Try me on Runboat

+

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

+ +
+

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:

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

+ +
+
+

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.

+

Current maintainer:

+

AungKoKoLin1997

+

This module is part of the OCA/server-ux project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/base_field_length_constraint/tests/__init__.py b/base_field_length_constraint/tests/__init__.py new file mode 100644 index 0000000000..66e5d614df --- /dev/null +++ b/base_field_length_constraint/tests/__init__.py @@ -0,0 +1 @@ +from . import test_base_field_length_rule diff --git a/base_field_length_constraint/tests/test_base_field_length_rule.py b/base_field_length_constraint/tests/test_base_field_length_rule.py new file mode 100644 index 0000000000..31c61ef30e --- /dev/null +++ b/base_field_length_constraint/tests/test_base_field_length_rule.py @@ -0,0 +1,830 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from psycopg2 import IntegrityError + +from odoo import Command +from odoo.exceptions import AccessError, ValidationError +from odoo.tests.common import TransactionCase, new_test_user +from odoo.tools import mute_logger + +from odoo.addons.base_field_length_constraint.models import ( + base_field_length_rule as rule_module, +) +from odoo.addons.web.models import models as web_models + +LOGGER = "odoo.addons.base_field_length_constraint.models.base_field_length_rule" + + +class TestBaseFieldLengthRule(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.rule_model = cls.env["base.field.length.rule"] + fields_model = cls.env["ir.model.fields"] + cls.partner_model = cls.env["ir.model"]._get("res.partner") + cls.ref_field = fields_model._get("res.partner", "ref") + cls.comment_field = fields_model._get("res.partner", "comment") + cls.country_model = cls.env["ir.model"]._get("res.country") + cls.country_format_field = fields_model._get("res.country", "address_format") + cls.company = cls.env["res.company"].create( + {"name": "Field Length Rule Company"} + ) + cls.other_company = cls.env["res.company"].create( + {"name": "Field Length Rule Other Company"} + ) + cls.partner = cls.env["res.partner"].create({"name": "Test Partner"}) + cls.user = new_test_user( + cls.env, + login="bflc_user", + groups="base.group_user,base.group_partner_manager", + ) + + def _create_rule(self, **values): + return self.rule_model.create( + { + "name": "Test Rule", + "model_id": self.partner_model.id, + "field_id": self.ref_field.id, + "max_length": 5, + **values, + } + ) + + def _violating_partner(self, **values): + """A partner breaching a 5-character rule on ``ref``, created before it. + + This is the data that predates a rule. + """ + return self.env["res.partner"].create( + {"name": "Long Ref Partner", "ref": "123456", **values} + ) + + def _onchange_ref(self, value, field_names=("ref",), user=None): + model = self.env["res.partner"] + if user: + model = model.with_user(user) + return model.onchange( + {"name": "Test Partner", "ref": value}, list(field_names), {"ref": {}} + ) + + def _cool_down(self, model_name="res.partner"): + """Warm the ormcache and drop the ORM cache, as a real request finds them. + + Creating a rule leaves it prefetched, which masks a missing sudo on the + read path. + """ + self.rule_model._get_rules(model_name) + self.env.invalidate_all() + + # Measurement + + def test_char_measure(self): + self._create_rule() + self.partner.ref = "12345" # the boundary passes + self.partner.ref = "東京都港区" # 5 characters, 15 UTF-8 bytes + with self.assertRaises(ValidationError): + self.partner.ref = "123456" + + def test_byte_measure(self): + rule = self._create_rule(measure="byte", encoding="cp932") + self.partner.ref = "12345" # five half-width characters, five bytes + with self.assertRaises(ValidationError): + self.partner.ref = "あいう" # three characters, six cp932 bytes + # An unmappable character is replaced, not raised on. + rule.encoding = "ascii" + self.partner.ref = "あいうえお" # five characters, five replacement bytes + with self.assertRaises(ValidationError): + self.partner.ref = "あいうえおか" + with self.assertRaises(ValidationError): + self.rule_model._measure_length("x", "byte", "not-an-encoding") + + def test_html_measure(self): + self._create_rule(field_id=self.comment_field.id) + self.partner.comment = "

Hi

" # 26 of markup, 2 of text + # What the editor stores for an empty field renders to nothing. + spec = self.rule_model._get_rules("res.partner")[0] + self.assertFalse(self.rule_model._get_measurable_value("


", spec)) + with self.assertRaises(ValidationError): + self.partner.comment = "

Hello World

" + + # Write paths + + def test_error_on_create(self): + self._create_rule() + with self.assertRaises(ValidationError): + self.env["res.partner"].create({"name": "New Partner", "ref": "123456"}) + + def test_stored_computed_field(self): + field = self.env["ir.model.fields"]._get( + "res.partner", "commercial_company_name" + ) + self._create_rule(field_id=field.id) + with self.assertRaises(ValidationError): + self.env["res.partner"].create( + {"name": "A Very Long Company Name", "is_company": True} + ) + + def test_unwritten_field_is_not_rechecked(self): + partner = self._violating_partner(is_company=True) + self._create_rule() + self._create_rule( + name="Conditional", condition_domain="[('is_company', '=', True)]" + ) + partner.name = "Renamed Partner" + self.assertEqual(partner.ref, "123456") + + def test_excluded_names_are_skipped(self): + partner = self._violating_partner() + self._create_rule() + partner._validate_fields({"ref"}, {"ref"}) + with self.assertRaises(ValidationError): + partner._validate_fields({"ref"}) + + # Messages + + def test_error_message(self): + self._create_rule(name="WMS interface IF-01") + with self.assertRaises(ValidationError) as error: + self.partner.ref = "123456" + self.assertIn("WMS interface IF-01", str(error.exception)) + self.assertIn("Test Partner", str(error.exception)) + self.rule_model.search([]).message = "The reference is too long for the WMS." + with self.assertRaises(ValidationError) as error: + self.partner.ref = "1234567" + self.assertIn("too long for the WMS", str(error.exception)) + + def test_every_violated_rule_is_reported(self): + self._create_rule(name="Loose rule", max_length=10) + self._create_rule(name="Tight rule", max_length=5) + with self.assertRaises(ValidationError) as error: + self.partner.ref = "1234567" + self.assertIn("Tight rule", str(error.exception)) + self.assertNotIn("Loose rule", str(error.exception)) + with self.assertRaises(ValidationError) as error: + self.partner.ref = "12345678901" + self.assertIn("Loose rule", str(error.exception)) + + def test_error_and_warning_on_the_same_write(self): + self._create_rule(name="Blocking rule") + self._create_rule(name="Warning rule", enforcement="warning", max_length=3) + with self.assertNoLogs(LOGGER, level="WARNING"): + with self.assertRaises(ValidationError) as error: + self.partner.ref = "123456" + self.assertIn("Blocking rule", str(error.exception)) + self.assertNotIn("Warning rule", str(error.exception)) + + # Scoping + + def test_condition_domain(self): + self._create_rule(condition_domain="[('is_company', '=', True)]") + # Not a company, so out of scope. + self.partner.ref = "123456" + self.env["res.partner"].create({"name": "Person Partner", "ref": "123456"}) + company_partner = self.env["res.partner"].create( + {"name": "Company Partner", "is_company": True} + ) + with self.assertRaises(ValidationError): + company_partner.ref = "123456" + with self.assertRaises(ValidationError): + self.env["res.partner"].create( + {"name": "Company Partner", "is_company": True, "ref": "123456"} + ) + + def test_condition_field_brings_the_record_into_scope(self): + self._create_rule(condition_domain="[('is_company', '=', True)]") + for label, values in ( + ("condition field", {"is_company": True}), + ("field inversing into it", {"company_type": "company"}), + ): + with self.subTest(label): + partner = self.env["res.partner"].create( + {"name": "Person Partner", "ref": "123456"} + ) + with self.assertRaises(ValidationError): + partner.write(values) + + def test_scope_is_read_after_the_inverses(self): + # The ORM validates the stored fields before the inverse of + # company_type has run, when the record still holds the default the + # contact menu gave it. + self._create_rule(condition_domain="[('is_company', '=', True)]") + partner = ( + self.env["res.partner"] + .with_context(default_is_company=True) + .create( + {"name": "Person Partner", "ref": "123456", "company_type": "person"} + ) + ) + self.assertFalse(partner.is_company) + company_partner = self.env["res.partner"].create( + {"name": "Company Partner", "is_company": True} + ) + company_partner.write({"company_type": "person", "ref": "123456"}) + self.assertEqual(company_partner.ref, "123456") + # The marker create() sets must not outlive it. + with self.assertRaises(ValidationError): + partner.is_company = True + + def test_company_scope(self): + self._create_rule(company_id=self.company.id) + self.partner.company_id = self.other_company + self.partner.ref = "123456" # the rule belongs to another company + self.partner.ref = False + self.partner.company_id = self.company + with self.assertRaises(ValidationError): + self.partner.ref = "123456" + moved = self._violating_partner(company_id=self.other_company.id) + with self.assertRaises(ValidationError): + moved.company_id = self.company + + def test_company_scope_covers_branches(self): + branch = self.env["res.company"].create( + {"name": "Field Length Rule Branch", "parent_id": self.company.id} + ) + self._create_rule(company_id=self.company.id) + self.partner.company_id = branch + with self.assertRaises(ValidationError): + self.partner.ref = "123456" + # Not the other way round. + self.rule_model.search([]).unlink() + self._create_rule(company_id=branch.id) + self.partner.company_id = self.company + self.partner.ref = "123456" + self.assertEqual(self.partner.ref, "123456") + + def test_company_scope_falls_back_to_active_company(self): + # A model without company_id, then one leaving it empty. + self._create_rule( + model_id=self.country_model.id, + field_id=self.country_format_field.id, + company_id=self.company.id, + ) + country = self.env["res.country"].create({"name": "Testland", "code": "ZZ"}) + country.with_company(self.other_company).address_format = "123456" + with self.assertRaises(ValidationError): + country.with_company(self.company).address_format = "1234567" + self._create_rule(company_id=self.company.id) + self.assertFalse(self.partner.company_id) + self.partner.with_company(self.other_company).ref = "123456" + self.partner.ref = False + with self.assertRaises(ValidationError): + self.partner.with_company(self.company).ref = "123456" + + def test_record_rules_on_the_rules_themselves(self): + branch = self.env["res.company"].create( + {"name": "Field Length Rule Branch", "parent_id": self.company.id} + ) + rule_a = self._create_rule(name="A", company_id=self.company.id) + rule_b = self._create_rule(name="B", company_id=self.other_company.id) + rule_all = self._create_rule(name="All") + for label, company in (("company admin", self.company), ("branch", branch)): + with self.subTest(label): + admin = new_test_user( + self.env, + login=f"bflc_admin_{company.id}", + groups="base.group_user,base.group_system", + company_id=company.id, + company_ids=[Command.set(company.ids)], + ) + visible = self.rule_model.with_user(admin).search( + [("id", "in", (rule_a + rule_b + rule_all).ids)] + ) + self.assertEqual(visible, rule_a + rule_all) + with self.assertRaises(AccessError): + rule_a.with_user(admin).max_length = 99 + + # Warning enforcement + + def test_warning_enforcement(self): + self._create_rule(enforcement="warning") + users = self.env.registry["res.users"] + with ( + self.assertLogs(LOGGER, level="WARNING") as logs, + patch.object(users, "_bus_send") as bus_send, + ): + self.partner.ref = "123456" + self.assertEqual(self.partner.ref, "123456") + self.assertIn("Field length rule violated", logs.output[0]) + notification_type, payload = bus_send.call_args[0] + self.assertEqual(notification_type, "simple_notification") + self.assertEqual(payload["type"], "warning") + self.assertIn("Test Rule", payload["message"]) + + def test_onchange_warning(self): + rule = self._create_rule(enforcement="warning") + warning = self._onchange_ref("123456")["warning"] + self.assertEqual(warning["type"], "dialog") + self.assertIn("Test Rule", warning["message"]) + # Within the limit, asking for defaults, and reporting another field. + self.assertNotIn("warning", self._onchange_ref("12345")) + self.assertNotIn("warning", self._onchange_ref("123456", field_names=())) + self.assertNotIn( + "warning", + self.env["res.partner"].onchange( + {"name": "Test Partner", "ref": "123456"}, + ["name"], + {"name": {}, "ref": {}}, + ), + ) + # An error rule reports itself by refusing the save. + rule.enforcement = "error" + self.assertNotIn("warning", self._onchange_ref("123456")) + + def test_onchange_warning_on_a_derived_value(self): + # The value arrives in the response rather than in the values sent, + # and on a field the client does not report as modified. + field = self.env["ir.model.fields"]._get( + "res.partner", "commercial_company_name" + ) + self._create_rule(field_id=field.id, enforcement="warning") + result = self.env["res.partner"].onchange( + {"name": "A Very Long Company Name", "is_company": True}, + ["is_company"], + {"name": {}, "is_company": {}, "commercial_company_name": {}}, + ) + self.assertIn("commercial_company_name", result["value"]) + self.assertIn("Test Rule", result["warning"]["message"]) + + def test_onchange_warning_merges_with_another_module(self): + # Patched below our own override, so the warning reaches us through + # super() as another module's would. + self._create_rule(enforcement="warning") + original = web_models.Base.onchange + + def onchange(self, values, field_names, fields_spec): + result = original(self, values, field_names, fields_spec) + result["warning"] = { + "title": "Other module", + "message": "Other message", + "type": "notification", + } + return result + + with patch.object(web_models.Base, "onchange", onchange): + warning = self._onchange_ref("123456")["warning"] + self.assertIn("Other message", warning["message"]) + self.assertIn("Test Rule", warning["message"]) + # Its weaker type must not demote our dialog to a toast. + self.assertEqual(warning["type"], "dialog") + + def test_has_onchange_flag(self): + field = self.env["res.partner"]._fields["ref"] + self.assertFalse(self.env["res.partner"]._has_onchange(field, [])) + rule = self._create_rule() + self.assertFalse(self.env["res.partner"]._has_onchange(field, [])) + rule.enforcement = "warning" + self.assertTrue(self.env["res.partner"]._has_onchange(field, [])) + + def test_onchange_model_id_clears_the_dependent_fields(self): + rule = self.rule_model.new( + { + "model_id": self.partner_model.id, + "field_id": self.ref_field.id, + "condition_domain": "[('is_company', '=', True)]", + } + ) + rule.model_id = self.country_model + rule._onchange_model_id() + self.assertFalse(rule.field_id) + self.assertFalse(rule.condition_domain) + + # Rule lifecycle + + def test_cache_invalidation(self): + # Warmed first, or the miss would hide a missing invalidation. + self.assertFalse(self.rule_model._get_rules("res.partner")) + self.env.registry.cache_invalidated.clear() + rule = self._create_rule() + # "templates" carries the view cache, where _has_onchange is baked in. + self.assertEqual(self.env.registry.cache_invalidated, {"default", "templates"}) + self.assertFalse(self.rule_model._get_rules("res.country")) + with self.assertRaises(ValidationError): + self.partner.ref = "123456" + rule.max_length = 10 + self.partner.ref = "123456" + rule.active = False + self.partner.ref = "12345678901" + # Warmed again, so the unlink has something stale to invalidate. + rule.active = True + self.assertTrue(self.rule_model._get_rules("res.partner")) + rule.unlink() + self.assertFalse(self.rule_model._get_rules("res.partner")) + + def test_archived_rule_is_not_enforced_under_active_test_false(self): + # Without the pin, this write would cache the archived rule for every + # later request in the worker. + rule = self._create_rule() + rule.active = False + partner = self.partner.with_context(active_test=False) + partner.ref = "123456" + self.assertEqual(partner.ref, "123456") + self.assertFalse(self.rule_model._get_rules("res.partner")) + + def _drift_the_condition(self, rule, condition_domain): + """Leave the stored condition saying what the model cannot answer. + + The schema moves after the rule is saved, with nothing to check it + again. SQL, because that is the state the database is left holding. + """ + self.env.cr.execute( + "UPDATE base_field_length_rule SET condition_domain = %s WHERE id = %s", + [condition_domain, rule.id], + ) + rule.invalidate_recordset() + self.env.registry.clear_cache() + + def test_unusable_condition_is_skipped(self): + # An unenforceable rule must not take every write on the model with it. + cases = { + "dead field": ("[('x_gone', '=', True)]", "no longer exists"), + "across a relation": ( + "[('country_id.x_gone', '=', True)]", + "no longer exists", + ), + "inside an any": ( + "[('bank_ids', 'any', [('x_gone', '=', True)])]", + "no longer exists", + ), + "no longer a literal": ( + "[('date', '>=', context_today())]", + "cannot be read", + ), + } + for label, (drifted, expected) in cases.items(): + with self.subTest(label): + rule = self._create_rule(condition_domain="[('is_company', '=', True)]") + self._drift_the_condition(rule, drifted) + with self.assertLogs(LOGGER, level="WARNING") as logs: + partner = self.env["res.partner"].create( + {"name": "Still Writable", "ref": "123456"} + ) + self.assertEqual(partner.ref, "123456") + self.assertIn(expected, logs.output[0]) + # The audit is deliberate, so there it is reported. + with self.assertRaisesRegex(ValidationError, "Test Rule"): + rule.action_check_existing_records() + rule.unlink() + + def test_guards_against_a_module_that_is_gone(self): + # ir.model.unlink drops the table but skips the registry reload, so the + # override stays live with nothing behind it. + self._create_rule() + with patch.object(rule_module, "table_exists", return_value=False): + self.env.registry.clear_cache() + self.assertFalse(self.rule_model._get_rules("res.partner")) + self.env.registry.clear_cache() + with patch.dict(self.env.registry.models): + self.env.registry.models.pop(self.rule_model._name) + self.partner.ref = "123456" + self.assertEqual(self.partner.ref, "123456") + + # Check API + + def test_validate_records(self): + partner = self._violating_partner() + self._create_rule() + violations = self.rule_model.validate_records(partner, raise_on_error=False) + self.assertEqual(len(violations), 1) + self.assertEqual(violations[0]["length"], 6) + self.assertEqual(violations[0]["max_length"], 5) + self.assertFalse( + self.rule_model.validate_records( + partner, field_names=["name"], raise_on_error=False + ) + ) + with self.assertRaises(ValidationError): + self.rule_model.validate_records(partner) + + def test_check_value(self): + self._create_rule(measure="byte", encoding="cp932") + self.assertFalse( + self.rule_model.check_value( + "res.partner", "ref", "12345", record=self.partner + ) + ) + with self.assertRaises(ValidationError): + self.rule_model.check_value( + "res.partner", "ref", "あいう", record=self.partner + ) + violations = self.rule_model.check_value( + "res.partner", "ref", "あいう", record=self.partner, raise_on_error=False + ) + self.assertEqual(violations[0]["length"], 6) + # No html extraction, whatever the type of the field. + self._create_rule(field_id=self.comment_field.id) + violations = self.rule_model.check_value( + "res.partner", "comment", "

Hi

", raise_on_error=False + ) + self.assertEqual(violations[0]["length"], 9) + + def test_check_value_resolves_the_scope(self): + conditional = self._create_rule(condition_domain="[('is_company', '=', True)]") + self.assertFalse(self.rule_model.check_value("res.partner", "ref", "123456")) + self.assertFalse( + self.rule_model.check_value( + "res.partner", "ref", "123456", record=self.partner + ) + ) + company_partner = self.env["res.partner"].create( + {"name": "Company Partner", "is_company": True} + ) + with self.assertRaises(ValidationError): + self.rule_model.check_value( + "res.partner", "ref", "123456", record=company_partner + ) + conditional.write({"condition_domain": False, "company_id": self.company.id}) + self.partner.company_id = self.other_company + rule_model = self.rule_model.with_company(self.company) + self.assertFalse( + rule_model.check_value("res.partner", "ref", "123456", record=self.partner) + ) + self.assertFalse( + self.rule_model.with_company(self.other_company).check_value( + "res.partner", "ref", "123456" + ) + ) + with self.assertRaises(ValidationError): + rule_model.check_value("res.partner", "ref", "123456") + + def test_check_apis_inspect_without_side_effects(self): + # The reads are elevated; the records handed back must not be, or + # writing through one would bypass every ACL. + partner = self._violating_partner().with_user(self.user) + self._create_rule(enforcement="warning") + rule_model = self.rule_model.with_user(self.user) + users = self.env.registry["res.users"] + with ( + patch.object(users, "_bus_send") as bus_send, + self.assertNoLogs(LOGGER, level="WARNING"), + ): + results = ( + rule_model.validate_records(partner), + rule_model.check_value("res.partner", "ref", "123456", record=partner), + ) + bus_send.assert_not_called() + for violations in results: + record = violations[0]["record"] + self.assertFalse(record.env.su) + self.assertEqual(record.env.uid, self.user.id) + + # Existing records audit + + def test_action_check_existing_records(self): + plain = self._violating_partner() + company_partner = self._violating_partner( + name="Company Partner", is_company=True + ) + rule = self._create_rule(condition_domain="[('is_company', '=', True)]") + action = rule.action_check_existing_records() + self.assertEqual(action["res_model"], "res.partner") + self.assertIn(company_partner.id, action["domain"][0][2]) + self.assertNotIn(plain.id, action["domain"][0][2]) + + def test_audit_ignores_archiving(self): + partner = self._violating_partner() + archived_partner = self._violating_partner() + archived_partner.active = False + rule = self._create_rule() + rule.active = False + action = rule.action_check_existing_records() + found = action["domain"][0][2] + self.assertIn(partner.id, found) + self.assertIn(archived_partner.id, found) + # Without active_test the client's own search drops them again. + self.assertFalse(action["context"]["active_test"]) + self.assertEqual( + self.env[rule.model] + .with_context(**action["context"]) + .search(action["domain"]), + partner + archived_partner, + ) + + def test_audit_reports_what_it_may_not_read(self): + # The method is RPC-reachable and call_kw enforces no ACL of its own. + rule = self._create_rule() + with self.assertRaises(AccessError): + rule.with_user(self.user).action_check_existing_records() + partner_class = self.env.registry["res.partner"] + with patch.object( + partner_class, "check_access", side_effect=AccessError("denied") + ): + with self.assertRaisesRegex(AccessError, "cannot audit"): + rule.action_check_existing_records() + + def test_audit_does_not_cross_companies(self): + # The target search is not sudo'd. ``shared`` is the positive control, + # without which this would pass on an audit returning nothing. + partner_b = self._violating_partner(name="Company B Partner") + partner_b.company_id = self.other_company + shared = self._violating_partner() + rule = self._create_rule() + admin_a = new_test_user( + self.env, + login="bflc_audit_a", + groups="base.group_user,base.group_system", + company_id=self.company.id, + company_ids=[Command.set(self.company.ids)], + ) + found = rule.with_user(admin_a).action_check_existing_records()["domain"][0][2] + self.assertIn(shared.id, found) + self.assertNotIn(partner_b.id, found) + + def _paged_audit(self, prefix="Paged", batch_size=3, on_first_page=None): + """Audit nine partners in pages, optionally disturbing the data midway. + + The names run backwards on purpose: ``res.partner._order`` sorts on + ``complete_name``, so ascending names would agree with ascending ids + and the paging would come out right even without the explicit ``order``. + """ + partners = self.env["res.partner"].create( + [ + { + "name": f"{prefix} {8 - index}", + "ref": "12345" if index == 0 else "123456", + } + for index in range(9) + ] + ) + rule = self._create_rule(condition_domain=f"[('name', '=like', '{prefix} %')]") + rule_class = self.env.registry[self.rule_model._name] + original = rule_class._get_violations + pages = [] + + def _get_violations(model, records, specs, field_names=None, excluded_names=()): + violations = original(model, records, specs, field_names, excluded_names) + pages.append(records.ids) + if len(pages) == 1 and on_first_page: + on_first_page(partners) + return violations + + with ( + patch.object(rule_class, "_get_violations", _get_violations), + patch.object(rule_module, "CHECK_BATCH_SIZE", batch_size), + ): + action = rule.action_check_existing_records() + return partners, action + + def test_audit_does_not_lose_a_record_when_the_rows_shift(self): + # Paging on an offset skips the rows already seen by position, so + # deleting one pulls everything below it up over the next offset. + partners, action = self._paged_audit( + on_first_page=lambda partners: partners[0].unlink() + ) + found = action["domain"][0][2] + self.assertEqual(len(found), len(set(found)), "a record was scanned twice") + self.assertEqual(set(found), set(partners[1:].ids)) + + @mute_logger(LOGGER) + def test_audit_bounds_its_result(self): + with patch.object(rule_module, "MAX_VIOLATIONS", 3): + _partners, action = self._paged_audit(prefix="Capped") + self.assertEqual(len(action["domain"][0][2]), 3) + self.assertIn("First 3", action["name"]) + # Exactly eight violations: an exhaustive result, not a truncated one. + with patch.object(rule_module, "MAX_VIOLATIONS", 8): + partners, action = self._paged_audit(prefix="Exhaustive") + self.assertEqual(set(action["domain"][0][2]), set(partners[1:].ids)) + self.assertNotIn("First", action["name"]) + + def test_audit_reports_a_clean_result_as_such(self): + # Otherwise this arrives as the target model's "create one" placeholder. + # The violating fixture keeps a populated database out of the answer. + self._violating_partner() + rule = self._create_rule( + condition_domain="[('name', '=like', 'No Such Partner%')]" + ) + action = rule.action_check_existing_records() + self.assertEqual(action["type"], "ir.actions.client") + self.assertEqual(action["params"]["type"], "success") + self.assertIn("Test Rule", action["params"]["message"]) + + # Access rights + + def test_non_admin_gets_the_length_message(self): + self._create_rule(name="WMS interface IF-01") + self._cool_down() + with self.assertRaises(ValidationError) as error: + self.partner.with_user(self.user).ref = "123456" + self.assertIn("WMS interface IF-01", str(error.exception)) + rule_model = self.rule_model.with_user(self.user) + violations = rule_model.check_value( + "res.partner", "ref", "123456", raise_on_error=False + ) + self.assertEqual(len(violations), 1) + with self.assertRaises(ValidationError) as error: + rule_model.check_value("res.partner", "ref", "123456") + self.assertIn("WMS interface IF-01", str(error.exception)) + + def test_non_admin_warning_enforcement(self): + self._create_rule(enforcement="warning") + self._cool_down() + result = self._onchange_ref("123456", user=self.user) + self.assertIn("Test Rule", result["warning"]["message"]) + partner = self.partner.with_user(self.user) + with self.assertLogs(LOGGER, level="WARNING"): + partner.ref = "123456" + self.assertEqual(partner.ref, "123456") + + def test_write_path_reads_through_a_relation_it_may_not_follow(self): + # Unelevated, this would be an AccessError on the country instead. + self.partner.country_id = self.env.ref("base.jp") + self.env["ir.rule"].create( + { + "name": "No country for the test user", + "model_id": self.country_model.id, + "domain_force": "[(0, '=', 1)]", + "groups": [Command.link(self.env.ref("base.group_user").id)], + } + ) + self._create_rule(condition_domain="[('country_id.code', '=', 'JP')]") + self._cool_down() + with self.assertRaises(ValidationError): + self.partner.with_user(self.user).ref = "123456" + + def test_check_apis_read_what_the_caller_cannot(self): + # Record rules are per operation, so a record the caller may write but + # not read back is a state a real database reaches. + self.env["ir.rule"].create( + { + "name": "No read on the test partner", + "model_id": self.partner_model.id, + "domain_force": f"[('id', '!=', {self.partner.id})]", + "groups": [Command.link(self.env.ref("base.group_user").id)], + "perm_read": True, + "perm_write": False, + "perm_create": False, + "perm_unlink": False, + } + ) + partner = self.partner.with_user(self.user) + rule_model = self.rule_model.with_user(self.user) + self._create_rule(condition_domain="[('is_company', '=', True)]") + self._cool_down() + self.assertFalse( + rule_model.check_value("res.partner", "ref", "123456", record=partner) + ) + self.partner.ref = "123456" + self._create_rule() + self._cool_down() + violations = rule_model.validate_records(partner, raise_on_error=False) + self.assertEqual(len(violations), 1) + self.assertIn("Test Partner", self.rule_model._format_violation(violations[0])) + + # Rule configuration + + def test_invalid_rule_is_refused(self): + fields_model = self.env["ir.model.fields"] + contact_address_field = fields_model._get("res.partner", "contact_address") + tz_field = fields_model._get("res.partner", "tz") + apikeys_model = self.env["ir.model"]._get("res.users.apikeys.show") + # Each case needs its field to still have the property it was picked for. + self.assertFalse(contact_address_field.store) + self.assertTrue(tz_field.store) + self.assertNotIn(tz_field.ttype, ("char", "text", "html")) + cases = { + "field of another model": ( + {"field_id": self.country_format_field.id}, + "does not belong to model", + ), + "unstored field": ({"field_id": contact_address_field.id}, "is not stored"), + "unmeasurable field": ({"field_id": tz_field.id}, "Only char, text, html"), + "abstract model": ( + { + "model_id": apikeys_model.id, + "field_id": fields_model._get("res.users.apikeys.show", "key").id, + }, + "is abstract", + ), + "unknown encoding": ( + {"measure": "byte", "encoding": "not-an-encoding"}, + "Unknown encoding", + ), + "undefined codec": ( + {"measure": "byte", "encoding": "undefined"}, + "Unknown encoding", + ), + "unknown field in condition": ( + {"condition_domain": "[('no_such_field', '=', True)]"}, + "Invalid condition", + ), + "malformed condition": ( + {"condition_domain": "['bogus']"}, + "Invalid condition", + ), + } + for label, (values, expected) in cases.items(): + with self.subTest(label): + # Matched on the message, or a case passes on another branch. + with ( + self.assertRaisesRegex(ValidationError, expected), + self.cr.savepoint(), + ): + self._create_rule(**values) + + @mute_logger("odoo.sql_db") + def test_max_length_must_be_positive(self): + with self.assertRaises(IntegrityError), self.cr.savepoint(): + self._create_rule(max_length=0) diff --git a/base_field_length_constraint/views/base_field_length_rule_views.xml b/base_field_length_constraint/views/base_field_length_rule_views.xml new file mode 100644 index 0000000000..9c9539cc09 --- /dev/null +++ b/base_field_length_constraint/views/base_field_length_rule_views.xml @@ -0,0 +1,160 @@ + + + + + base.field.length.rule + + + + + + + + + + + + + + + + + + base.field.length.rule + +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + base.field.length.rule + + + + + + + + + + + + + + + + + + + Field Length Rules + base.field.length.rule + list,form + + +

Define a field length rule

+

+ A rule declares the maximum length a field value may reach, as + required by whatever the value has to fit into. Several rules + may target the same field, and the tightest one wins. +

+
+
+ +