diff --git a/sequence_regex/README.rst b/sequence_regex/README.rst new file mode 100644 index 00000000000..21840f15e9c --- /dev/null +++ b/sequence_regex/README.rst @@ -0,0 +1,7 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +==================== +Sequence Recognition +==================== diff --git a/sequence_regex/__init__.py b/sequence_regex/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/sequence_regex/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/sequence_regex/__manifest__.py b/sequence_regex/__manifest__.py new file mode 100644 index 00000000000..9f812e4b5a4 --- /dev/null +++ b/sequence_regex/__manifest__.py @@ -0,0 +1,13 @@ +# Copyright 2026 manaTec GmbH () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + "name": "Sequence Recognition", + "summary": """Detection of names generates by a sequence""", + "author": "manaTec GmbH ,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/server-tools", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "category": "Generic Modules", + "depends": ["base"], + "data": [], +} diff --git a/sequence_regex/models/__init__.py b/sequence_regex/models/__init__.py new file mode 100644 index 00000000000..5b015772ab2 --- /dev/null +++ b/sequence_regex/models/__init__.py @@ -0,0 +1 @@ +from . import ir_sequence diff --git a/sequence_regex/models/ir_sequence.py b/sequence_regex/models/ir_sequence.py new file mode 100644 index 00000000000..9da6742b903 --- /dev/null +++ b/sequence_regex/models/ir_sequence.py @@ -0,0 +1,62 @@ +# Copyright 2026 manaTec GmbH () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import re + +from odoo import models + + +class IrSequence(models.Model): + _inherit = "ir.sequence" + + def name_fits_sequence(self, name): + """Helper function that determines whether a certain name + could have been generated by the sequence""" + self.ensure_one() + pattern = self._get_sequence_regex_pattern() + return bool(re.fullmatch(pattern, name)) + + def _get_sequence_regex_pattern(self): + """Returns a regular expression pattern matching the sequence definition""" + self.ensure_one() + + # Regex patterns matching the valid formatting strings for + # ir.sequence prefixes and suffixes + format_strings = { + "year": r"(19|20|21)\d{2}", + "y": r"\d{2}", + "month": r"(0[1-9]|1[0-2])", + "day": r"(0[1-9]|[12][0-9]|3[01])", + "doy": r"(0[0-9][1-9]|[12][0-9]{2}|3[0-5][0-9]|36[0-6])", + "woy": r"([0-4][0-9]|5[0-3])", + "weekday": r"([0-6])", + "h12": r"(0[1-9]|1[0-2])", + "h24": r"([01][0-9]|2[0-3])", + "min": r"([0-5][0-9])", + "sec": r"([0-5][0-9])", + } + # Regex pattern that matches all valid formatting strings + # The formatting strings are inside a capturing group + formatter_pattern = r"%\((" + "|".join(format_strings.keys()) + r")\)s" + + def parse_affixes(affix): + # Uses re.split() to separate out the formatting strings + # Since there are capturing groups inside the splitting regex, + # the result alternates between split strings + # and the captured group of the splitting string + # Static parts (even indexes) are escaped into static regex patterns + # Formatting strings (odd indexes) are replaced by their pattern + return r"".join( + re.escape(part) if i % 2 == 0 else format_strings.get(part, "") + for i, part in enumerate(re.split(formatter_pattern, affix)) + ) + + # Total pattern is: + # Prefix-matching pattern + # + number of digits at least equal to padding + # + Suffix-matching pattern + return ( + parse_affixes(self.prefix or "") + + r"\d{%d,}" % self.padding + + parse_affixes(self.suffix or "") + ) diff --git a/sequence_regex/pyproject.toml b/sequence_regex/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/sequence_regex/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/sequence_regex/readme/CONTRIBUTORS.md b/sequence_regex/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..66bd1e4e9a6 --- /dev/null +++ b/sequence_regex/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Gerald Malsch \ diff --git a/sequence_regex/readme/DESCRIPTION.md b/sequence_regex/readme/DESCRIPTION.md new file mode 100644 index 00000000000..215cbc5440f --- /dev/null +++ b/sequence_regex/readme/DESCRIPTION.md @@ -0,0 +1,13 @@ +This module allows to check if a string fits the pattern indicated by +a sequence's definition. + +This serves to be able to detect if a record name was generated by that +sequence, or if it was set to some custom value. + +To perform this check, it generates a regular expression that matches the +expected output of the sequence as closely as possible. This way, it can +handle the dynamic placeholders of the prefix and suffix. + +Use cases for this module could be: + +- You want to replace a record name later if it's a sequence-generated one diff --git a/sequence_regex/readme/USAGE.md b/sequence_regex/readme/USAGE.md new file mode 100644 index 00000000000..73f2f98331c --- /dev/null +++ b/sequence_regex/readme/USAGE.md @@ -0,0 +1,31 @@ +This module does not provide any immediate new interface features. + +The check has to be called from another module via the +function `name_fits_sequence()`. The function returns a Boolean. + +As example, a function that re-numbers sale orders on confirmation +using this check, to avoid overriding user-edited names: + +```python + def action_confirm(self): + for order in self: + if order.state not in ("draft", "sent"): + continue + sequence = self.env["ir.sequence"].search( + [ + ("code", "=", "sale.quotation"), + ("company_id", "in", [order.company_id.id, False]), + ], + order="company_id", + limit=1, + ) + if sequence and not sequence.name_fits_sequence(order.name): + continue + sequence = ( + self.with_company(order.company_id.id) + .env["ir.sequence"] + .next_by_code("sale.order") + ) + order.write({"name": sequence}) + return super().action_confirm() +``` diff --git a/sequence_regex/static/description/icon.png b/sequence_regex/static/description/icon.png new file mode 100644 index 00000000000..3a0328b516c Binary files /dev/null and b/sequence_regex/static/description/icon.png differ diff --git a/sequence_regex/tests/__init__.py b/sequence_regex/tests/__init__.py new file mode 100644 index 00000000000..b32ac6c6ab7 --- /dev/null +++ b/sequence_regex/tests/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2020 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from . import test_ir_sequence diff --git a/sequence_regex/tests/test_ir_sequence.py b/sequence_regex/tests/test_ir_sequence.py new file mode 100644 index 00000000000..6bb2751b201 --- /dev/null +++ b/sequence_regex/tests/test_ir_sequence.py @@ -0,0 +1,43 @@ +# Copyright 2026 manaTec GmbH () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests.common import TransactionCase + + +class TestSequence(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + def test_static_prefix(self): + sequence = self.env["ir.sequence"].create( + {"name": "Static Prefix Sequence", "prefix": "PRE/", "padding": 4} + ) + name = sequence._next_do() + regex = sequence._get_sequence_regex_pattern() + fits = sequence.name_fits_sequence(name) + self.assertEqual(regex, r"PRE/\d{4,}") + self.assertRegex(name, regex) + self.assertTrue(fits) + + def test_dynamic_prefix(self): + sequence = self.env["ir.sequence"].create( + {"name": "Dynamic Prefix Sequence", "prefix": "PRE/%(year)s/", "padding": 4} + ) + name = sequence._next_do() + regex = sequence._get_sequence_regex_pattern() + fits = sequence.name_fits_sequence(name) + self.assertEqual(regex, r"PRE/(19|20|21)\d{2}/\d{4,}") + self.assertRegex(name, regex) + self.assertTrue(fits) + + def test_mismatch(self): + sequence_1 = self.env["ir.sequence"].create( + {"name": "Static Prefix Sequence", "prefix": "PRE/", "padding": 4} + ) + sequence_2 = self.env["ir.sequence"].create( + {"name": "Dynamic Prefix Sequence", "prefix": "PRE/%(year)s/", "padding": 4} + ) + name = sequence_2._next_do() + fits = sequence_1.name_fits_sequence(name) + self.assertFalse(fits)