From 1ec72e41c8fbc96496e4ac26b4ef2572fc5391e5 Mon Sep 17 00:00:00 2001 From: classicitbb Date: Tue, 30 Dec 2025 12:37:43 -0400 Subject: [PATCH 1/2] Add Odoo contact sync script for option B --- README.md | 45 ++++++++ config.example.yaml | 30 +++++ requirements.txt | 1 + sync_contacts.py | 260 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 336 insertions(+) create mode 100644 README.md create mode 100644 config.example.yaml create mode 100644 requirements.txt create mode 100644 sync_contacts.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef4dcd6 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# Odoo Contact Sync (Option B) + +This project provides a simple **Option B** sync flow: contacts are created/updated in a +**primary Odoo database**, and a sync script propagates those changes to **secondary +Odoo databases**. The script also attempts to deduplicate by using a shared +`x_global_uuid` custom field, and falls back to email/phone matching when needed. + +## Features +- One-way sync from a primary Odoo DB to multiple targets. +- Deduplication based on: + 1. `x_global_uuid` (preferred) + 2. normalized email + 3. normalized phone +- Conflict detection for ambiguous matches. +- State file to sync only recent changes. + +## Setup + +1. Create and populate a configuration file: + +```bash +cp config.example.yaml config.yaml +``` + +2. Install dependencies: + +```bash +pip install -r requirements.txt +``` + +3. Run the sync: + +```bash +python sync_contacts.py --config config.yaml --state state.json +``` + +## Files +- `sync_contacts.py`: main sync script. +- `config.example.yaml`: sample configuration. +- `state.json`: stored sync cursor (created automatically). +- `conflicts.log`: detected ambiguous matches. + +## Notes +- The script will ensure the `x_global_uuid` field exists on `res.partner`. +- For intervention, review `conflicts.log` and correct/merge in Odoo, then re-run. diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..0a9ed10 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,30 @@ +primary: + url: "https://primary-odoo.example.com" + db: "primary_db" + username: "admin" + password: "admin" + +targets: + - name: "branch-1" + url: "https://branch-odoo.example.com" + db: "branch_db" + username: "admin" + password: "admin" + +sync: + batch_size: 200 + fields: + - name + - email + - phone + - mobile + - vat + - company_type + - street + - street2 + - city + - state_id + - zip + - country_id + - website + - comment diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8392d54 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +PyYAML==6.0.2 diff --git a/sync_contacts.py b/sync_contacts.py new file mode 100644 index 0000000..4b39337 --- /dev/null +++ b/sync_contacts.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +import argparse +import json +import sys +import time +import uuid +import xmlrpc.client +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import yaml + +GLOBAL_UUID_FIELD = "x_global_uuid" +CONFLICT_LOG = "conflicts.log" + + +@dataclass +class OdooConfig: + url: str + db: str + username: str + password: str + + +class OdooClient: + def __init__(self, config: OdooConfig): + self.config = config + self.common = xmlrpc.client.ServerProxy(f"{config.url}/xmlrpc/2/common") + self.uid = self.common.authenticate( + config.db, config.username, config.password, {} + ) + if not self.uid: + raise RuntimeError(f"Authentication failed for {config.db} at {config.url}") + self.models = xmlrpc.client.ServerProxy(f"{config.url}/xmlrpc/2/object") + + def execute(self, model: str, method: str, *args: Any, **kwargs: Any) -> Any: + return self.models.execute_kw( + self.config.db, self.uid, self.config.password, model, method, args, kwargs + ) + + +def load_config(path: Path) -> Tuple[OdooConfig, List[OdooConfig], Dict[str, Any]]: + data = yaml.safe_load(path.read_text()) + primary = OdooConfig(**data["primary"]) + targets = [OdooConfig(**target) for target in data.get("targets", [])] + sync = data.get("sync", {}) + return primary, targets, sync + + +def load_state(path: Path) -> Dict[str, str]: + if not path.exists(): + return {} + return json.loads(path.read_text()) + + +def save_state(path: Path, state: Dict[str, str]) -> None: + path.write_text(json.dumps(state, indent=2, sort_keys=True)) + + +def ensure_global_uuid_field(client: OdooClient) -> None: + field_ids = client.execute( + "ir.model.fields", + "search", + [["name", "=", GLOBAL_UUID_FIELD], ["model", "=", "res.partner"]], + {"limit": 1}, + ) + if field_ids: + return + client.execute( + "ir.model.fields", + "create", + { + "name": GLOBAL_UUID_FIELD, + "field_description": "Global UUID", + "model_id": client.execute( + "ir.model", + "search", + [["model", "=", "res.partner"]], + {"limit": 1}, + )[0], + "ttype": "char", + "required": False, + "readonly": False, + "store": True, + "size": 36, + }, + ) + + +def normalize_email(value: Optional[str]) -> Optional[str]: + if not value: + return None + return value.strip().lower() + + +def normalize_phone(value: Optional[str]) -> Optional[str]: + if not value: + return None + digits = "".join(ch for ch in value if ch.isdigit()) + return digits or None + + +def fetch_recent_contacts( + client: OdooClient, fields: List[str], since: Optional[str], limit: int +) -> List[Dict[str, Any]]: + domain = [] + if since: + domain = [["write_date", ">", since]] + return client.execute( + "res.partner", + "search_read", + domain, + { + "fields": fields, + "limit": limit, + "order": "write_date asc", + }, + ) + + +def find_match( + target: OdooClient, contact: Dict[str, Any] +) -> Tuple[Optional[int], Optional[str]]: + global_id = contact.get(GLOBAL_UUID_FIELD) + if global_id: + ids = target.execute( + "res.partner", "search", [[GLOBAL_UUID_FIELD, "=", global_id]] + ) + if ids: + return ids[0], "global_uuid" + + email = normalize_email(contact.get("email")) + if email: + ids = target.execute("res.partner", "search", [["email", "=", email]]) + if len(ids) == 1: + return ids[0], "email" + if len(ids) > 1: + return None, "conflict_email" + + phone = normalize_phone(contact.get("phone")) or normalize_phone(contact.get("mobile")) + if phone: + ids = target.execute( + "res.partner", + "search", + ["|", ["phone", "=", phone], ["mobile", "=", phone]], + ) + if len(ids) == 1: + return ids[0], "phone" + if len(ids) > 1: + return None, "conflict_phone" + + return None, None + + +def log_conflict(target_name: str, contact: Dict[str, Any], reason: str) -> None: + with Path(CONFLICT_LOG).open("a", encoding="utf-8") as handle: + handle.write( + json.dumps( + { + "timestamp": datetime.now(timezone.utc).isoformat(), + "target": target_name, + "reason": reason, + "contact": contact, + } + ) + + "\n" + ) + + +def prepare_payload(contact: Dict[str, Any], fields: List[str]) -> Dict[str, Any]: + payload = {field: contact.get(field) for field in fields if field in contact} + payload[GLOBAL_UUID_FIELD] = contact.get(GLOBAL_UUID_FIELD) or str(uuid.uuid4()) + return payload + + +def sync_contact( + source_contact: Dict[str, Any], + target: OdooClient, + target_name: str, + fields: List[str], +) -> None: + payload = prepare_payload(source_contact, fields) + match_id, reason = find_match(target, payload) + if reason and reason.startswith("conflict"): + log_conflict(target_name, payload, reason) + return + if match_id: + target.execute("res.partner", "write", [match_id], payload) + else: + target.execute("res.partner", "create", payload) + + +def sync_contacts( + primary: OdooClient, + targets: List[Tuple[str, OdooClient]], + fields: List[str], + state_path: Path, + batch_size: int, +) -> None: + state = load_state(state_path) + since = state.get("primary_write_date") + contacts = fetch_recent_contacts(primary, fields + [GLOBAL_UUID_FIELD], since, batch_size) + + if not contacts: + return + + for contact in contacts: + if not contact.get(GLOBAL_UUID_FIELD): + contact[GLOBAL_UUID_FIELD] = str(uuid.uuid4()) + primary.execute( + "res.partner", "write", [contact["id"]], {GLOBAL_UUID_FIELD: contact[GLOBAL_UUID_FIELD]} + ) + for target_name, target in targets: + sync_contact(contact, target, target_name, fields) + + last_write_date = contacts[-1].get("write_date") + if last_write_date: + state["primary_write_date"] = last_write_date + save_state(state_path, state) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Sync Odoo contacts from primary to targets") + parser.add_argument("--config", required=True, type=Path, help="Path to config.yaml") + parser.add_argument( + "--state", + default=Path("state.json"), + type=Path, + help="Path to sync state file", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + primary_config, target_configs, sync = load_config(args.config) + + fields = sync.get("fields", []) + if "write_date" not in fields: + fields.append("write_date") + + batch_size = int(sync.get("batch_size", 200)) + + primary = OdooClient(primary_config) + ensure_global_uuid_field(primary) + + targets: List[Tuple[str, OdooClient]] = [] + for target in target_configs: + client = OdooClient(target) + ensure_global_uuid_field(client) + targets.append((target.db, client)) + + sync_contacts(primary, targets, fields, args.state, batch_size) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 8781ebcfaac9ced900b78dc5baeded5bc3f1bf72 Mon Sep 17 00:00:00 2001 From: classicitbb Date: Tue, 30 Dec 2025 13:43:21 -0400 Subject: [PATCH 2/2] Allow optional target name in config --- sync_contacts.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sync_contacts.py b/sync_contacts.py index 4b39337..c8690ea 100644 --- a/sync_contacts.py +++ b/sync_contacts.py @@ -22,6 +22,7 @@ class OdooConfig: db: str username: str password: str + name: Optional[str] = None class OdooClient: @@ -250,7 +251,8 @@ def main() -> int: for target in target_configs: client = OdooClient(target) ensure_global_uuid_field(client) - targets.append((target.db, client)) + target_label = target.name or target.db + targets.append((target_label, client)) sync_contacts(primary, targets, fields, args.state, batch_size) return 0