use case Immobilier : créez votre propre modèle avec Studio (les champs auront des noms techniques x_...), puis l’API peut écrire dedans
Odoo Online (SaaS) accepte les APIs XML-RPC et JSON-RPC.
La seule vraie limite d’Online : pas de modules custom installables (sauf via Odoo Studio). Donc :
- utilisez des modèles natifs (ex. product.template, crm.lead) ou
- créez votre propre modèle avec Studio (les champs auront des noms techniques x_...), puis l’API peut écrire dedans.
Ci-dessous je vous fournis un script prêt à adapter pour Odoo Online en XML-RPC (et un équivalent JSON-RPC si vous préférez). Il gère la création d’articles immobiliers dans product.template (simple et disponible en Online), avec image principale en base64, déduplication par default_code (votre référence annonce) et support des champs Studio.
Paramètres requis (Odoo Online)
- URL de votre base (ex. https://votre-société.odoo.com)
- DB name (visible dans About / A propos ou dans l’URL du plan de facturation).
- Email de connexion
-
API Key (Menu utilisateur → My Profile → Account Security → New API Key).
→ Dans l’API, l’API key se met à la place du mot de passe.
Option 1 — XML-RPC (recommandé, simple)
# odoo_online_import_products.py import xmlrpc.client, base64, csv, os # 1) RENSEIGNEZ VOS PARAMÈTRES ODOO_URL = "https://votre-societe.odoo.com" DB = "votre_db" USER = "vous@domaine.com" API_KEY = "votre_api_key" # générée dans Account Security CSV_PATH = "annonces.csv" # votre CSV source (depuis votre scrapper) # 2) CONNEXION common = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/common") uid = common.authenticate(DB, USER, API_KEY, {}) if not uid: raise RuntimeError("Échec d'authentification : vérifiez DB/USER/API_KEY") models = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/object") def image_to_b64(path): if not path or not os.path.exists(path): return False with open(path, "rb") as f: return base64.b64encode(f.read()).decode() # 3) IMPORT with open(CSV_PATH, newline="", encoding="utf-8") as f: reader = csv.DictReader(f) for r in reader: ref = (r.get("ref") or "").strip() name = (r.get("title") or "").strip() or ref or "Bien immobilier" price = float(r.get("price") or 0) desc = r.get("description") or "" address = r.get("address") or "" surface = float(r.get("surface_m2") or 0) img_path = r.get("main_image_local") or "" # 3.a Déduplication par ref (default_code) existing = models.execute_kw(DB, uid, API_KEY, 'product.template', 'search', [[['default_code', '=', ref]]], {'limit': 1}) vals = { 'name': name, 'type': 'product', # article vendable 'sale_ok': True, 'purchase_ok': False, 'list_price': price, # prix de vente 'default_code': ref, # votre identifiant unique 'description_sale': desc, # Champs Studio (créez-les dans Odoo Studio et remplacez les noms techniques ci-dessous) 'x_address': address, # ex. Char (tech name: x_address) 'x_surface_m2': surface, # ex. Float (tech name: x_surface_m2) 'x_source_url': r.get("url",""), } img_b64 = image_to_b64(img_path) if img_b64: vals['image_1920'] = img_b64 if existing: # UPDATE models.execute_kw(DB, uid, API_KEY, 'product.template', 'write', [[existing[0]], vals]) print(f"✔ update {ref} ({name})") else: # CREATE pid = models.execute_kw(DB, uid, API_KEY, 'product.template', 'create', [vals]) print(f"➕ create {pid} {ref} ({name})")
À faire côté Odoo Online (Studio)
Créez les champs Studio dont vous avez besoin et notez leurs noms techniques (ex. x_address, x_surface_m2, x_source_url) pour les réutiliser dans vals.
Option 2 — JSON-RPC (alternative)
# odoo_online_import_jsonrpc.py import requests, json, base64, csv, os ODOO_URL = "https://votre-societe.odoo.com" DB = "votre_db" USER = "vous@domaine.com" API_KEY = "votre_api_key" CSV_PATH = "annonces.csv" def call(model, method, args=None, kwargs=None): payload = { "jsonrpc": "2.0", "method": "call", "id": 1, "params": { "model": model, "method": method, "args": args or [], "kwargs": kwargs or {}, }, } r = requests.post(f"{ODOO_URL}/jsonrpc", json=payload, auth=(USER, API_KEY)) r.raise_for_status() res = r.json() if "error" in res: raise RuntimeError(res["error"]) return res["result"] def image_to_b64(path): if not path or not os.path.exists(path): return False return base64.b64encode(open(path, "rb").read()).decode() with open(CSV_PATH, newline="", encoding="utf-8") as f: reader = csv.DictReader(f) for r in reader: ref = (r.get("ref") or "").strip() name = (r.get("title") or "").strip() or ref or "Bien immobilier" price = float(r.get("price") or 0) vals = { "name": name, "type": "product", "sale_ok": True, "purchase_ok": False, "list_price": price, "default_code": ref, "description_sale": r.get("description") or "", "x_address": r.get("address") or "", "x_surface_m2": float(r.get("surface_m2") or 0), "x_source_url": r.get("url",""), } img_b64 = image_to_b64(r.get("main_image_local") or "") if img_b64: vals["image_1920"] = img_b64 # search existing = call('product.template', 'search', [[['default_code', '=', ref]]], {"limit": 1}) if existing: call('product.template', 'write', [existing, vals]) print(f"✔ update {ref}") else: pid = call('product.template', 'create', [vals]) print(f"➕ create {pid} {ref}")
Et si vous préférez un vrai modèle “immobilier” ?
-
Sur Odoo Online, vous ne pouvez pas installer un module Python personnalisé, mais vous pouvez :
- Créer un modèle avec Odoo Studio (ex. x_estate_property) avec vos champs métier (type, surface, pièces, prix, adresse, URL source, statut).
- Importer via les scripts ci-dessus en changeant 'product.template' par le nom technique du modèle Studio (visible dans Studio), et adapter les champs (x_...).
- Si vous avez besoin de logique avancée (workflows, vues portal), on peut ensuite créer des automatisations (server actions, e-mails, vues kanban) directement dans Studio.
Créer un modèle avec Odoo Studio (ex. x_estate_property) avec vos champs métier (type, surface, pièces, prix, adresse, URL source, statut).
🏗️ Ce que je vous recommande (vu votre objectif “scraper + injecter dans Odoo Online”)
✅ Scénario simple (0 code Studio)
Utiliser le modèle Produits (product.template), en ajoutant quelques champs via Studio :
- x_address (Adresse)
- x_surface_m2
- x_type (Maison, Terrain…)
- x_source_url
- x_ref_ext (Référence externe)
Puis votre script d’import (XML-RPC) fonctionne immédiatement.
🚀 Scénario enrichi
Créer dans Studio un modèle complet x_estate_property avec :
- name (Nom du bien)
- x_price, x_surface_m2, x_rooms, x_type
- x_address, x_city, x_zip
- x_description, x_source_url
- x_state (Brouillon / Publié)
Voici un pack complet pour créer votre modèle “immobilier” dans Odoo Online via Studio et l’alimenter par script.
Je vous donne : le schéma exact des champs, les étapes Studio, les vues conseillées, puis deux scripts d’import (XML-RPC & JSON-RPC) déjà adaptés au modèle x_estate_property + un gabarit CSV.
1) Schéma du modèle Studio : x_estate_property (Odoo Online)
| Étiquette (UI) | Nom technique (API) | Type | Oblig. | Exemple / Valeurs |
|---|---|---|---|---|
| Nom du bien | name | Char | ✅ | “Maison 70m² – Centre” |
| Référence externe | x_ref_ext | Char (unique*) | ✅ | “REF_27830” |
| Type de bien | x_type | Selection | maison, appartement, terrain, local, autre | |
| Prix (€) | x_price | Float/Monétaire | 179000 | |
| Surface (m²) | x_surface_m2 | Float | 70.0 | |
| Pièces | x_rooms | Integer | 3 | |
| Adresse | x_address | Char | “12 rue Victor Hugo” | |
| Ville | x_city | Char | “RODEZ” | |
| Code postal | x_zip | Char | “12000” | |
| Pays | x_country_id | Many2one → res.country | France | |
| Description | x_description | Text | Texte libre | |
| URL source | x_source_url | Char (URL) | https://…/annonce/… | |
| Statut | x_state | Selection | draft (Brouillon), published (Publié), archived | |
| Image principale | x_image_1920 | Image (binary) | (jpg/png) | |
| Galerie (pièces jointes) | ir.attachment lié | (hors champ, via pièces jointes) | 0..n images |
* Unicité : dans Studio, créez une contrainte d’unicité sur x_ref_ext (si Studio ne propose pas de contrainte stricte, on gèrera au script par “search puis create/update”).
Étapes rapides dans Odoo Studio
- Studio → Créer une App (icône +) → Immobilier (Lite).
- Nouveau modèle → Nom technique : x_estate_property.
-
Ajouter les champs de la table ci-dessus (respecter les noms techniques).
- x_type (Selection) : ajoutez les valeurs : maison, appartement, terrain, local, autre.
- x_state (Selection) : draft, published, archived (étiquettes FR).
- x_image_1920 (Image).
- x_country_id (Relation) → “Pays (res.country)”.
-
Onglet Vues :
- Kanban : image + badge prix + ville + surface.
- Formulaire : onglet “Détails” (prix/surface/rooms/type), onglet “Adresse”, onglet “Description”, onglet “Pièces jointes”.
- Liste : colonnes name, x_ref_ext, x_type, x_price, x_city, x_zip, x_state.
- Recherche : filtres rapides Type, Ville, Statut, plage de prix.
- Sécurité : donnez l’accès lecture/écriture aux groupes (Ventes/CRM ou un groupe custom “Immobilier”).
- (Optionnel) Activités : créer une activité automatique “Vérifier annonce” à la création (statut = draft).
2) Gabarit CSV d’import (depuis votre scrapper)
Colonnes conseillées (UTF-8 avec en-têtes) :
x_ref_ext,name,x_type,x_price,x_surface_m2,x_rooms,x_address,x_city,x_zip,x_source_url,x_state,main_image_local,gallery_urls
- main_image_local : chemin local ou URL directe (le script supporte les deux).
- gallery_urls : URLs multiples séparées par | (pipe) pour créer des pièces jointes.
3) Script XML-RPC (Odoo Online) — import/MAJ x_estate_property
- Upsert par x_ref_ext
- Image principale → champ x_image_1920
- Galerie → ir.attachment liés au record
- Supporte main_image_local (fichier local) ou URL (il télécharge)
# import_estate_xmlrpc.py import xmlrpc.client, base64, csv, os, requests ODOO_URL = "https://votre-societe.odoo.com" DB = "votre_db" USER = "vous@domaine.com" API_KEY = "votre_api_key" CSV_PATH = "annonces.csv" TIMEOUT = 25 common = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/common") uid = common.authenticate(DB, USER, API_KEY, {}) if not uid: raise RuntimeError("Auth échouée : vérifiez DB/USER/API_KEY") models = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/object") def file_or_url_to_b64(path_or_url: str): if not path_or_url: return None try: if path_or_url.startswith("http"): r = requests.get(path_or_url, timeout=TIMEOUT) r.raise_for_status() return base64.b64encode(r.content).decode() if os.path.exists(path_or_url): with open(path_or_url, "rb") as f: return base64.b64encode(f.read()).decode() except Exception as e: print("⚠️ image fail:", path_or_url, e) return None def create_attachment(name, data_b64, model, res_id): return models.execute_kw(DB, uid, API_KEY, 'ir.attachment', 'create', [{ 'name': name, 'datas': data_b64, 'res_model': model, 'res_id': res_id, 'type': 'binary', 'mimetype': 'image/jpeg', }]) with open(CSV_PATH, newline="", encoding="utf-8") as f: reader = csv.DictReader(f) for r in reader: ref = (r.get('x_ref_ext') or '').strip() if not ref: print("⏭️ skip: x_ref_ext manquant") continue vals = { 'name': (r.get('name') or ref), 'x_ref_ext': ref, 'x_type': (r.get('x_type') or 'autre'), 'x_price': float(r.get('x_price') or 0) or 0.0, 'x_surface_m2': float(r.get('x_surface_m2') or 0) or 0.0, 'x_rooms': int(float(r.get('x_rooms') or 0)) or 0, 'x_address': r.get('x_address') or '', 'x_city': (r.get('x_city') or '').upper(), 'x_zip': r.get('x_zip') or '', 'x_source_url': r.get('x_source_url') or '', 'x_state': r.get('x_state') or 'draft', 'x_description': r.get('x_description') or '', } # Image principale img_b64 = file_or_url_to_b64(r.get('main_image_local') or r.get('main_image_url') or '') if img_b64: vals['x_image_1920'] = img_b64 # champ image Studio # Upsert par x_ref_ext existing = models.execute_kw(DB, uid, API_KEY, 'x_estate_property', 'search', [[['x_ref_ext', '=', ref]]], {'limit': 1}) if existing: models.execute_kw(DB, uid, API_KEY, 'x_estate_property', 'write', [[existing[0]], vals]) rec_id = existing[0] print(f"✔ update {ref}") else: rec_id = models.execute_kw(DB, uid, API_KEY, 'x_estate_property', 'create', [vals]) print(f"➕ create {ref} (id {rec_id})") # Galerie (pièces jointes) gallery = r.get('gallery_urls') or '' if gallery: for i, u in enumerate([x.strip() for x in gallery.split('|') if x.strip()]): b64 = file_or_url_to_b64(u) if b64: create_attachment(f"{ref}_gal_{i+1}.jpg", b64, 'x_estate_property', rec_id)
4) Variante JSON-RPC (même logique)
# import_estate_jsonrpc.py import requests, json, base64, csv, os ODOO_URL = "https://votre-societe.odoo.com" DB, USER, API_KEY = "votre_db", "vous@domaine.com", "votre_api_key" CSV_PATH = "annonces.csv" TIMEOUT = 25 sess = requests.Session() sess.auth = (USER, API_KEY) def rpc(model, method, args=None, kwargs=None): payload = {"jsonrpc":"2.0","method":"call","id":1, "params":{"model":model,"method":method, "args":args or [],"kwargs":kwargs or {}}} r = sess.post(f"{ODOO_URL}/jsonrpc", json=payload, timeout=TIMEOUT) r.raise_for_status() res = r.json() if "error" in res: raise RuntimeError(res["error"]) return res["result"] def file_or_url_to_b64(p): try: if p and p.startswith("http"): r = sess.get(p, timeout=TIMEOUT) r.raise_for_status() return base64.b64encode(r.content).decode() if p and os.path.exists(p): return base64.b64encode(open(p,"rb").read()).decode() except Exception as e: print("img error:", p, e) return None with open(CSV_PATH, newline="", encoding="utf-8") as f: for r in csv.DictReader(f): ref = (r.get('x_ref_ext') or '').strip() if not ref: continue vals = { 'name': (r.get('name') or ref), 'x_ref_ext': ref, 'x_type': (r.get('x_type') or 'autre'), 'x_price': float(r.get('x_price') or 0) or 0.0, 'x_surface_m2': float(r.get('x_surface_m2') or 0) or 0.0, 'x_rooms': int(float(r.get('x_rooms') or 0)) or 0, 'x_address': r.get('x_address') or '', 'x_city': (r.get('x_city') or '').upper(), 'x_zip': r.get('x_zip') or '', 'x_source_url': r.get('x_source_url') or '', 'x_state': r.get('x_state') or 'draft', 'x_description': r.get('x_description') or '', } img_b64 = file_or_url_to_b64(r.get('main_image_local') or r.get('main_image_url') or '') if img_b64: vals['x_image_1920'] = img_b64 existing = rpc('x_estate_property','search',[[['x_ref_ext','=',ref]]],{"limit":1}) if existing: rpc('x_estate_property','write',[existing, vals]) rec_id = existing[0] print("update", ref) else: rec_id = rpc('x_estate_property','create',[vals]) print("create", ref) gallery = (r.get('gallery_urls') or '').split('|') for i,u in enumerate([x.strip() for x in gallery if x.strip()]): b64 = file_or_url_to_b64(u) if b64: rpc('ir.attachment','create',[{ 'name': f'{ref}_gal_{i+1}.jpg', 'datas': b64, 'res_model': 'x_estate_property', 'res_id': rec_id, 'type': 'binary', 'mimetype': 'image/jpeg', }])
5) Vues conseillées (rapide)
Kanban (carte)
- Image : x_image_1920
- Titre : name
- Badges : x_price (format €), x_surface_m2 “m²”, x_city
- État (puce) : x_state
Recherche
- Filtres rapides : Type, Statut, “Prix ≤ …” (filtre personnalisé), “Ville = …”
- Group by : x_city, x_type, x_state
6) Bonnes pratiques
- Clé unique : x_ref_ext = anti-doublon.
- Logs : le script imprime create/update ; exportez aussi un CSV des erreurs.
- Taux : 2–5 req/s max, sinon 429.
- Images : privilégiez ≤ 1920px pour limiter la taille des binaires.
- Droits : l’utilisateur API doit avoir l’accès à l’App Studio créée.
ANNEXE
Bonnes pratiques côté Online
- Clé unique (ex. default_code ou x_external_id) pour éviter les doublons.
- Batching : envoyez par paquets (ex. 50–100 lignes), logguez les erreurs.
- Rate-limit : ne spammez pas l’API (délai 0.2–0.5s entre requêtes si nécessaire).
- Images : image_1920 pour la principale ; pour une galerie, créez des pièces jointes (ir.attachment) reliées au record.
- Champs Studio : toujours utiliser le nom technique (ex. x_my_field), pas l’étiquette.
- Droits : l’API key hérite des droits de l’utilisateur → placez le compte dans le bon groupe (Ventes, Inventaire, etc.).