Skip to content

Commit 1cc795c

Browse files
committed
[ADD] util.fields: handle ir.exports model/fields renames/removal
Implement proper fixing and/or removal of ir.exports and ir.exports.line records on model/fields renames/removal: - renamed fields: update affected ir.exports.line records - removed fields: remove affected ir.exports.line records - renamed models: update affected ir.exports records `resource` - removed models: remove affected ir.exports.line / ir.exports records Some of these cases were already handled but have been improved. Specifically: - for renamed fields, previously was done by doing a simple string replacement, which is not good enough because export lines can reference fields paths, and these in turn *might* contain paths to multiple fields with the same name on different models, only some of which are affected. Now the fields path get properly resolved for renaming, only the affected fields path parts are updated. - for removed fields, previously no action was taken, leaving a broken export template and UI traceback. Now the affected export lines are removed, using fields paths resolution, and this is logged in the migration report to inform the users to double check these records. - for renamed and removed models, this was already handled by the indirect_references mechanism, but now export lines for the removed models are checked with the same mechanism for fields above. Additionally, unit tests have been added to make sure these cases are properly handled by the code.
1 parent d4a608c commit 1cc795c

File tree

6 files changed

+268
-18
lines changed

6 files changed

+268
-18
lines changed

src/base/tests/test_util.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,63 @@ def test_remove_field(self, domain, expected):
359359
self.assertEqual(altered_domain, expected)
360360

361361

362+
class TestIrExports(UnitTestCase):
363+
def setUp(self):
364+
super().setUp()
365+
self.export = self.env["ir.exports"].create(
366+
[
367+
{
368+
"name": "Test currency export",
369+
"resource": "res.currency",
370+
"export_fields": [
371+
(0, 0, {"name": "full_name"}),
372+
(0, 0, {"name": "rate_ids/company_id/user_ids/name"}),
373+
(0, 0, {"name": "rate_ids/company_id/user_ids/partner_id/user_ids/name"}),
374+
(0, 0, {"name": "rate_ids/name"}),
375+
],
376+
}
377+
]
378+
)
379+
util.flush(self.export)
380+
381+
def _invalidate(self):
382+
util.invalidate(self.export.export_fields)
383+
util.invalidate(self.export)
384+
385+
def test_rename_field(self):
386+
util.rename_field(self.cr, "res.partner", "user_ids", "renamed_user_ids")
387+
self._invalidate()
388+
self.assertEqual(
389+
self.export.export_fields[2].name, "rate_ids/company_id/user_ids/partner_id/renamed_user_ids/name"
390+
)
391+
392+
util.rename_field(self.cr, "res.users", "name", "new_name")
393+
self._invalidate()
394+
self.assertEqual(self.export.export_fields[1].name, "rate_ids/company_id/user_ids/new_name")
395+
396+
def test_remove_field(self):
397+
util.remove_field(self.cr, "res.currency.rate", "company_id")
398+
self._invalidate()
399+
self.assertEqual(len(self.export.export_fields), 2)
400+
self.assertEqual(self.export.export_fields[0].name, "full_name")
401+
self.assertEqual(self.export.export_fields[1].name, "rate_ids/name")
402+
403+
def test_rename_model(self):
404+
util.rename_model(self.cr, "res.currency", "res.currency2")
405+
self._invalidate()
406+
self.assertEqual(self.export.resource, "res.currency2")
407+
408+
def test_remove_model(self):
409+
util.remove_model(self.cr, "res.currency.rate")
410+
self._invalidate()
411+
self.assertEqual(len(self.export.export_fields), 1)
412+
self.assertEqual(self.export.export_fields[0].name, "full_name")
413+
414+
util.remove_model(self.cr, "res.currency")
415+
self.cr.execute("SELECT * FROM ir_exports WHERE id = %s", [self.export.id])
416+
self.assertFalse(self.cr.fetchall())
417+
418+
362419
class TestIterBrowse(UnitTestCase):
363420
def test_iter_browse_iter(self):
364421
cr = self.env.cr

src/util/fields.py

Lines changed: 151 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@
1313
import logging
1414
import re
1515
import warnings
16+
from collections import namedtuple
1617

1718
import psycopg2
1819
from psycopg2 import sql
19-
from psycopg2.extras import Json
20+
from psycopg2.extras import Json, execute_values
2021

2122
try:
2223
from odoo import release
@@ -79,6 +80,150 @@ def make_index_name(table_name, column_name):
7980
)
8081

8182

83+
ResolvedExportsLine = namedtuple(
84+
"ResolvedExportsLine",
85+
"export_id export_name export_model line_id path_parts part_index field_name field_model field_id relation_model",
86+
)
87+
88+
89+
def get_resolved_ir_exports(cr, models=None, fields=None, only_missing=False):
90+
"""
91+
Return a list of ir.exports.line records which models or fields match the given arguments.
92+
93+
Export lines can reference nested models through relationship field "paths"
94+
(e.g. "partner_id/country_id/name"), therefore these needs to be resolved properly.
95+
96+
Only one of ``models`` or ``fields`` arguments should be provided.
97+
98+
:param list[str] models: a list of model names to match in exports
99+
:param list[(str, str)] fields: a list of (model, field) tuples to match in exports
100+
:param bool only_missing: include only lines which contain missing models/fields
101+
:return: the matched resolved exports lines
102+
:rtype: list[ResolvedExportsLine]
103+
104+
:meta private: exclude from online docs
105+
"""
106+
assert bool(models) ^ bool(fields), "One of models or fields must be given, and not both."
107+
extra_where = ""
108+
query_params = {}
109+
if models:
110+
extra_where += " AND field_model = ANY(%(models)s)"
111+
query_params["models"] = list(models)
112+
if fields:
113+
extra_where += " AND (field_model, field_name) IN %(fields)s"
114+
query_params["fields"] = tuple((model, field) for model, field in fields)
115+
if only_missing:
116+
extra_where += " AND field_id IS NULL"
117+
# Resolve exports using a recursive CTE query
118+
cr.execute(
119+
"""
120+
WITH RECURSIVE resolved_exports_fields AS (
121+
-- non-recursive term
122+
SELECT e.id AS export_id,
123+
e.name AS export_name,
124+
e.resource AS export_model,
125+
el.id AS line_id,
126+
string_to_array(el.name, '/') AS path_parts,
127+
1 AS part_index,
128+
replace((string_to_array(el.name, '/'))[1], '.id', 'id') AS field_name,
129+
e.resource AS field_model,
130+
elf.id AS field_id,
131+
elf.relation AS relation_model
132+
FROM ir_exports_line el
133+
JOIN ir_exports e
134+
ON el.export_id = e.id
135+
LEFT JOIN ir_model_fields elf
136+
ON elf.model = e.resource AND elf.name = (string_to_array(el.name, '/'))[1]
137+
LEFT JOIN ir_model em
138+
ON em.model = e.resource
139+
140+
UNION ALL
141+
142+
-- recursive term
143+
SELECT ref.export_id,
144+
ref.export_name,
145+
ref.export_model,
146+
ref.line_id,
147+
ref.path_parts,
148+
ref.part_index + 1 AS part_index,
149+
replace(ref.path_parts[ref.part_index + 1], '.id', 'id') AS field_name,
150+
ref.relation_model AS field_model,
151+
refmf.id AS field_id,
152+
refmf.relation AS relation_model
153+
FROM resolved_exports_fields ref
154+
LEFT JOIN ir_model_fields refmf
155+
ON refmf.model = ref.relation_model AND refmf.name = ref.path_parts[ref.part_index + 1]
156+
WHERE cardinality(ref.path_parts) > ref.part_index AND ref.relation_model IS NOT NULL
157+
)
158+
SELECT *
159+
FROM resolved_exports_fields
160+
WHERE field_name != 'id' {extra_where}
161+
ORDER BY export_id, line_id, part_index
162+
""".format(extra_where=extra_where),
163+
query_params,
164+
)
165+
return [ResolvedExportsLine(**row) for row in cr.dictfetchall()]
166+
167+
168+
def rename_ir_exports_fields(cr, models_fields_map):
169+
"""
170+
Rename fields references in ir.exports.line records.
171+
172+
:param dict[str, dict[str, str]] models_fields_map: a dict of models to the fields rename dict,
173+
like: `{"model.name": {"old_field": "new_field", ...}, ...}`
174+
175+
:meta private: exclude from online docs
176+
"""
177+
matching_exports = get_resolved_ir_exports(
178+
cr,
179+
fields=[(model, field) for model, fields_map in models_fields_map.items() for field in fields_map],
180+
)
181+
if not matching_exports:
182+
return
183+
_logger.debug("Renaming %d export template lines with renamed fields", len(matching_exports))
184+
fixed_lines_paths = {}
185+
for row in matching_exports:
186+
assert row.field_model in models_fields_map
187+
fields_map = models_fields_map[row.field_model]
188+
assert row.field_name in fields_map
189+
assert row.path_parts[row.part_index - 1] == row.field_name
190+
new_field_name = fields_map[row.field_name]
191+
fixed_path = list(row.path_parts)
192+
fixed_path[row.part_index - 1] = new_field_name
193+
fixed_lines_paths[row.line_id] = fixed_path
194+
execute_values(
195+
cr,
196+
"""
197+
UPDATE ir_exports_line el
198+
SET name = v.name
199+
FROM (VALUES %s) AS v(id, name)
200+
WHERE el.id = v.id
201+
""",
202+
[(k, "/".join(v)) for k, v in fixed_lines_paths.items()],
203+
)
204+
205+
206+
def remove_ir_exports_lines(cr, models=None, fields=None):
207+
"""
208+
Delete ir.exports.line records that reference models or fields that are/will be removed.
209+
210+
Only one of ``models`` or ``fields`` arguments should be provided.
211+
212+
:param list[str] models: a list of model names to match in exports
213+
:param list[(str, str)] fields: a list of (model, field) tuples to match in exports
214+
215+
:meta private: exclude from online docs
216+
"""
217+
matching_exports = get_resolved_ir_exports(cr, models=models, fields=fields)
218+
if not matching_exports:
219+
return
220+
lines_ids = {row.line_id for row in matching_exports}
221+
_logger.debug("Deleting %d export template lines with removed models/fields", len(lines_ids))
222+
cr.execute("DELETE FROM ir_exports_line WHERE id IN %s", [tuple(lines_ids)])
223+
for row in matching_exports:
224+
add_to_migration_reports(row, category="Export Templates")
225+
226+
82227
def ensure_m2o_func_field_data(cr, src_table, column, dst_table):
83228
"""
84229
Fix broken m2o relations.
@@ -202,6 +347,9 @@ def clean_context(context):
202347
[(fieldname, fieldname + " desc"), model, r"\y{}\y".format(fieldname)],
203348
)
204349

350+
# ir.exports
351+
remove_ir_exports_lines(cr, fields=[(model, fieldname)])
352+
205353
def adapter(leaf, is_or, negated):
206354
# replace by TRUE_LEAF, unless negated or in a OR operation but not negated
207355
if is_or ^ negated:
@@ -1065,22 +1213,9 @@ def _update_field_usage_multi(cr, models, old, new, domain_adapter=None, skip_in
10651213
"""
10661214
cr.execute(q.format(col_prefix=col_prefix), p)
10671215

1068-
# ir.exports.line
1069-
q = """
1070-
UPDATE ir_exports_line l
1071-
SET name = regexp_replace(l.name, %(old)s, %(new)s, 'g')
1072-
"""
1216+
# ir.exports
10731217
if only_models:
1074-
q += """
1075-
FROM ir_exports e
1076-
WHERE e.id = l.export_id
1077-
AND e.resource IN %(models)s
1078-
AND
1079-
"""
1080-
else:
1081-
q += "WHERE "
1082-
q += "l.name ~ %(old)s"
1083-
cr.execute(q, p)
1218+
rename_ir_exports_fields(cr, {model: {old: new} for model in only_models})
10841219

10851220
# mail.alias
10861221
if column_exists(cr, "mail_alias", "alias_defaults"):

src/util/indirect_references.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def indirect_references(cr, bound_only=False):
3939
IR("ir_model_fields", "relation", None), # destination of a relation field
4040
IR("ir_model_data", "model", "res_id"),
4141
IR("ir_filters", "model_id", None, set_unknown=True), # YUCK!, not an id
42-
IR("ir_exports", "resource", None, set_unknown=True),
42+
IR("ir_exports", "resource", None),
4343
IR("ir_ui_view", "model", None, set_unknown=True),
4444
IR("ir_values", "model", "res_id"),
4545
IR("wkf_transition", "trigger_model", None),

src/util/models.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import re
1111

1212
from .const import ENVIRON
13-
from .fields import IMD_FIELD_PATTERN, remove_field
13+
from .fields import IMD_FIELD_PATTERN, remove_field, remove_ir_exports_lines
1414
from .helpers import _ir_values_value, _validate_model, model_of_table, table_of_model
1515
from .indirect_references import indirect_references
1616
from .inherit import for_each_inherit, inherit_parents
@@ -129,6 +129,9 @@ def remove_model(cr, model, drop_table=True, ignore_m2m=()):
129129
cr.execute(query, args + (tuple(ids),))
130130
notify = notify or bool(cr.rowcount)
131131

132+
# for ir.exports.line we have to take care of "nested" references in fields "paths"
133+
remove_ir_exports_lines(cr, models=[model])
134+
132135
_rm_refs(cr, model)
133136

134137
cr.execute(

src/util/report-migration.xml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,35 @@
4343
</ul>
4444
</details></li>
4545
</t>
46+
<t t-elif="category == 'Export Templates'">
47+
<li><details>
48+
<summary>
49+
During the upgrade some fields have been removed.
50+
The lines of Export templates referencing those fields have also been automatically removed.
51+
Please double-check the affected exports listed below to make sure they work as expected.
52+
</summary>
53+
<ul>
54+
<t t-set="grouped_exports" t-value="group_messages(messages[category], attribute='export_id')"/>
55+
<t t-foreach="grouped_exports" t-as="export_id">
56+
<t t-if="grouped_exports[export_id]">
57+
<t t-set="export_messages" t-value="grouped_exports[export_id]"/>
58+
<li>
59+
<strong><t t-raw="get_anchor_link_to_record('ir.exports', export_id, export_messages[0][0].export_name)"/></strong>
60+
(<t t-out="export_messages[0][0].export_model"/>)
61+
<ul>
62+
<t t-foreach="export_messages" t-as="message">
63+
<li>
64+
<t t-raw="'/'.join(message[0].path_parts)"/>:
65+
model <t t-raw="message[0].field_model"/> field <t t-raw="message[0].field_name"/> removed
66+
</li>
67+
</t>
68+
</ul>
69+
</li>
70+
</t>
71+
</t>
72+
</ul>
73+
</details></li>
74+
</t>
4675
<t t-else="">
4776
<t t-foreach="messages[category]" t-as="message">
4877
<li><t t-if="message[1]" t-raw="message[0]" /><t t-else="" t-esc="message[0]" /></li>

src/util/report.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
import os
44
import re
5+
from collections import defaultdict
56
from textwrap import dedent
67

78
import lxml
@@ -95,6 +96,7 @@ def announce_migration_report(cr):
9596
"major_version": release.major_version,
9697
"messages": migration_reports,
9798
"get_anchor_link_to_record": get_anchor_link_to_record,
99+
"group_messages": group_messages,
98100
}
99101
_logger.info(migration_reports)
100102
render = e["ir.qweb"].render if hasattr(e["ir.qweb"], "render") else e["ir.qweb"]._render
@@ -240,3 +242,27 @@ def get_anchor_link_to_record(model, id, name, action_id=None):
240242
if Markup:
241243
anchor_tag = Markup(anchor_tag)
242244
return anchor_tag
245+
246+
247+
def group_messages(messages, item=None, attribute=None):
248+
"""
249+
Group messages by a given item index or name or attribute name.
250+
251+
:param list[(typing.Any, bool)] messages: the list of messages to group.
252+
:param typing.Hashable item: the item index to group by.
253+
Mutually exclusive with `attribute` argument.
254+
:param str attribute: the attribute name to group by.
255+
Mutually exclusive with `item` argument.
256+
:return: the grouped messages as a dictionary of grouped messages lists.
257+
:rtype: dict[typing.Any, list[(typing.Any, bool)]]
258+
259+
:meta private: exclude from online docs
260+
"""
261+
assert (item is not None) ^ bool(attribute), "Either item or attribute must be specified, and not both"
262+
groups = defaultdict(list)
263+
for message, raw in messages:
264+
if attribute:
265+
groups[getattr(message, attribute)].append((message, raw))
266+
else:
267+
groups[message[item]].append((message, raw))
268+
return dict(groups)

0 commit comments

Comments
 (0)