Skip to content

Commit 78a386c

Browse files
committed
[IMP] l10n_br_fiscal: editable data mixin
1 parent 3972b86 commit 78a386c

13 files changed

+743
-14
lines changed

l10n_br_fiscal/models/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
22

3+
from . import data_editable_mixin
34
from . import data_abstract
45
from . import data_product_abstract
56
from . import data_ncm_nbs_abstract

l10n_br_fiscal/models/cfop.py

+4
Original file line numberDiff line numberDiff line change
@@ -198,3 +198,7 @@ def _compute_destination(self):
198198
"CFOP already exists with this code !",
199199
)
200200
]
201+
202+
def _get_xml_id_name(self):
203+
self.ensure_one()
204+
return f"cfop_{self.code}"

l10n_br_fiscal/models/cnae.py

+4
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,7 @@ class Cnae(models.Model):
3737
_("CNAE already exists with this code !"),
3838
)
3939
]
40+
41+
def _get_xml_id_name(self):
42+
self.ensure_one()
43+
return f"cnae_{self.code.replace('.', '').replace('/', '').replace('-', '')}"

l10n_br_fiscal/models/cst.py

+4
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,7 @@ class CST(models.Model):
3737
_("CST already exists with this code !"),
3838
)
3939
]
40+
41+
def _get_xml_id_name(self):
42+
self.ensure_one()
43+
return f"cst_{self.tax_domain}_{self.code}"

l10n_br_fiscal/models/data_abstract.py

+2-13
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@
77
from lxml import etree
88

99
from odoo import _, api, fields, models
10-
from odoo.exceptions import AccessError
1110
from odoo.osv import expression
1211

1312

1413
class DataAbstract(models.AbstractModel):
1514
_name = "l10n_br_fiscal.data.abstract"
1615
_description = "Fiscal Data Abstract"
16+
_inherit = "l10n_br_fiscal.data.editable.mixin"
17+
1718
_order = "code"
1819

1920
code = fields.Char(required=True, index=True)
@@ -24,18 +25,6 @@ class DataAbstract(models.AbstractModel):
2425
string="Unmasked Code", compute="_compute_code_unmasked", store=True, index=True
2526
)
2627

27-
active = fields.Boolean(default=True)
28-
29-
def action_archive(self):
30-
if not self.env.user.has_group("l10n_br_fiscal.group_manager"):
31-
raise AccessError(_("You don't have permission to archive records."))
32-
return super().action_archive()
33-
34-
def action_unarchive(self):
35-
if not self.env.user.has_group("l10n_br_fiscal.group_manager"):
36-
raise AccessError(_("You don't have permission to unarchive records."))
37-
return super().action_unarchive()
38-
3928
@api.depends("code")
4029
def _compute_code_unmasked(self):
4130
for r in self:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
# Copyright 2025-TODAY Akretion - Raphael Valyi <raphael.valyi@akretion.com>
2+
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html).
3+
4+
import logging
5+
6+
from odoo import _, api, fields, models
7+
from odoo.exceptions import AccessError, UserError
8+
9+
_logger = logging.getLogger(__name__)
10+
11+
12+
class EditableDataMixin(models.AbstractModel):
13+
"""
14+
Mixin to automatically manage ir.model.data entries for records.
15+
16+
Features:
17+
- Automatically creates ir.model.data entries for manually created records.
18+
- Skips creation if an entry already exists (e.g., from XML/CSV import).
19+
- Requires inheriting models to define `_xml_id_module` and implement
20+
`_get_xml_id_name()` to specify the naming pattern.
21+
- Provides `get_records_without_xmlid()` to find records lacking an XML ID.
22+
- Provides `fill_missing_xml_ids()` to backfill missing XML IDs for
23+
existing records.
24+
- Prevent writing values inconsistent with record xml_id.
25+
- Allow to toggle update/noupdate mode.
26+
"""
27+
28+
_name = "l10n_br_fiscal.data.editable.mixin"
29+
_description = "Mixin for Automatic ir.model.data Management"
30+
31+
# To be defined in the inheriting model
32+
_xml_id_module = "l10n_br_fiscal"
33+
34+
active = fields.Boolean(default=True)
35+
36+
def _get_xml_id_name(self):
37+
"""
38+
Calculate the specific 'name' for the ir.model.data entry.
39+
This method MUST be implemented by the inheriting model.
40+
41+
:param self: A singleton recordset of the inheriting model.
42+
:return: The string to be used as the 'name' field in ir.model.data.
43+
Return None or False if an XML ID cannot/should not be generated
44+
for this specific record based on its current state.
45+
:rtype: str | None
46+
"""
47+
if self._name in [
48+
"l10n_br_fiscal.document.serie",
49+
"l10n_br_fiscal.document.type",
50+
]:
51+
return None # disable the mixin effect because it's cleary a user managed record
52+
raise NotImplementedError(
53+
_("Method `_get_xml_id_name` must be implemented in model %s.") % self._name
54+
)
55+
56+
@api.model_create_multi
57+
def create(self, vals_list):
58+
if self.env.context.get("install_mode") or self.env.context.get("module"):
59+
self = self.with_context(tracking_disable=True) # faster creation
60+
return super().create(vals_list)
61+
62+
records = super().create(vals_list)
63+
if not records:
64+
return records
65+
66+
# Check which records already have an XML ID
67+
new_ids = records.ids
68+
DataModel = self.env["ir.model.data"]
69+
_logger.info(
70+
"Mixin Create: Searching ir.model.data for model '%s' and res_ids %s",
71+
self._name,
72+
new_ids,
73+
)
74+
existing_data = DataModel.search(
75+
[("model", "=", self._name), ("res_id", "in", new_ids)]
76+
)
77+
ids_with_xmlid = set(existing_data.mapped("res_id"))
78+
79+
# Filter records needing an XML ID
80+
records_to_process = records.filtered(lambda r: r.id not in ids_with_xmlid)
81+
82+
# Process records needing an XML ID
83+
if records_to_process:
84+
DataModelSudo = DataModel.sudo()
85+
for record in records_to_process:
86+
try:
87+
# Call the helper method for the specific record
88+
self._create_missing_xml_id(record, DataModelSudo)
89+
except Exception as e:
90+
# Log error but continue with other records
91+
_logger.error(
92+
"Mixin Create: Error calling _create_missing_xml_id for "
93+
"%s (%s): %s",
94+
record._name,
95+
record.id,
96+
e,
97+
exc_info=True,
98+
)
99+
100+
return records
101+
102+
def write(self, vals):
103+
"""Prevent writing values inconsistent with the record xml_id."""
104+
105+
if self.env.context.get("install_mode") or self.env.context.get("module"):
106+
# self = self.with_context(tracking_disable=True) # faster write; risky?
107+
return super().write(vals)
108+
109+
existing_data = (
110+
self.env["ir.model.data"]
111+
.sudo()
112+
.search([("model", "=", self._name), ("res_id", "in", self.ids)])
113+
)
114+
original_ids = {}
115+
for record in self:
116+
original_ids[record.id] = tuple(
117+
map(
118+
lambda imd: imd.name,
119+
filter(lambda imd: imd.res_id == record.id, existing_data),
120+
)
121+
)
122+
123+
res = super().write(vals)
124+
125+
for record in self:
126+
if (
127+
original_ids[record.id]
128+
and record._get_xml_id_name() != original_ids[record.id][0]
129+
):
130+
raise UserError(
131+
_(
132+
"Writing these values %(vals)s is forbidden in this record "
133+
"because this record is tracked by the xml_id "
134+
"l10n_br_fiscal.%(xml_id_name)s and these values would "
135+
"mean an xml_id like l10n_br_fiscal.%(new_xml_id_name)s "
136+
"instead! So if you need a record with these values you "
137+
"can archive this record and create a new one instead. "
138+
"(The proper xml_id will be created by the l10n_br_fiscal "
139+
"module).",
140+
vals=vals,
141+
xml_id_name=original_ids[record.id][0],
142+
new_xml_id_name=record._get_xml_id_name(),
143+
)
144+
)
145+
return res
146+
147+
def action_archive(self):
148+
if not self.env.user.has_group("l10n_br_fiscal.group_manager"):
149+
raise AccessError(_("You don't have permission to archive records."))
150+
return super().action_archive()
151+
152+
def action_unarchive(self):
153+
if not self.env.user.has_group("l10n_br_fiscal.group_manager"):
154+
raise AccessError(_("You don't have permission to unarchive records."))
155+
return super().action_unarchive()
156+
157+
def button_set_update(self):
158+
if (
159+
not self.env.user.has_group("l10n_br_fiscal.group_manager")
160+
and not self.env.user._is_superuser()
161+
and not self.env.user._is_system()
162+
):
163+
raise AccessError(_("You don't have permission to set records for update!"))
164+
165+
imds = (
166+
self.env["ir.model.data"]
167+
.sudo()
168+
.search(
169+
[
170+
("model", "=", self._name),
171+
("res_id", "in", self.ids),
172+
("noupdate", "=", True),
173+
]
174+
)
175+
)
176+
_logger.warning(
177+
f"Toogle noupdate = True for {self._name} records {imds.mapped('res_id')}"
178+
)
179+
return imds.sudo().write({"noupdate": False})
180+
181+
def button_set_noupdate(self):
182+
if (
183+
not self.env.user.has_group("l10n_br_fiscal.group_manager")
184+
and not self.env.user._is_superuser()
185+
and not self.env.user._is_system()
186+
):
187+
raise AccessError(_("You don't have permission to disable records update!"))
188+
189+
imds = (
190+
self.env["ir.model.data"]
191+
.sudo()
192+
.search(
193+
[
194+
("model", "=", self._name),
195+
("res_id", "in", self.ids),
196+
("noupdate", "=", False),
197+
]
198+
)
199+
)
200+
_logger.warning(
201+
f"Toogle noupdate = False for {self._name} records {imds.mapped('res_id')}"
202+
)
203+
return imds.sudo().write({"noupdate": True})
204+
205+
def _create_missing_xml_id(self, record, DataModel):
206+
"""Internal helper to create the ir.model.data record."""
207+
record.ensure_one()
208+
209+
xml_id_name = record._get_xml_id_name()
210+
if xml_id_name:
211+
# Check if the specific name already exists
212+
if DataModel.search_count(
213+
[("module", "=", self._xml_id_module), ("name", "=", xml_id_name)]
214+
):
215+
return
216+
217+
DataModel.create(
218+
{
219+
"module": self._xml_id_module,
220+
"name": xml_id_name,
221+
"model": record._name,
222+
"res_id": record.id,
223+
"noupdate": True,
224+
}
225+
)
226+
227+
# --- Utility Methods ---
228+
229+
def get_records_without_xmlid(self):
230+
"""
231+
Returns a recordset containing only the records from self
232+
that do not have a corresponding ir.model.data entry.
233+
234+
:param self: The input recordset.
235+
:return: A recordset of the same model.
236+
:rtype: odoo.models.Model
237+
"""
238+
if not self:
239+
return self.browse()
240+
241+
record_ids = self.ids
242+
if not record_ids:
243+
return self.browse()
244+
245+
data_model = self.env["ir.model.data"]
246+
existing_data = data_model.search(
247+
[("model", "=", self._name), ("res_id", "in", record_ids)]
248+
)
249+
ids_with_xmlid = set(existing_data.mapped("res_id"))
250+
251+
ids_without_xmlid = [rid for rid in record_ids if rid not in ids_with_xmlid]
252+
253+
# Return as recordset for easier chaining/operations
254+
return self.browse(ids_without_xmlid)
255+
256+
def fill_missing_xml_ids(self):
257+
"""
258+
Finds records in the current recordset (`self`) without an XML ID
259+
and attempts to create one based on the model's pattern.
260+
261+
Use this for backfilling records created before this mixin was active.
262+
Example Usage: self.env['your.model'].search([]).fill_missing_xml_ids()
263+
"""
264+
records_to_process = self.get_records_without_xmlid()
265+
DataModelSudo = self.env["ir.model.data"].sudo()
266+
267+
for record in records_to_process:
268+
if not DataModelSudo.search_count(
269+
[("model", "=", record._name), ("res_id", "=", record.id)]
270+
):
271+
self._create_missing_xml_id(record, DataModelSudo)

l10n_br_fiscal/models/nbs.py

+4
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,7 @@ class Nbs(models.Model):
3333

3434
def _get_ibpt(self, config, code_unmasked):
3535
return get_ibpt_service(config, code_unmasked)
36+
37+
def _get_xml_id_name(self):
38+
self.ensure_one()
39+
return f"nbs_{self.code.replace('.', '')}"

l10n_br_fiscal/models/ncm.py

+7
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,10 @@ class Ncm(models.Model):
7676

7777
def _get_ibpt(self, config, code_unmasked):
7878
return get_ibpt_product(config, code_unmasked)
79+
80+
def _get_xml_id_name(self):
81+
self.ensure_one()
82+
return (
83+
f"ncm_{self.code.replace('.', '')}"
84+
f"{self.exception and '_' + self.exception or ''}"
85+
)

l10n_br_fiscal/models/service_type.py

+13-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# Copyright (C) 2014 KMEE - www.kmee.com.br
33
# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html
44

5-
from odoo import fields, models
5+
from odoo import _, fields, models
66

77

88
class ServiceType(models.Model):
@@ -43,3 +43,15 @@ class ServiceType(models.Model):
4343
)
4444

4545
withholding_possible = fields.Boolean(string="Subject to withholding tax")
46+
47+
_sql_constraints = [
48+
(
49+
"fiscal_service_type_code_uniq",
50+
"unique (code)",
51+
_("service_type already exists with this code!"),
52+
)
53+
]
54+
55+
def _get_xml_id_name(self):
56+
self.ensure_one()
57+
return f"service_type_{self.code.replace('.', '')}"

0 commit comments

Comments
 (0)