Skip to content

Commit 18f3f32

Browse files
committed
[ADD] base_import_extended: New module
TT52224
1 parent d02602e commit 18f3f32

17 files changed

+960
-0
lines changed

base_import_extended/README.rst

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
====================
2+
Base import extended
3+
====================
4+
5+
..
6+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
7+
!! This file is generated by oca-gen-addon-readme !!
8+
!! changes will be overwritten. !!
9+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
10+
!! source digest: sha256:bc2594e42c1110b31090b0eb7d8f55e2899f79c43fa10a04451f2b65be0f25a2
11+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
12+
13+
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
14+
:target: https://odoo-community.org/page/development-status
15+
:alt: Beta
16+
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
17+
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
18+
:alt: License: AGPL-3
19+
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--ux-lightgray.png?logo=github
20+
:target: https://github.com/OCA/server-ux/tree/17.0/base_import_extended
21+
:alt: OCA/server-ux
22+
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
23+
:target: https://translation.odoo-community.org/projects/server-ux-17-0/server-ux-17-0-base_import_extended
24+
:alt: Translate me on Weblate
25+
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
26+
:target: https://runboat.odoo-community.org/builds?repo=OCA/server-ux&target_branch=17.0
27+
:alt: Try me on Runboat
28+
29+
|badge1| |badge2| |badge3| |badge4| |badge5|
30+
31+
This module allows to create templates for import data files
32+
33+
**Table of contents**
34+
35+
.. contents::
36+
:local:
37+
38+
Usage
39+
=====
40+
41+
42+
43+
Bug Tracker
44+
===========
45+
46+
Bugs are tracked on `GitHub Issues <https://github.com/OCA/server-ux/issues>`_.
47+
In case of trouble, please check there if your issue has already been reported.
48+
If you spotted it first, help us to smash it by providing a detailed and welcomed
49+
`feedback <https://github.com/OCA/server-ux/issues/new?body=module:%20base_import_extended%0Aversion:%2017.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
50+
51+
Do not contact contributors directly about support or help with technical issues.
52+
53+
Credits
54+
=======
55+
56+
Authors
57+
-------
58+
59+
* Tecnativa
60+
61+
Contributors
62+
------------
63+
64+
- `Tecnativa <https://www.tecnativa.com>`__:
65+
66+
- Carlos Dauden
67+
68+
Maintainers
69+
-----------
70+
71+
This module is maintained by the OCA.
72+
73+
.. image:: https://odoo-community.org/logo.png
74+
:alt: Odoo Community Association
75+
:target: https://odoo-community.org
76+
77+
OCA, or the Odoo Community Association, is a nonprofit organization whose
78+
mission is to support the collaborative development of Odoo features and
79+
promote its widespread use.
80+
81+
This module is part of the `OCA/server-ux <https://github.com/OCA/server-ux/tree/17.0/base_import_extended>`_ project on GitHub.
82+
83+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

base_import_extended/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import models

base_import_extended/__manifest__.py

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Copyright 2025 Tecnativa - Carlos Dauden
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
3+
4+
{
5+
"name": "Base import extended",
6+
"summary": "Base import extended to manage templates and extra conversion methods",
7+
"version": "17.0.1.0.0",
8+
"category": "Tools",
9+
"website": "https://github.com/OCA/server-ux",
10+
"author": "Tecnativa, Odoo Community Association (OCA)",
11+
"license": "AGPL-3",
12+
"installable": True,
13+
"depends": [
14+
"base_import",
15+
],
16+
"data": [
17+
"security/ir.model.access.csv",
18+
"views/base_import_mapping_template_views.xml",
19+
"views/base_import_mapping_value_map_views.xml",
20+
# "views/invoice_import_xls_view.xml",
21+
],
22+
}
+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from . import base_import_mapping
2+
from . import base_import_mapping_template
3+
from . import base_import_mapping_value_map
4+
from . import ir_fields
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Copyright 2025 Tecnativa - Carlos Dauden
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
3+
4+
from odoo import api, fields, models
5+
from odoo.osv import expression
6+
7+
8+
class ImportMapping(models.Model):
9+
_inherit = "base_import.mapping"
10+
_rec_name = "column_name"
11+
12+
mapping_template_id = fields.Many2one(
13+
comodel_name="base_import.mapping.template",
14+
ondelete="cascade",
15+
)
16+
pre_process_method = fields.Selection(
17+
[
18+
("str_abs_value", "Absolute value (string)"),
19+
("prefix_c", "Prefix C"),
20+
("prefix_p", "Prefix P"),
21+
]
22+
)
23+
mapped_value_ids = fields.Many2many(
24+
comodel_name="base_import.mapping.value.map",
25+
relation="base_import_mapping_value_map_rel",
26+
column1="mapping_id",
27+
column2="value_map_id",
28+
)
29+
python_code = fields.Char()
30+
# Convert to compute to manage from template if is set
31+
res_model = fields.Char(compute="_compute_res_model", store=True, readonly=False)
32+
33+
@api.depends("mapping_template_id")
34+
def _compute_res_model(self):
35+
for line in self.filtered("mapping_template_id"):
36+
line.res_model = line.mapping_template_id.res_model
37+
38+
@api.model
39+
def search(self, domain, offset=0, limit=None, order=None):
40+
domain = expression.AND(
41+
[
42+
domain,
43+
[
44+
(
45+
"mapping_template_id",
46+
"=",
47+
self.env.context.get("use_mapping_template_id", False),
48+
)
49+
],
50+
]
51+
)
52+
return super().search(domain, offset=offset, limit=limit, order=order)
53+
54+
@api.model_create_multi
55+
def create(self, vals_list):
56+
mapping_template_id = self.env.context.get("use_mapping_template_id", False)
57+
if mapping_template_id:
58+
for vals in vals_list:
59+
if "mapping_template_id" not in vals:
60+
vals["mapping_template_id"] = mapping_template_id
61+
return super().create(vals_list)
62+
63+
def pre_process_method_str_abs_value(self, value):
64+
return value.replace("-", "")
65+
66+
def pre_process_method_prefix_c(self, value):
67+
return f"C-{value}"
68+
69+
def pre_process_method_prefix_p(self, value):
70+
return f"P-{value}"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
# Copyright 2025 Tecnativa - Carlos Dauden
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
3+
4+
import base64
5+
import math
6+
7+
from odoo import _, api, fields, models
8+
from odoo.exceptions import UserError
9+
from odoo.tools.safe_eval import safe_eval
10+
11+
12+
class ImportMappingTemplate(models.Model):
13+
_inherit = ["base_import.import"]
14+
_name = "base_import.mapping.template"
15+
_description = "Template to group import mapping data"
16+
_transient = False
17+
18+
name = fields.Char()
19+
action_id = fields.Many2one(comodel_name="ir.actions.act_window")
20+
mapping_ids = fields.One2many(
21+
comodel_name="base_import.mapping",
22+
inverse_name="mapping_template_id",
23+
)
24+
forced_context = fields.Char(string="Context Value", default={}, required=True)
25+
forced_options = fields.Char(default={}, required=True)
26+
27+
def execute_import_template(self):
28+
options = {
29+
"import_skip_records": [],
30+
"import_set_empty_fields": [],
31+
"fallback_values": {},
32+
"name_create_enabled_fields": {},
33+
"encoding": "",
34+
"separator": "",
35+
"quoting": '"',
36+
"date_format": "%Y-%m-%d",
37+
"datetime_format": "",
38+
"float_thousand_separator": ",",
39+
"float_decimal_separator": ".",
40+
"advanced": True,
41+
"has_headers": True,
42+
"keep_matches": False,
43+
"limit": 2000,
44+
"sheets": [],
45+
"sheet": "",
46+
"skip": 0,
47+
"tracking_disable": True,
48+
}
49+
eval_ctx = dict(self.env.context)
50+
ctx = {}
51+
if self.action_id:
52+
ctx.update(**safe_eval(self.action_id.context, eval_ctx))
53+
if self.forced_context != "{}":
54+
ctx.update(**safe_eval(self.forced_context, eval_ctx))
55+
self = self.with_context(**ctx)
56+
self.file = base64.b64decode(self.file)
57+
preview = self.parse_preview(options=options)
58+
if self.forced_options != "{}":
59+
options.update(**safe_eval(self.forced_options, eval_ctx))
60+
columns = [col.lower() for col in preview.get("headers", [])]
61+
fields = []
62+
for column in columns:
63+
line = self.mapping_ids.filtered(lambda x, col=column: x.column_name == col)
64+
fields.append(line.field_name)
65+
limit = options["limit"]
66+
steps_number = math.ceil((preview.get("file_length", 1) - 1) / limit)
67+
all_ids = []
68+
for step in range(steps_number):
69+
options["skip"] = step * limit
70+
options["limit"] = limit
71+
res = self.with_context(
72+
use_mapping_template_id=self.id, use_cached_db_id_for=True
73+
).execute_import(fields, columns, options, dryrun=False)
74+
messages = res.get("messages", [])
75+
if messages:
76+
text_message = "\n".join(m.get("message", "") for m in messages)
77+
if step > 0:
78+
text_message = (
79+
f"Already imported {step * limit} records, but \n{text_message}"
80+
)
81+
raise UserError(text_message)
82+
res_ids = res.get("ids", [])
83+
if not res_ids:
84+
raise UserError(_("No records were imported"))
85+
all_ids.extend(res_ids)
86+
# self.env.registry.clear_cache()
87+
self.file = False
88+
return self.action_view_imported_records(all_ids, ctx)
89+
90+
@api.model
91+
def _convert_import_data(self, fields, options):
92+
data, import_fields = super()._convert_import_data(fields, options)
93+
if not self.env.context.get("use_mapping_template_id"):
94+
return data, import_fields
95+
multilevel = "id" in import_fields and any(
96+
"_ids/" in f for f in import_fields if f
97+
)
98+
if multilevel:
99+
id_index = import_fields.index("id")
100+
index_line_dict, index_column_dict = self.get_index_dictionaries(import_fields)
101+
# for index, field_name in enumerate(import_fields):
102+
# line = self.mapping_ids.filtered(
103+
# lambda x, f_name=field_name: x.field_name == f_name
104+
# )
105+
for index, line in index_line_dict.items():
106+
field_name = line.field_name
107+
if line.pre_process_method:
108+
process_fnc = getattr(
109+
line, f"pre_process_method_{line.pre_process_method}"
110+
)
111+
for row in data:
112+
row[index] = process_fnc(row[index])
113+
elif line.python_code:
114+
self.update_data_with_python_code(
115+
data, index, line.python_code, index_column_dict
116+
)
117+
elif field_name == "id":
118+
prefix = self.res_model.replace(".", "_")
119+
for row in data:
120+
row[index] = f"{prefix}_{row[index]}_{self.id}"
121+
if line.mapped_value_ids:
122+
mapped_dict = {
123+
map_line.value: map_line.new_value_ref
124+
and str(map_line.new_value_ref.id)
125+
or map_line.new_value
126+
for map_line in line.mapped_value_ids
127+
}
128+
for row in data:
129+
row[index] = mapped_dict.get(row[index], row[index])
130+
# Empty repeat values for principal record fields
131+
if multilevel and "_ids/" not in field_name:
132+
last_value = ""
133+
for row in data:
134+
if row[id_index] in ("", last_value):
135+
row[index] = ""
136+
else:
137+
last_value = row[id_index]
138+
return data, import_fields
139+
140+
def get_index_dictionaries(self, import_fields):
141+
index_line_dict = {}
142+
index_column_dict = {}
143+
for index, field_name in enumerate(import_fields):
144+
line = self.mapping_ids.filtered(
145+
lambda x, f_name=field_name: x.field_name == f_name
146+
)
147+
index_line_dict[index] = line
148+
index_column_dict[index] = line.column_name
149+
return index_line_dict, index_column_dict
150+
151+
def update_data_with_python_code(self, data, index, python_code, index_column_dict):
152+
for row in data:
153+
col_vals = {}
154+
if "col_vals" in python_code:
155+
for idx, col in index_column_dict.items():
156+
col_vals[col] = row[idx]
157+
row[index] = safe_eval(
158+
python_code,
159+
{"value": row[index], "col_vals": col_vals},
160+
)
161+
162+
def action_view_imported_records(self, res_ids, context=None):
163+
if self.action_id:
164+
action = self.env["ir.actions.actions"]._for_xml_id(self.action_id.xml_id)
165+
else:
166+
action = {
167+
"type": "ir.actions.act_window",
168+
"res_model": self.res_model,
169+
"name": _("Imported Records"),
170+
"views": [[False, "tree"], [False, "kanban"], [False, "form"]],
171+
}
172+
action["domain"] = [("id", "in", res_ids)]
173+
if context:
174+
action["context"] = context
175+
return action
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Copyright 2025 Tecnativa - Carlos Dauden
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
3+
4+
from odoo import fields, models
5+
6+
7+
class ImportMappingValueMap(models.Model):
8+
_name = "base_import.mapping.value.map"
9+
_description = "Map import value with odoo value"
10+
_rec_name = "value"
11+
12+
mapping_ids = fields.Many2many(
13+
comodel_name="base_import.mapping",
14+
relation="base_import_mapping_value_map_rel",
15+
column1="value_map_id",
16+
column2="mapping_id",
17+
)
18+
value = fields.Char()
19+
new_value = fields.Char()
20+
new_value_ref = fields.Reference(
21+
lambda self: [
22+
(m.model, m.name) for m in self.env["ir.model"].sudo().search([])
23+
],
24+
string="Object",
25+
)

0 commit comments

Comments
 (0)