Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions sequence_regex/README.rst
Original file line number Diff line number Diff line change
@@ -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
====================
1 change: 1 addition & 0 deletions sequence_regex/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
13 changes: 13 additions & 0 deletions sequence_regex/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copyright 2026 manaTec GmbH (<https://manatec.de>)
# 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": [],
}
1 change: 1 addition & 0 deletions sequence_regex/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import ir_sequence
62 changes: 62 additions & 0 deletions sequence_regex/models/ir_sequence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Copyright 2026 manaTec GmbH (<https://manatec.de>)
# 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 "")
)
3 changes: 3 additions & 0 deletions sequence_regex/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[build-system]
requires = ["whool"]
build-backend = "whool.buildapi"
1 change: 1 addition & 0 deletions sequence_regex/readme/CONTRIBUTORS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Gerald Malsch \<gerald.malsch@manatec.de\>
13 changes: 13 additions & 0 deletions sequence_regex/readme/DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions sequence_regex/readme/USAGE.md
Original file line number Diff line number Diff line change
@@ -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()
```
Binary file added sequence_regex/static/description/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions sequence_regex/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Copyright 2020 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from . import test_ir_sequence
43 changes: 43 additions & 0 deletions sequence_regex/tests/test_ir_sequence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Copyright 2026 manaTec GmbH (<https://manatec.de>)
# 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)
Loading