Skip to content

[16.0][ADD] l10n_br_fiscal: editable data mixin #3777

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: 16.0
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions l10n_br_fiscal/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).

from . import data_editable_mixin
from . import data_abstract
from . import data_product_abstract
from . import data_ncm_nbs_abstract
Expand Down
4 changes: 4 additions & 0 deletions l10n_br_fiscal/models/cfop.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,7 @@ def _compute_destination(self):
"CFOP already exists with this code !",
)
]

def _get_xml_id_name(self):
self.ensure_one()
return f"cfop_{self.code}"
4 changes: 4 additions & 0 deletions l10n_br_fiscal/models/cnae.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,7 @@ class Cnae(models.Model):
_("CNAE already exists with this code !"),
)
]

def _get_xml_id_name(self):
self.ensure_one()
return f"cnae_{self.code.replace('.', '').replace('/', '').replace('-', '')}"
4 changes: 4 additions & 0 deletions l10n_br_fiscal/models/cst.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,7 @@ class CST(models.Model):
_("CST already exists with this code !"),
)
]

def _get_xml_id_name(self):
self.ensure_one()
return f"cst_{self.tax_domain}_{self.code}"
17 changes: 3 additions & 14 deletions l10n_br_fiscal/models/data_abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@
from erpbrasil.base import misc
from lxml import etree

from odoo import _, api, fields, models
from odoo.exceptions import AccessError
from odoo import api, fields, models
from odoo.osv import expression


class DataAbstract(models.AbstractModel):
_name = "l10n_br_fiscal.data.abstract"
_description = "Fiscal Data Abstract"
_inherit = "l10n_br_fiscal.data.editable.mixin"

_order = "code"

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

active = fields.Boolean(default=True)

def action_archive(self):
if not self.env.user.has_group("l10n_br_fiscal.group_manager"):
raise AccessError(_("You don't have permission to archive records."))
return super().action_archive()

def action_unarchive(self):
if not self.env.user.has_group("l10n_br_fiscal.group_manager"):
raise AccessError(_("You don't have permission to unarchive records."))
return super().action_unarchive()

@api.depends("code")
def _compute_code_unmasked(self):
for r in self:
Expand Down
274 changes: 274 additions & 0 deletions l10n_br_fiscal/models/data_editable_mixin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
# Copyright 2025-TODAY Akretion - Raphael Valyi <raphael.valyi@akretion.com>
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html).

import logging

from odoo import _, api, fields, models
from odoo.exceptions import AccessError, UserError

_logger = logging.getLogger(__name__)


class EditableDataMixin(models.AbstractModel):
"""
Mixin to automatically manage ir.model.data entries for records.

Features:
- Automatically creates ir.model.data entries for manually created records.
- Skips creation if an entry already exists (e.g., from XML/CSV import).
- Requires inheriting models to define `_xml_id_module` and implement
`_get_xml_id_name()` to specify the naming pattern.
- Provides `get_records_without_xmlid()` to find records lacking an XML ID.
- Provides `fill_missing_xml_ids()` to backfill missing XML IDs for
existing records.
- Prevent writing values inconsistent with record xml_id.
- Allow to toggle update/noupdate mode.
"""

_name = "l10n_br_fiscal.data.editable.mixin"
_description = "Mixin for Automatic ir.model.data Management"

# To be defined in the inheriting model
_xml_id_module = "l10n_br_fiscal"

active = fields.Boolean(default=True)

def _get_xml_id_name(self):
"""
Calculate the specific 'name' for the ir.model.data entry.
This method MUST be implemented by the inheriting model.

:param self: A singleton recordset of the inheriting model.
:return: The string to be used as the 'name' field in ir.model.data.
Return None or False if an XML ID cannot/should not be generated
for this specific record based on its current state.
:rtype: str | None
"""
if self._name in [
"l10n_br_fiscal.document.serie",
"l10n_br_fiscal.document.type",
]:
return (
None
) # disable the mixin effect because it's cleary a user managed record
raise NotImplementedError(

Check warning on line 54 in l10n_br_fiscal/models/data_editable_mixin.py

View check run for this annotation

Codecov / codecov/patch

l10n_br_fiscal/models/data_editable_mixin.py#L54

Added line #L54 was not covered by tests
_("Method `_get_xml_id_name` must be implemented in model %s.") % self._name
)

@api.model_create_multi
def create(self, vals_list):
if self.env.context.get("install_mode") or self.env.context.get("module"):
self = self.with_context(tracking_disable=True) # faster creation
return super().create(vals_list)

records = super().create(vals_list)
if not records:
return records

Check warning on line 66 in l10n_br_fiscal/models/data_editable_mixin.py

View check run for this annotation

Codecov / codecov/patch

l10n_br_fiscal/models/data_editable_mixin.py#L66

Added line #L66 was not covered by tests

# Check which records already have an XML ID
new_ids = records.ids
DataModel = self.env["ir.model.data"]
_logger.info(
"Mixin Create: Searching ir.model.data for model '%s' and res_ids %s",
self._name,
new_ids,
)
existing_data = DataModel.search(
[("model", "=", self._name), ("res_id", "in", new_ids)]
)
ids_with_xmlid = set(existing_data.mapped("res_id"))

# Filter records needing an XML ID
records_to_process = records.filtered(lambda r: r.id not in ids_with_xmlid)

# Process records needing an XML ID
if records_to_process:
DataModelSudo = DataModel.sudo()
for record in records_to_process:
try:
# Call the helper method for the specific record
self._create_missing_xml_id(record, DataModelSudo)
except Exception as e:

Check warning on line 91 in l10n_br_fiscal/models/data_editable_mixin.py

View check run for this annotation

Codecov / codecov/patch

l10n_br_fiscal/models/data_editable_mixin.py#L91

Added line #L91 was not covered by tests
# Log error but continue with other records
_logger.error(

Check warning on line 93 in l10n_br_fiscal/models/data_editable_mixin.py

View check run for this annotation

Codecov / codecov/patch

l10n_br_fiscal/models/data_editable_mixin.py#L93

Added line #L93 was not covered by tests
"Mixin Create: Error calling _create_missing_xml_id for "
"%s (%s): %s",
record._name,
record.id,
e,
exc_info=True,
)

return records

def write(self, vals):
"""Prevent writing values inconsistent with the record xml_id."""

if self.env.context.get("install_mode") or self.env.context.get("module"):
# self = self.with_context(tracking_disable=True) # faster write; risky?
return super().write(vals)

existing_data = (
self.env["ir.model.data"]
.sudo()
.search([("model", "=", self._name), ("res_id", "in", self.ids)])
)
original_ids = {}
for record in self:
original_ids[record.id] = tuple(
map(
lambda imd: imd.name,
filter(lambda imd: imd.res_id == record.id, existing_data),
)
)

res = super().write(vals)

for record in self:
if (
original_ids[record.id]
and record._get_xml_id_name() is not None
and record._get_xml_id_name() != original_ids[record.id][0]
):
raise UserError(
_(
"Writing these values %(vals)s is forbidden in this record "
"because this record is tracked by the xml_id "
"l10n_br_fiscal.%(xml_id_name)s and these values would "
"mean an xml_id like l10n_br_fiscal.%(new_xml_id_name)s "
"instead! So if you need a record with these values you "
"can archive this record and create a new one instead. "
"(The proper xml_id will be created by the l10n_br_fiscal "
"module).",
vals=vals,
xml_id_name=original_ids[record.id][0],
new_xml_id_name=record._get_xml_id_name(),
)
)
return res

def action_archive(self):
if not self.env.user.has_group("l10n_br_fiscal.group_manager"):
raise AccessError(_("You don't have permission to archive records."))
return super().action_archive()

def action_unarchive(self):
if not self.env.user.has_group("l10n_br_fiscal.group_manager"):
raise AccessError(_("You don't have permission to unarchive records."))
return super().action_unarchive()

def button_set_update(self):
if (
not self.env.user.has_group("l10n_br_fiscal.group_manager")
and not self.env.user._is_superuser()
and not self.env.user._is_system()
):
raise AccessError(_("You don't have permission to set records for update!"))

imds = (
self.env["ir.model.data"]
.sudo()
.search(
[
("model", "=", self._name),
("res_id", "in", self.ids),
("noupdate", "=", True),
]
)
)
_logger.warning(
f"Toogle noupdate = True for {self._name} records {imds.mapped('res_id')}"
)
return imds.sudo().write({"noupdate": False})

def button_set_noupdate(self):
if (
not self.env.user.has_group("l10n_br_fiscal.group_manager")
and not self.env.user._is_superuser()
and not self.env.user._is_system()
):
raise AccessError(_("You don't have permission to disable records update!"))

imds = (
self.env["ir.model.data"]
.sudo()
.search(
[
("model", "=", self._name),
("res_id", "in", self.ids),
("noupdate", "=", False),
]
)
)
_logger.warning(
f"Toogle noupdate = False for {self._name} records {imds.mapped('res_id')}"
)
return imds.sudo().write({"noupdate": True})

def _create_missing_xml_id(self, record, DataModel):
"""Internal helper to create the ir.model.data record."""
record.ensure_one()

xml_id_name = record._get_xml_id_name()
if xml_id_name:
# Check if the specific name already exists
if DataModel.search_count(
[("module", "=", self._xml_id_module), ("name", "=", xml_id_name)]
):
return

Check warning on line 218 in l10n_br_fiscal/models/data_editable_mixin.py

View check run for this annotation

Codecov / codecov/patch

l10n_br_fiscal/models/data_editable_mixin.py#L218

Added line #L218 was not covered by tests

DataModel.create(
{
"module": self._xml_id_module,
"name": xml_id_name,
"model": record._name,
"res_id": record.id,
"noupdate": True,
}
)

# --- Utility Methods ---

def get_records_without_xmlid(self):
"""
Returns a recordset containing only the records from self
that do not have a corresponding ir.model.data entry.

:param self: The input recordset.
:return: A recordset of the same model.
:rtype: odoo.models.Model
"""
if not self:
return self.browse()

Check warning on line 242 in l10n_br_fiscal/models/data_editable_mixin.py

View check run for this annotation

Codecov / codecov/patch

l10n_br_fiscal/models/data_editable_mixin.py#L242

Added line #L242 was not covered by tests

record_ids = self.ids
if not record_ids:
return self.browse()

Check warning on line 246 in l10n_br_fiscal/models/data_editable_mixin.py

View check run for this annotation

Codecov / codecov/patch

l10n_br_fiscal/models/data_editable_mixin.py#L246

Added line #L246 was not covered by tests

data_model = self.env["ir.model.data"]
existing_data = data_model.search(
[("model", "=", self._name), ("res_id", "in", record_ids)]
)
ids_with_xmlid = set(existing_data.mapped("res_id"))

ids_without_xmlid = [rid for rid in record_ids if rid not in ids_with_xmlid]

# Return as recordset for easier chaining/operations
return self.browse(ids_without_xmlid)

def fill_missing_xml_ids(self):
"""
Finds records in the current recordset (`self`) without an XML ID
and attempts to create one based on the model's pattern.

Use this for backfilling records created before this mixin was active.
Example Usage: self.env['your.model'].search([]).fill_missing_xml_ids()
"""
records_to_process = self.get_records_without_xmlid()
DataModelSudo = self.env["ir.model.data"].sudo()

for record in records_to_process:
if not DataModelSudo.search_count(
[("model", "=", record._name), ("res_id", "=", record.id)]
):
self._create_missing_xml_id(record, DataModelSudo)
4 changes: 4 additions & 0 deletions l10n_br_fiscal/models/nbs.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@ class Nbs(models.Model):

def _get_ibpt(self, config, code_unmasked):
return get_ibpt_service(config, code_unmasked)

def _get_xml_id_name(self):
self.ensure_one()
return f"nbs_{self.code.replace('.', '')}"
7 changes: 7 additions & 0 deletions l10n_br_fiscal/models/ncm.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,10 @@ class Ncm(models.Model):

def _get_ibpt(self, config, code_unmasked):
return get_ibpt_product(config, code_unmasked)

def _get_xml_id_name(self):
self.ensure_one()
return (
f"ncm_{self.code.replace('.', '')}"
f"{self.exception and '_' + self.exception or ''}"
)
14 changes: 13 additions & 1 deletion l10n_br_fiscal/models/service_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Copyright (C) 2014 KMEE - www.kmee.com.br
# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html

from odoo import fields, models
from odoo import _, fields, models


class ServiceType(models.Model):
Expand Down Expand Up @@ -43,3 +43,15 @@ class ServiceType(models.Model):
)

withholding_possible = fields.Boolean(string="Subject to withholding tax")

_sql_constraints = [
(
"fiscal_service_type_code_uniq",
"unique (code)",
_("service_type already exists with this code!"),
)
]

def _get_xml_id_name(self):
self.ensure_one()
return f"service_type_{self.code.replace('.', '')}"
Loading