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 ProfileAccount SecurityNew 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 :
    1. 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).
    2. 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_...).
    3. 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)TypeOblig.Exemple / Valeurs
Nom du biennameChar“Maison 70m² – Centre”
Référence externex_ref_extChar (unique*)“REF_27830”
Type de bienx_typeSelectionmaison, appartement, terrain, local, autre
Prix (€)x_priceFloat/Monétaire179000
Surface (m²)x_surface_m2Float70.0
Piècesx_roomsInteger3
Adressex_addressChar“12 rue Victor Hugo”
Villex_cityChar“RODEZ”
Code postalx_zipChar“12000”
Paysx_country_idMany2one → res.countryFrance
Descriptionx_descriptionTextTexte libre
URL sourcex_source_urlChar (URL)https://…/annonce/…
Statutx_stateSelectiondraft (Brouillon), published (Publié), archived
Image principalex_image_1920Image (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

  1. Studio → Créer une App (icône +) → Immobilier (Lite).
  2. Nouveau modèle → Nom technique : x_estate_property.
  3. 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)”.
  4. 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.
  5. Sécurité : donnez l’accès lecture/écriture aux groupes (Ventes/CRM ou un groupe custom “Immobilier”).
  6. (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.).