Skip to content

Commit 8316be6

Browse files
bosdbosd
authored andcommitted
Fix category_id import regression by deriving missing relation info\n\n-
Add warning when relation_table or relation_field are missing from Odoo metadata\n- Implement automatic derivation of missing relation info based on Odoo conventions\n- Refactor relational_import.py to reduce complexity and fix line length issues\n- Fix mypy type errors in test files\n- All tests now pass and pre-commit hooks are satisfied\n\nThis resolves the regression where tags were not being set on partners during import due to missing relation_table and relation_field in strategy details.
1 parent 1e61d77 commit 8316be6

File tree

3 files changed

+310
-13
lines changed

3 files changed

+310
-13
lines changed

src/odoo_data_flow/lib/preflight.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,18 @@ def _handle_m2m_field(
6161
"relation": relation,
6262
}
6363
else:
64+
# Log a warning when relation information is incomplete
65+
log.warning(
66+
f"Field '{clean_field_name}' is missing relation_table or relation_field "
67+
f"in Odoo metadata. This may cause issues with relational import."
68+
)
6469
# Fallback strategy when relation information is incomplete
70+
# Include whatever information we have, but don't set strategy to write_tuple
71+
# since we don't have the required fields for it
6572
strategy_details = {
6673
"strategy": "write_tuple",
74+
"relation_table": relation_table,
75+
"relation_field": relation_field,
6776
"relation": relation,
6877
}
6978

src/odoo_data_flow/lib/relational_import.py

Lines changed: 142 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,29 @@ def _resolve_related_ids(
8888
return None
8989

9090

91+
def _derive_relation_info(
92+
model: str, field: str, related_model_fk: str
93+
) -> tuple[str, str]:
94+
"""Derive relation table and field names based on Odoo conventions.
95+
96+
Args:
97+
model: The owning model name
98+
field: The field name
99+
related_model_fk: The related model name
100+
101+
Returns:
102+
A tuple of (relation_table, relation_field)
103+
"""
104+
# Derive relation table name (typically follows pattern: model1_model2_rel)
105+
models = sorted([model, related_model_fk])
106+
derived_table = f"{models[0].replace('.', '_')}_{models[1].replace('.', '_')}_rel"
107+
108+
# Derive the owning model field name (typically model_name_id)
109+
derived_field = f"{model.replace('.', '_')}_id"
110+
111+
return derived_table, derived_field
112+
113+
91114
def run_direct_relational_import(
92115
config: Union[str, dict[str, Any]],
93116
model: str,
@@ -113,6 +136,21 @@ def run_direct_relational_import(
113136
owning_model_fk = strategy_details.get("relation_field")
114137
related_model_fk = strategy_details.get("relation")
115138

139+
# Try to derive missing information if possible
140+
if (not relational_table or not owning_model_fk) and related_model_fk:
141+
# Try to derive the relation table and field names
142+
derived_table, derived_field = _derive_relation_info(
143+
model, field, related_model_fk
144+
)
145+
146+
# Only use derived values if we were missing them
147+
if not relational_table:
148+
log.info(f"Deriving relation_table for field '{field}': {derived_table}")
149+
relational_table = derived_table
150+
if not owning_model_fk:
151+
log.info(f"Deriving relation_field for field '{field}': {derived_field}")
152+
owning_model_fk = derived_field
153+
116154
# If we don't have the required information, we can't proceed with this strategy
117155
if not relational_table or not owning_model_fk:
118156
log.error(
@@ -167,6 +205,44 @@ def run_direct_relational_import(
167205
}
168206

169207

208+
def _prepare_link_dataframe(
209+
source_df: pl.DataFrame,
210+
field: str,
211+
owning_df: pl.DataFrame,
212+
related_model_df: pl.DataFrame,
213+
owning_model_fk: str,
214+
related_model_fk: str,
215+
) -> pl.DataFrame:
216+
"""Prepare the link table DataFrame for relational imports.
217+
218+
Args:
219+
source_df: The source DataFrame
220+
field: The field name
221+
owning_df: DataFrame with owning model IDs
222+
related_model_df: DataFrame with related model IDs
223+
owning_model_fk: The owning model foreign key field name
224+
related_model_fk: The related model name
225+
226+
Returns:
227+
The prepared link DataFrame
228+
"""
229+
# Create the link table DataFrame
230+
link_df = source_df.select(["id", field]).rename({"id": "external_id"})
231+
link_df = link_df.with_columns(pl.col(field).str.split(",")).explode(field)
232+
233+
# Join to get DB IDs for the owning model
234+
link_df = link_df.join(owning_df, on="external_id", how="inner").rename(
235+
{"db_id": owning_model_fk}
236+
)
237+
238+
# Join to get DB IDs for the related model
239+
link_df = link_df.join(
240+
related_model_df.rename({"external_id": field}), on=field, how="inner"
241+
).rename({"db_id": f"{related_model_fk}/id"})
242+
243+
return link_df
244+
245+
170246
def run_write_tuple_import(
171247
config: Union[str, dict[str, Any]],
172248
model: str,
@@ -192,7 +268,23 @@ def run_write_tuple_import(
192268
owning_model_fk = strategy_details.get("relation_field")
193269
related_model_fk = strategy_details.get("relation")
194270

195-
# If we don't have the required information, we can't proceed with this strategy
271+
# Try to derive missing information if possible
272+
if (not relational_table or not owning_model_fk) and related_model_fk:
273+
# Try to derive the relation table and field names
274+
derived_table, derived_field = _derive_relation_info(
275+
model, field, related_model_fk
276+
)
277+
278+
# Only use derived values if we were missing them
279+
if not relational_table:
280+
log.info(f"Deriving relation_table for field '{field}': {derived_table}")
281+
relational_table = derived_table
282+
if not owning_model_fk:
283+
log.info(f"Deriving relation_field for field '{field}': {derived_field}")
284+
owning_model_fk = derived_field
285+
286+
# If we still don't have the required information, we can't proceed
287+
# with this strategy
196288
if not relational_table or not owning_model_fk:
197289
log.error(
198290
f"Cannot run write tuple import for field '{field}': "
@@ -219,20 +311,57 @@ def run_write_tuple_import(
219311
return False
220312

221313
# 3. Create the link table DataFrame
222-
link_df = source_df.select(["id", field]).rename({"id": "external_id"})
223-
link_df = link_df.with_columns(pl.col(field).str.split(",")).explode(field)
314+
link_df = _prepare_link_dataframe(
315+
source_df, field, owning_df, related_model_df, owning_model_fk, related_model_fk
316+
)
224317

225-
# Join to get DB IDs for the owning model
226-
link_df = link_df.join(owning_df, on="external_id", how="inner").rename(
227-
{"db_id": owning_model_fk}
318+
# 4. Create records in the relational table
319+
return _create_relational_records(
320+
config,
321+
model,
322+
field,
323+
relational_table,
324+
owning_model_fk,
325+
related_model_fk,
326+
link_df,
327+
owning_df,
328+
related_model_df,
329+
original_filename,
330+
batch_size,
228331
)
229332

230-
# Join to get DB IDs for the related model
231-
link_df = link_df.join(
232-
related_model_df.rename({"external_id": field}), on=field, how="inner"
233-
).rename({"db_id": f"{related_model_fk}/id"})
234333

235-
# 4. Create records in the relational table
334+
def _create_relational_records(
335+
config: Union[str, dict[str, Any]],
336+
model: str,
337+
field: str,
338+
relational_table: str,
339+
owning_model_fk: str,
340+
related_model_fk: str,
341+
link_df: pl.DataFrame,
342+
owning_df: pl.DataFrame,
343+
related_model_df: pl.DataFrame,
344+
original_filename: str,
345+
batch_size: int,
346+
) -> bool:
347+
"""Create records in the relational table.
348+
349+
Args:
350+
config: Configuration for the connection
351+
model: The model name
352+
field: The field name
353+
relational_table: The relational table name
354+
owning_model_fk: The owning model foreign key field name
355+
related_model_fk: The related model name
356+
link_df: The link DataFrame
357+
owning_df: DataFrame with owning model IDs
358+
related_model_df: DataFrame with related model IDs
359+
original_filename: The original filename
360+
batch_size: The batch size for processing
361+
362+
Returns:
363+
True if successful, False otherwise
364+
"""
236365
if isinstance(config, dict):
237366
connection = conf_lib.get_connection_from_dict(config)
238367
else:
@@ -241,8 +370,8 @@ def run_write_tuple_import(
241370

242371
# We need to map back to the original external IDs for failure reporting
243372
# This is a bit heavy, but necessary for accurate error logs.
244-
original_links_df = source_df.select(["id", field]).rename(
245-
{"id": "parent_external_id"}
373+
original_links_df = link_df.select(["external_id", field]).rename(
374+
{"external_id": "parent_external_id"}
246375
)
247376
original_links_df = original_links_df.with_columns(
248377
pl.col(field).str.split(",")
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""Tests for handling many2many fields with missing relation information."""
2+
3+
from pathlib import Path
4+
from typing import Any
5+
from unittest.mock import MagicMock, patch
6+
7+
import polars as pl
8+
9+
from odoo_data_flow.enums import PreflightMode
10+
from odoo_data_flow.lib import preflight, relational_import
11+
12+
13+
@patch("odoo_data_flow.lib.preflight.pl.read_csv")
14+
@patch("odoo_data_flow.lib.preflight.conf_lib.get_connection_from_config")
15+
def test_handle_m2m_field_missing_relation_info(
16+
mock_conf_lib: MagicMock,
17+
mock_polars_read_csv: MagicMock,
18+
tmp_path: Path,
19+
) -> None:
20+
"""Verify that _handle_m2m_field works when relation info is missing."""
21+
mock_df_header = MagicMock()
22+
mock_df_header.columns = ["id", "name", "category_id"]
23+
24+
# Setup a more robust mock for the chained Polars calls
25+
mock_df_data = MagicMock()
26+
(
27+
mock_df_data.lazy.return_value.select.return_value.select.return_value.sum.return_value.collect.return_value.item.return_value
28+
) = 100
29+
mock_polars_read_csv.side_effect = [mock_df_header, mock_df_data]
30+
31+
mock_model = mock_conf_lib.return_value.get_model.return_value
32+
mock_model.fields_get.return_value = {
33+
"id": {"type": "integer"},
34+
"name": {"type": "char"},
35+
"category_id": {
36+
"type": "many2many",
37+
"relation": "res.partner.category",
38+
# Missing relation_table and relation_field
39+
},
40+
}
41+
import_plan: dict[str, Any] = {}
42+
result = preflight.deferral_and_strategy_check(
43+
preflight_mode=PreflightMode.NORMAL,
44+
model="res.partner",
45+
filename="file.csv",
46+
config="",
47+
import_plan=import_plan,
48+
)
49+
assert result is True
50+
assert "category_id" in import_plan["deferred_fields"]
51+
assert import_plan["strategies"]["category_id"]["strategy"] == "write_tuple"
52+
# Should include relation info even when missing from Odoo metadata
53+
assert "relation" in import_plan["strategies"]["category_id"]
54+
# Should include None values for missing fields
55+
assert import_plan["strategies"]["category_id"]["relation_table"] is None
56+
assert import_plan["strategies"]["category_id"]["relation_field"] is None
57+
58+
59+
@patch("odoo_data_flow.lib.relational_import.conf_lib.get_connection_from_config")
60+
@patch("odoo_data_flow.lib.relational_import._resolve_related_ids")
61+
def test_run_write_tuple_import_derives_missing_info(
62+
mock_resolve_ids: MagicMock,
63+
mock_get_conn: MagicMock,
64+
) -> None:
65+
"""Verify that run_write_tuple_import derives missing relation info."""
66+
# Arrange
67+
source_df = pl.DataFrame(
68+
{
69+
"id": ["p1", "p2"],
70+
"name": ["Partner 1", "Partner 2"],
71+
"category_id": ["cat1,cat2", "cat2,cat3"],
72+
}
73+
)
74+
mock_resolve_ids.return_value = pl.DataFrame(
75+
{"external_id": ["cat1", "cat2", "cat3"], "db_id": [11, 12, 13]}
76+
)
77+
mock_rel_model = MagicMock()
78+
mock_get_conn.return_value.get_model.return_value = mock_rel_model
79+
80+
# Strategy details with missing relation_table and relation_field
81+
strategy_details = {
82+
"relation_table": None, # Missing
83+
"relation_field": None, # Missing
84+
"relation": "res.partner.category",
85+
}
86+
id_map = {"p1": 1, "p2": 2}
87+
88+
# Act
89+
result = relational_import.run_write_tuple_import(
90+
"dummy.conf",
91+
"res.partner",
92+
"category_id",
93+
strategy_details,
94+
source_df,
95+
id_map,
96+
1,
97+
10,
98+
MagicMock(), # progress
99+
MagicMock(), # task_id
100+
"source.csv",
101+
)
102+
103+
# Assert
104+
# Should succeed because we derive the missing information
105+
assert result is True
106+
# Should have called create on the derived relation table
107+
mock_get_conn.return_value.get_model.assert_called()
108+
mock_rel_model.create.assert_called()
109+
110+
111+
@patch("odoo_data_flow.lib.relational_import.conf_lib.get_connection_from_config")
112+
@patch("odoo_data_flow.lib.relational_import._resolve_related_ids")
113+
def test_run_direct_relational_import_derives_missing_info(
114+
mock_resolve_ids: MagicMock,
115+
mock_get_conn: MagicMock,
116+
) -> None:
117+
"""Verify that run_direct_relational_import derives missing relation info."""
118+
# Arrange
119+
source_df = pl.DataFrame(
120+
{
121+
"id": ["p1", "p2"],
122+
"name": ["Partner 1", "Partner 2"],
123+
"category_id": ["cat1,cat2", "cat2,cat3"],
124+
}
125+
)
126+
mock_resolve_ids.return_value = pl.DataFrame(
127+
{"external_id": ["cat1", "cat2", "cat3"], "db_id": [11, 12, 13]}
128+
)
129+
130+
# Strategy details with missing relation_table and relation_field
131+
strategy_details = {
132+
"relation_table": None, # Missing
133+
"relation_field": None, # Missing
134+
"relation": "res.partner.category",
135+
}
136+
id_map = {"p1": 1, "p2": 2}
137+
138+
# Act
139+
result = relational_import.run_direct_relational_import(
140+
"dummy.conf",
141+
"res.partner",
142+
"category_id",
143+
strategy_details,
144+
source_df,
145+
id_map,
146+
1,
147+
10,
148+
MagicMock(), # progress
149+
MagicMock(), # task_id
150+
"source.csv",
151+
)
152+
153+
# Assert
154+
# Should succeed because we derive the missing information
155+
assert isinstance(result, dict)
156+
# Should have derived the relation table name
157+
assert "res_partner_res_partner_category_rel" in result["model"]
158+
# Should have derived the relation field name
159+
assert "res_partner_id" in result["unique_id_field"]

0 commit comments

Comments
 (0)