Skip to content

Commit f0ecfe2

Browse files
bosdbosd
authored andcommitted
[ENH]: Verify fields before import
1 parent 4790178 commit f0ecfe2

File tree

4 files changed

+205
-6
lines changed

4 files changed

+205
-6
lines changed

docs/guides/importing_data.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ odoo-data-flow import --file path/to/res_partner.csv
2020
* `--skip`: The number of initial lines to skip in the source file before reading the header.
2121
* `--sep`: The character separating columns. Defaults to a semicolon (`;`).
2222

23+
### Verifying Fields Before Import (`--verify-fields`)
24+
25+
To prevent common errors, you can add the `--verify-fields` flag to your import command. This is a "pre-flight check" that connects to Odoo and verifies that every column in your CSV header exists as a field on the target model before the import begins.
26+
27+
This is highly recommended as it allows you to "fail fast" with a clear error message, rather than waiting for a large import to fail on a single typo in a column name.
28+
29+
**Example Usage:**
30+
```bash
31+
odoo-data-flow import --file path/to/my_data.csv --model res.partner --verify-fields
32+
```
33+
If `my_data.csv` contains a column that does not exist on the `res.partner` model, the command will abort with an error message listing the invalid fields.
34+
2335
## The "Upsert" Strategy: How External IDs Work
2436

2537
A core feature of `odoo-data-flow` is its ability to safely handle both creating new records and updating existing ones in a single process. This is often called an "upsert" (update or insert) operation, and it is the default behavior of the tool.

src/odoo_data_flow/__main__.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,15 +125,13 @@ def install_languages_cmd(config: str, languages_str: str) -> None:
125125

126126

127127
# --- Workflow Command Group ---
128-
# This defines 'workflow' as a subcommand of 'cli'.
129128
@cli.group(name="workflow")
130129
def workflow_group() -> None:
131130
"""Run legacy or complex post-import processing workflows."""
132131
pass
133132

134133

135134
# --- Invoice v9 Workflow Sub-command ---
136-
# This command is now correctly nested under the 'workflow' group.
137135
@workflow_group.command(name="invoice-v9")
138136
@click.option(
139137
"-c",
@@ -185,7 +183,6 @@ def invoice_v9_cmd(**kwargs: Any) -> None:
185183

186184

187185
# --- Import Command ---
188-
# This command is attached directly to the main 'cli' group.
189186
@cli.command(name="import")
190187
@click.option(
191188
"-c",
@@ -200,6 +197,13 @@ def invoice_v9_cmd(**kwargs: Any) -> None:
200197
default=None,
201198
help="Odoo model to import into. If not provided, it's inferred from the filename.",
202199
)
200+
@click.option(
201+
"--verify-fields",
202+
is_flag=True,
203+
default=False,
204+
help="Connect to Odoo and verify that all CSV columns "
205+
"exist on the model before importing.",
206+
)
203207
@click.option(
204208
"--worker", default=1, type=int, help="Number of simultaneous connections."
205209
)

src/odoo_data_flow/importer.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,73 @@
11
"""This module contains the core logic for importing data into Odoo."""
22

33
import ast
4+
import csv
45
import os
56
from datetime import datetime
67
from typing import Any, Optional
78

89
from . import import_threaded
10+
from .lib import conf_lib
911
from .logging_config import log
1012

1113

14+
def _verify_import_fields(
15+
config: str, model: str, filename: str, separator: str, encoding: str
16+
) -> bool:
17+
"""Verify import fields.
18+
19+
Connects to Odoo and verifies that all columns in the CSV header
20+
exist as fields on the target model.
21+
"""
22+
log.info(f"Performing pre-flight check: Verifying fields for model '{model}'...")
23+
24+
# Step 1: Read the header from the local CSV file
25+
try:
26+
with open(filename, encoding=encoding) as f:
27+
reader = csv.reader(f, delimiter=separator)
28+
csv_header = next(reader)
29+
except FileNotFoundError:
30+
log.error(f"Pre-flight Check Failed: Input file not found at {filename}")
31+
return False
32+
except Exception as e:
33+
log.error(f"Pre-flight Check Failed: Could not read CSV header. Error: {e}")
34+
return False
35+
36+
# Step 2: Get the list of valid fields from the Odoo model
37+
try:
38+
connection: Any = conf_lib.get_connection_from_config(config_file=config)
39+
model_fields_obj = connection.get_model("ir.model.fields")
40+
domain = [("model", "=", model)]
41+
odoo_fields_data = model_fields_obj.search_read(domain, ["name"])
42+
odoo_field_names = {field["name"] for field in odoo_fields_data}
43+
except Exception as e:
44+
log.error(
45+
f"Pre-flight Check Failed: "
46+
f"Could not connect to Odoo to get model fields. Error: {e}"
47+
)
48+
return False
49+
50+
# Step 3: Compare the lists and find any missing fields
51+
missing_fields = [field for field in csv_header if field not in odoo_field_names]
52+
53+
if missing_fields:
54+
log.error(
55+
"Pre-flight Check Failed: The following columns in your CSV file "
56+
"do not exist on the Odoo model:"
57+
)
58+
for field in missing_fields:
59+
log.error(f" - '{field}' is not a valid field on model '{model}'")
60+
return False
61+
62+
log.info("Pre-flight Check Successful: All columns are valid fields on the model.")
63+
return True
64+
65+
1266
def run_import(
1367
config: str,
1468
filename: str,
1569
model: Optional[str] = None,
70+
verify_fields: bool = False,
1671
worker: int = 1,
1772
batch_size: int = 10,
1873
skip: int = 0,
@@ -32,6 +87,8 @@ def run_import(
3287
filename: Path to the source CSV file to import.
3388
model: The Odoo model to import data into. If not provided, it's inferred
3489
from the filename.
90+
verify_fields: If True, connects to Odoo to verify all CSV columns
91+
exist on the model before starting the import.
3592
worker: The number of simultaneous connections to use.
3693
batch_size: The number of records to process in each batch.
3794
skip: The number of initial lines to skip in the source file.
@@ -50,7 +107,6 @@ def run_import(
50107
if not final_model:
51108
base_name = os.path.basename(filename)
52109
inferred_model = os.path.splitext(base_name)[0].replace("_", ".")
53-
# Add a check for invalid inferred names (like hidden files)
54110
if not inferred_model or inferred_model.startswith("."):
55111
log.error(
56112
"Model not specified and could not be inferred from filename "
@@ -60,6 +116,14 @@ def run_import(
60116
final_model = inferred_model
61117
log.info(f"No model provided. Inferred model '{final_model}' from filename.")
62118

119+
# --- Pre-flight Check ---
120+
if verify_fields:
121+
if not _verify_import_fields(
122+
config, final_model, filename, separator, encoding
123+
):
124+
log.error("Aborting import due to failed pre-flight check.")
125+
return
126+
63127
try:
64128
parsed_context = ast.literal_eval(context)
65129
if not isinstance(parsed_context, dict):
@@ -73,7 +137,6 @@ def run_import(
73137
ignore_list = ignore.split(",") if ignore else []
74138

75139
file_dir = os.path.dirname(filename)
76-
77140
file_to_process: str
78141
fail_output_file: str
79142
is_fail_run: bool

tests/test_importer.py

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Test the high-level import orchestrator."""
1+
"""Test the high-level import orchestrator, including pre-flight checks."""
22

33
from pathlib import Path
44
from unittest.mock import MagicMock, patch
@@ -140,3 +140,123 @@ def test_run_import_for_migration(mock_import_data: MagicMock) -> None:
140140
assert call_kwargs["max_connection"] == 2
141141
assert call_kwargs["batch_size"] == 50
142142
assert "tracking_disable" in call_kwargs["context"]
143+
144+
145+
@patch("odoo_data_flow.importer.import_threaded.import_data")
146+
@patch("odoo_data_flow.importer._verify_import_fields")
147+
def test_run_import_skips_verification_by_default(
148+
mock_verify_fields: MagicMock, mock_import_data: MagicMock, tmp_path: Path
149+
) -> None:
150+
"""Tests that the field verification step is NOT run if the flag is omitted."""
151+
source_file = tmp_path / "data.csv"
152+
source_file.write_text("id,name\n1,test")
153+
154+
run_import(
155+
config="dummy.conf",
156+
filename=str(source_file),
157+
model="res.partner",
158+
verify_fields=False, # Explicitly False
159+
)
160+
161+
mock_verify_fields.assert_not_called()
162+
mock_import_data.assert_called_once()
163+
164+
165+
@patch("odoo_data_flow.importer.import_threaded.import_data")
166+
@patch("odoo_data_flow.importer.conf_lib.get_connection_from_config")
167+
def test_verify_fields_success(
168+
mock_get_connection: MagicMock, mock_import_data: MagicMock, tmp_path: Path
169+
) -> None:
170+
"""Tests the success path where all CSV columns exist on the Odoo model."""
171+
# 1. Setup
172+
source_file = tmp_path / "data.csv"
173+
source_file.write_text("id,name,email") # Header for the file to be read
174+
175+
# Mock the Odoo model object and its return value
176+
mock_model_fields_obj = MagicMock()
177+
# Simulate Odoo returning a list of valid fields
178+
mock_model_fields_obj.search_read.return_value = [
179+
{"name": "id"},
180+
{"name": "name"},
181+
{"name": "email"},
182+
]
183+
mock_connection = MagicMock()
184+
mock_connection.get_model.return_value = mock_model_fields_obj
185+
mock_get_connection.return_value = mock_connection
186+
187+
# 2. Action
188+
run_import(
189+
config="dummy.conf",
190+
filename=str(source_file),
191+
model="res.partner",
192+
verify_fields=True,
193+
separator=",", # Use the correct separator for the test file
194+
)
195+
196+
# 3. Assertions
197+
# The verification should pass, and the main import function should be called
198+
mock_import_data.assert_called_once()
199+
200+
201+
@patch("odoo_data_flow.importer.import_threaded.import_data")
202+
@patch("odoo_data_flow.importer.log.error")
203+
@patch("odoo_data_flow.importer.conf_lib.get_connection_from_config")
204+
def test_verify_fields_failure_missing_field(
205+
mock_get_connection: MagicMock,
206+
mock_log_error: MagicMock,
207+
mock_import_data: MagicMock,
208+
tmp_path: Path,
209+
) -> None:
210+
"""Tests the failure path where a CSV column does not exist on the Odoo model."""
211+
# 1. Setup
212+
source_file = tmp_path / "data.csv"
213+
# This file contains a column that is not on the mocked model below
214+
source_file.write_text("id,name,x_studio_legacy_field")
215+
216+
mock_model_fields_obj = MagicMock()
217+
# Simulate Odoo returning only two valid fields
218+
mock_model_fields_obj.search_read.return_value = [
219+
{"name": "id"},
220+
{"name": "name"},
221+
]
222+
mock_connection = MagicMock()
223+
mock_connection.get_model.return_value = mock_model_fields_obj
224+
mock_get_connection.return_value = mock_connection
225+
226+
# 2. Action
227+
run_import(
228+
config="dummy.conf",
229+
filename=str(source_file),
230+
model="res.partner",
231+
verify_fields=True,
232+
separator=",",
233+
)
234+
235+
# 3. Assertions
236+
# An error should be logged, and the main import should NOT be called
237+
assert mock_log_error.call_count > 0
238+
# Check that the specific error message was one of the logs
239+
assert any(
240+
"is not a valid field" in call[0][0] for call in mock_log_error.call_args_list
241+
)
242+
mock_import_data.assert_not_called()
243+
244+
245+
@patch("odoo_data_flow.importer.import_threaded.import_data")
246+
@patch("odoo_data_flow.importer.log.error")
247+
def test_verify_fields_failure_file_not_found(
248+
mock_log_error: MagicMock, mock_import_data: MagicMock
249+
) -> None:
250+
"""Tests that verification fails gracefully if the source file is not found."""
251+
run_import(
252+
config="dummy.conf",
253+
filename="non_existent_file.csv",
254+
model="res.partner",
255+
verify_fields=True,
256+
)
257+
258+
# Assert that the specific error message was logged
259+
assert any(
260+
"Input file not found" in call[0][0] for call in mock_log_error.call_args_list
261+
)
262+
mock_import_data.assert_not_called()

0 commit comments

Comments
 (0)