Pièges Odoo 19 CE — index des erreurs classifiées et leurs solutions
Cette page compile les pièges rencontrés et corrigés sur Odoo 19 Community Edition, chacun validé par un cycle qualité complet (PDCA) sur du code réel testé de bout en bout. Chaque entrée donne le symptôme observable, la cause identifiée et la solution vérifiée.
Les identifiants sont stables : F00x (fix Odoo 19 — changements de version), B00x (bugs rencontrés en développement) et N00x (méthode native Website Builder et multi-website). Trois familles, trois sections ci-dessous.
Index des pièges
Pour aller droit au but : chaque référence renvoie à sa fiche détaillée (symptôme, cause, solution).
| Réf | Domaine | Synthèse |
|---|---|---|
| F001 | Cron ir.cron | numbercall retiré + active via eval="True" |
| F002 | Vues search | n'accepte aucun attribut en search |
| F003 | Séquences ir.sequence | Sans company_id lockée sur main_company |
| B003 | Controllers @route | type='json' deprecated → type='jsonrpc' |
| B004 | ACL ir.model.access.csv | group_id explicite obligatoire |
| B005 | Groupes res.groups | category_id n'existe plus |
| B006 | Utilisateurs res.users | groups_id → group_ids ; env.user shell = superuser |
| B007 | Table ir_ui_menu_group_rel | group_id renommé gid |
| B008 | Assets web après install | Purger ir.attachment URL /web/assets/% |
| B009 | Menus invisibles après B008 | Apps → module → « Mettre à jour » (UI) |
| B010 | Tests — IntegrityError | Absent de odoo.exceptions ; mute_logger + savepoint |
| B011 | Tests — --test-enable | Échoue si service actif ; --test-tags=/ |
| B012 | Contraintes SQL | -u ne supprime pas ; DROP CONSTRAINT manuel ; composite multi-société |
| B016 | Syntaxe attrs={} | Supprimé Odoo 17+ → invisible="condition_python" |
| B017 | Chatter | |
| B019 | | "false" → ParseError ; create/delete="false" |
| B021 | Modèle _inherits | Créer le record parent d'abord |
| B022 | res.users + with_company | Exige base.group_system ; société dans company_ids |
| B023 | portal.portal_layout | Ancre o_portal_sidebar inexistante → ParseError |
| B024 | csrf + HttpCase | csrf=False requis sur POST publics testés |
| B025 | HttpCase.authenticate() | password obligatoire au create() |
| B027 | Borne Datetime | <= 'YYYY-MM-DD' exclut le jour → ' 23:59:59' |
| N001 | Formulaire — route 404 | Route = /website/form/, pas /website_form/ |
| N002 | Formulaire — mauvais modèle | Action → mail.mail, pas res.partner |
| N003 | Formulaire — email non reçu | SMTP + champ email_to |
| N004 | Snippet non éditable | Insérer via la barre, pas copier-coller |
| N005 | data-vcss manquant | Insérer depuis l'éditeur (attributs auto) |
| N006 | Image hors médiathèque | Uploader via Website Builder / Médiathèque |
| N007 | Vue d'extension | active=False, ne jamais vider l'arch |
| N008 | Double injection Plausible | Désactiver la vue d'injection manuelle |
| N009 | Champ website inexistant | Tester in env['website']._fields avant write |
| N010 | Partage res.company | Footer natif ou société distincte par site |
| N011 | Site servi par website_id=1 | Host $host Nginx + domain sans https:// |
| N012 | CSS palette par site | website.custom_code_head natif |
| N013 | data-website-id sur | Écrire body { ... } (custom_code_head scopé) |
| N014 | Vue d'extension non appliquée | inherit_id en champ + mode='extension' |
| N015 | Markup.replace() échappe | str() d'abord |
| N016 | Override per-site | t-if website.id dans la vue partagée ; website.rewrite |
| N017 | Logo d'en-tête | website.logo ≠ res.company.logo ; contraste fond |
| N018 | Racine /doc réservée | Module core api_doc (Odoo 19) — choisir une autre racine de pages |
| N019 | Menu parent → URL # | Rechercher le menu par nom, pas par URL ; lier l'accueil dans le déroulant |
Code Odoo 19 — pièges F et B (socle technique)
Ces pièges touchent le code des modules : cron, vues XML, controllers, sécurité, ORM et tests. Ils proviennent presque tous de changements introduits entre Odoo 16/17 et Odoo 19 — du code valide hier casse aujourd'hui, souvent silencieusement.
F001 — Cron ir.cron : numbercall supprimé, active via eval
Symptôme : erreur d'installation dès qu'un fichier data/*.xml référence le champ numbercall ; ou cron qui reste inactif malgré active renseigné.
Cause : le champ numbercall n'existe pas en Odoo 19. Et la forme texte n'est plus interprétée.
Solution : ne jamais utiliser numbercall ; renseigner active via eval.
F002 — Vue search : n'accepte aucun attribut
Symptôme : erreur de chargement de vue sur une vue search contenant .
Cause : en Odoo 19, le d'une vue search n'accepte aucun attribut — ni expand, ni string.
Solution : utiliser simple ; le string est porté par chaque . Pour structurer la barre, enchaîner plusieurs consécutifs.
...
F003 — Séquence ir.sequence lockée sur la société principale
Symptôme : en multi-société, next_by_code() retourne None pour toute société autre que la principale.
Cause : une ir.sequence créée sans company_id explicite est lockée sur base.main_company.
Solution : déclarer dans le XML. Si la séquence est déjà lockée, un -u ne corrige pas les données noupdate="1" — corriger en SQL :
UPDATE ir_sequence SET company_id = NULL WHERE code = 'mon.module.code';
B003 — Controller @route : type='json' deprecated
Symptôme : route JSON qui ne fonctionne pas correctement en Odoo 19.
Cause : type='json' est deprecated dans le décorateur @http.route.
Solution : utiliser type='jsonrpc' pour toutes les routes JSON-RPC.
# ❌ Deprecated en Odoo 19
@http.route("/my/endpoint", type="json", auth="public", methods=["POST"], csrf=False)
# ✅ Correct
@http.route("/my/endpoint", type="jsonrpc", auth="public", methods=["POST"], csrf=False)
B004 — ACL : group_id explicite obligatoire
Symptôme : warnings bloquants à l'installation sur des lignes de ir.model.access.csv.
Cause : en Odoo 19, toute ligne ACL sans group_id:id explicite est deprecated.
Solution : renseigner un groupe sur chaque ligne. Pour donner accès « à tous », créer plusieurs lignes (une par groupe : base.group_public, base.group_portal, base.group_user…), jamais une ligne à groupe vide.
access_my_model_public,my.model public,model_my_model,base.group_public,1,0,0,0
access_my_model_user,my.model user,model_my_model,base.group_user,1,1,1,0
B005 — res.groups : category_id n'existe plus
Symptôme : ValueError: Invalid field 'category_id' in 'res.groups' à l'installation.
Cause : le champ category_id a été retiré de res.groups en Odoo 19 ; le modèle ir.module.category existe encore mais la liaison est rompue.
Solution : ne plus utiliser category_id ; préfixer le nom du groupe par le nom du module (ex. « Mon Module / Administrateur ») et gérer l'héritage via implied_ids.
Mon Module / Administrateur
B006 — res.users : groups_id renommé group_ids
Symptôme : erreur à la création d'un utilisateur avec groups_id, ou erreur dans un domaine ORM filtrant sur les groupes.
Cause : le champ Many2many des groupes est renommé groups_id → group_ids en Odoo 19 ; groups_id n'est en outre pas utilisable dans un domaine de recherche.
Solution : utiliser group_ids à la création ; pour filtrer, préférer .filtered(lambda u: u.has_group('base.group_system')).
# ❌ INCORRECT Odoo 19
self.env['res.users'].create({'login': '...', 'groups_id': [(4, group.id)]})
# ✅ CORRECT
self.env['res.users'].create({'login': '...', 'group_ids': [(4, group.id)]})
Note shell CLI : dans un odoo shell, env.user est le superuser interne (__system__), pas l'admin. Pour agir en admin : env.ref('base.user_admin').
B007 — Table ir_ui_menu_group_rel : colonne group_id renommée gid
Symptôme : échec de toute requête SQL ciblant ir_ui_menu_group_rel.group_id.
Cause : dans la table de liaison menus ↔ groupes, la colonne group_id est renommée gid en Odoo 19.
Solution : privilégier l'API ORM (menu.groups_id) plutôt que le SQL direct.
B008 — Assets web obsolètes après installation d'un module
Symptôme : après installation d'un module ajoutant menus ou assets, l'interface ne reflète pas les nouveautés malgré un redémarrage.
Cause : un simple restart ne suffit pas — les anciens bundles d'assets restent en cache dans ir.attachment.
Solution : purger les assets puis redémarrer Odoo ; ils sont recompilés au prochain chargement.
DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';
B009 — Menus toujours invisibles après la purge B008
Symptôme : menus d'un module fraîchement installé toujours absents, même après purge des assets et restart.
Cause : le registry in-worker n'est pas complètement invalidé par le restart ni par le -u en CLI.
Solution : passer par l'interface : Apps → le module → bouton « Mettre à jour » — cette action déclenche l'invalidation complète du registry.
B010 — Tests : IntegrityError absent de odoo.exceptions
Symptôme : impossible d'importer IntegrityError depuis odoo.exceptions dans un test de contrainte.
Cause : l'exception n'y est pas exposée ; de plus, une erreur SQL invalide la transaction tant qu'elle n'est pas rollbackée.
Solution : combiner mute_logger, assertRaises(Exception) et un savepoint().
from odoo.tools import mute_logger
with mute_logger('odoo.sql_db'), self.assertRaises(Exception):
with self.cr.savepoint():
self.env['ma_table'].create({...}) # déclenche la contrainte
B011 — --test-enable échoue si le service Odoo est actif
Symptôme : lancement des tests en CLI qui échoue (port occupé) quand le service Odoo tourne.
Cause : le processus de test entre en conflit avec le service actif sur le même port.
Solution : arrêter le service avant les tests, et toujours cibler --test-tags=/ pour un module dépendant de website ou portal — sinon les tests website natifs (échecs préexistants en Odoo 19 CE) polluent le résultat.
sudo systemctl stop odoo
sudo -u odoo /usr/bin/odoo -c /etc/odoo/odoo.conf -i mon_module \
-d ma_base-dev --test-enable --test-tags=/mon_module --stop-after-init
sudo systemctl start odoo
B012 — Contraintes SQL : un -u ne supprime rien, et _sql_constraints a disparu
Symptôme : une contrainte UNIQUE obsolète continue de bloquer les créations (cas typique multi-société), malgré la mise à jour du module ; ou AttributeError en cascade au chargement du modèle.
Cause : une contrainte SQL déjà en base n'est ni supprimée ni modifiée par un simple -u. Par ailleurs, Odoo 19 n'utilise plus la liste _sql_constraints ; une déclaration models.Constraint placée dans une liste fait échouer la définition de classe — le modèle est absent du pool et tous les Many2one qui le référencent lèvent des AssertionError.
Solution : supprimer manuellement l'ancienne contrainte avant le -u, et déclarer les contraintes en attribut de classe direct.
-- 1. Supprimer manuellement l'ancienne contrainte AVANT le -u
ALTER TABLE ma_table DROP CONSTRAINT IF EXISTS ma_table_reference_uniq;
-- 2. Puis lancer le -u : la nouvelle contrainte composite sera créée
# ✅ Attribut de classe direct — jamais dans une liste [...]
name_unique = models.Constraint('UNIQUE(name)', 'Message')
B016 — Syntaxe attrs={} supprimée (Odoo 17+)
Symptôme : erreur de vue sur toute déclaration attrs="{'invisible': [...]}".
Cause : la syntaxe attrs n'existe plus depuis Odoo 17.
Solution : conditions en expressions Python directes, évaluées contre le record courant (pas des domaines) : Symptôme : chatter absent ou vue invalide avec l'ancienne structure Cause : depuis Odoo 17, le chatter se déclare par la balise unique Solution : placer Symptôme : Cause : l'attribut Solution : pour une liste non éditable, utiliser Symptôme : erreur de champs inexistants ( Cause : un modèle avec Solution : créer le parent d'abord, puis le délégant. Symptôme : Cause : créer un Solution : dans les tests, créer via Symptôme : Cause : le template n'expose plus l'ancre Solution : ne plus cibler ce div ; contournement v1 : template d'héritage vide, layout natif utilisé. Symptôme : les POST publics échouent dans les tests Cause : le token CSRF requis par défaut n'est pas fourni par le client de test sur les routes publiques. Solution : Symptôme : authentification HTTP qui échoue silencieusement dans les tests (routes Cause : l'utilisateur de test a été créé sans Solution : mot de passe explicite au Symptôme : les enregistrements du dernier jour manquent dans une recherche bornée par date. Cause : un Solution : suffixer Ces pièges se rencontrent pendant le cycle développement / déploiement sur serveur : symptômes opérationnels (logs vides, 502, modification ignorée…) plutôt qu'erreurs de code. La table ci-dessous permet un diagnostic en quelques secondes ; les entrées liées à un piège classifié renvoient vers sa fiche. Ces pièges sont spécifiques à la méthode native de création de site (Website Builder d'Odoo 19 CE) et au multi-website : formulaires, snippets, médiathèque, vues QWeb par site et analytics. Le contexte complet est décrit dans le guide Créer un site web avec le Website Builder. Symptôme : le formulaire de contact renvoie une erreur 404 à la soumission. Cause : la route Odoo 19 est Solution : vérifier l' Symptôme : les soumissions sont créées dans Cause : le modèle cible par défaut du formulaire est Solution : dans le Website Builder, action → « Envoyer un email » ( Symptôme : le formulaire semble fonctionner mais aucun email n'arrive. Cause : (1) SMTP sortant non configuré — les mails sont créés en base mais non livrés ; (2) champ caché Solution : configurer le SMTP (Paramètres → Email → Serveurs sortants) et vérifier le champ caché Symptôme : un snippet collé dans une page n'est plus éditable comme un bloc du Website Builder. Cause : snippet collé manuellement sans attributs Solution : toujours insérer via la barre de l'éditeur (glisser-déposer), pas par copier-coller. Symptôme : Cause : le CSS de ces snippets est chargé via Solution : insérer le snippet depuis la barre de l'éditeur — les attributs sont portés automatiquement. Symptôme : image en Cause : image insérée en data URI au lieu d'un upload. Solution : uploader via le Website Builder (« Remplacer » → « Uploader ») ou la Médiathèque ; utiliser l'URL Odoo ( Symptôme : 500 Internal Server Error au prochain chargement après modification d'une vue d'extension. Cause : une vue d'extension vidée a des vues enfants (primary) dont les XPath ( Solution : Symptôme : Cause : une vue Solution : désactiver la vue d'injection manuelle — avec Symptôme : un Cause : champ testé sans vérification d'existence, dans la même transaction que des créations critiques. Solution : tester l'existence du champ, et ne jamais mettre un Symptôme : en multi-website, les deux sites affichent les mêmes coordonnées dans le pied de page natif. Cause : les sites partagent la même société ; les snippets lisent Solution : désactiver le pied de page natif ( Symptôme : le second domaine affiche le contenu du premier site. Cause : (1) Solution : vérifier Symptôme : le CSS de palette d'un site ne s'applique jamais. Cause : la vue censée étendre Solution : injecter via le champ natif Symptôme : le sélecteur Cause : Odoo place Solution : comme Symptôme : une vue d'extension créée par script n'a aucun effet. Cause : Solution : régler le champ Symptôme : un Cause : ces champs renvoient un objet Solution : convertir en chaîne d'abord : Symptôme : un footer ou lien partagé ( Cause : le mécanisme copy-on-write ne produit pas l'effet attendu sur ce cas. Solution : rendre le contenu conditionnel dans la vue partagée. Pour un simple lien : une redirection Cache à connaître : les changements de vues ( Symptôme : le logo modifié sur la société n'apparaît pas dans le bandeau du site, ou le logo est illisible sur le fond du header. Cause : le logo du bandeau est Solution : modifier Symptôme : une page website créée sous Cause : le module core Solution : ne jamais créer de page website directement sous Symptôme : un Cause : Website transforme tout menu ayant des enfants en simple déclencheur de déroulant, sans lien propre. Solution : rechercher le menu parent par Méthode IA&TIC — © 2026 IA&TIC — Freddy Maillard & Fabrice-Loïc Barbier — CC BY-SA 4.0invisible, readonly, required sur , , , ,
B017 — Chatter :
. après , au même niveau dans le .
B019 —
: ParseError
ParseError bloquante au chargement d'une vue liste.editable n'accepte que "top" ou "bottom" — jamais "false".create="false" delete="false".
B021 — Modèle _inherits : créer le record parent d'abord
name, phone, email…) à la création d'un record sur un modèle délégant._inherits = {'res.partner': 'partner_id'} n'a pas de champs d'identité propres — ils sont portés par le parent.# ✅ CORRECT — créer le parent d'abord, puis le délégant
partner = self.env['res.partner'].create({'name': 'Jean Dupont', 'phone': '0102030405'})
self.env['mon_module.ma_table'].create({'partner_id': partner.id})B022 — Création de res.users et contrainte with_company
AccessError à la création d'un utilisateur, ou recordset vide silencieux avec with_company().res.users exige l'appartenance à base.group_system (un groupe applicatif custom ne suffit pas) ; et with_company(company) exige que la société figure dans user.company_ids.base.user_admin et renseigner company_ids.self.env['res.users'].with_user(self.env.ref('base.user_admin')).create({
'name': 'Utilisateur Test',
'login': 'user_test@example.com',
'password': 'user_test@example.com',
'company_id': self.company.id,
'company_ids': [(4, self.company.id)],
})B023 — portal.portal_layout : ancre o_portal_sidebar inexistante
ParseError bloquant l'installation d'un module héritant de portal.portal_layout.//div[hasclass('o_portal_sidebar')] en Odoo 19.
B024 — csrf=True bloque les POST publics en HttpCase
HttpCase.csrf=False sur les formulaires publics si la validation est faite côté backend. Sur les routes auth='user', le token est géré automatiquement — csrf=True peut rester.@http.route('/mon_module/endpoint', auth='public', website=True,
methods=['GET', 'POST'], csrf=False)B025 — HttpCase.authenticate() exige un mot de passe défini
auth='user' → 303 vers la page de login).password.create() (convention : login = password dans les tests).user = self.env['res.users'].sudo().create({
'name': 'Utilisateur Test',
'login': 'user_test@example.com',
'password': 'user_test@example.com',
})
self.authenticate('user_test@example.com', 'user_test@example.com')B027 — Borne 'YYYY-MM-DD' sur un champ Datetime exclut le jour
fields.Datetime stocke YYYY-MM-DD HH:MM:SS ; la borne ('date_champ', '<=', '2026-05-20') est interprétée <= '2026-05-20 00:00:00'.' 23:59:59', ou borner strictement sur le lendemain. Ne s'applique pas aux champs fields.Date (sans composant heure).# ✅ option 1 : suffixer ' 23:59:59'
domain = [('date_champ', '<=', fin.strftime('%Y-%m-%d') + ' 23:59:59')]
# ✅ option 2 : borne stricte sur le lendemain
domain = [('date_champ', '<', (fin + timedelta(days=1)).strftime('%Y-%m-%d'))]Développement et déploiement — table de triage rapide
Problème Cause Solution Mise à jour silencieuse (aucun log) Erreur XML/Python bloquante silencieuse Lire le log après -uLogs vides dans le terminal logfile configuré dans odoo.confLire /var/log/odoo/odoo-server.log.py modifié non pris en compteCache bytecode .pycfind ... -name "*.pyc" -delete + restartMenu invisible après -uErreur XML silencieuse / action manquante Vérifier logs + mode debug (cf. B008/B009) attrs deprecatedOdoo 17+ supprime attrsinvisible="condition" directe (cf. B016)t-set en back-officeDirective OWL interdite en vue formulaire Templates QWeb website uniquement res.partner.mobile → AttributeErrorChamp absent en Odoo 19 CE Utiliser phone et email502 Bad Gateway Odoo planté au démarrage sudo tail -50 /var/log/odoo/odoo-server.logCSRF manquant dans form POST Token requis dans tout Champ inconnu dans domain filter Module dépendant non déclaré Ajouter la dépendance dans __manifest__.py/website/info expose les modulesRoute publique listant les modules — risque sécu Site Web → Pages système → désactiver /website/info_sql_constraints → upgrade silencieuxOdoo 19 n'utilise plus _sql_constraintsMigrer vers models.Constraint attribut direct (cf. B012)models.Constraint dans une liste → AttributeErrorSyntaxe liste interdite name_unique = models.Constraint('UNIQUE(name)', 'Message')groups_id dans domain ORM → erreurNon searchable en Odoo 19 .filtered(lambda u: u.has_group('base.group_system')) (cf. B006)StripeObject.get() → AttributeErrorSDK Stripe récent : .get() non supportépi[key] dans un helper _pi_field(pi, key, default)'cancelled' dans Selection offre → rollback silencieuxValeur inexistante → rollback PG sans log Utiliser 'rejected' pour les offres non retenuesnumbercall dans ir.cron → erreur installChamp supprimé Ne pas utiliser (cf. F001) active="True" dans ir.cron → ignoréForme texte non interprétée (cf. F001) en search → erreur n'accepte aucun attribut simple (cf. F002)type="json" dans @http.route → KODeprecated type="jsonrpc" (cf. B003)Ligne ACL sans group_id → warning bloquantObligatoire en Odoo 19 Groupe explicite (cf. B004) Website Builder et multi-website — pièges N001 à N019
N001 — Route formulaire /website_form/ → 404
/website/form/ (avec préfixe website/), pas /website_form/. Le JS construit l'URL à partir de l'action + data-model_name.action du → /website/form/ uniquement.N002 — Formulaire envoie vers le mauvais modèle
res.partner au lieu de partir par email.res.partner.mail.mail). Vérifier data-model_name="mail.mail" sur le .N003 — Email non reçu après soumission du formulaire
email_to absent ou incorrect.email_to.N004 — Snippet non reconnu après import
data-name, ou classe racine non reconnue.N005 — data-vcss manquant : styles absents
s_comparisons, s_features_grid, s_quotes_carousel… s'affichent sans mise en forme.data-vcss="001" (ou 002) ; si l'attribut est absent, la feuille versionnée n'est pas chargée.N006 — Image non reprise par la médiathèque
src="data:..." (base64), absente de la médiathèque./web/image/ir.attachment/ID/raw).N007 — Désactiver une vue d'extension : active=False, pas vider l'arch
//a[hasclass('oe_unremovable')]) ne trouvent plus leur cible.view.write({'active': False}) — Odoo ignore les vues inactives. Ne jamais vider l'arch d'une vue qui peut avoir des enfants.N008 — Double injection du script Plausible
curl -s https://CLIENT.fr | grep -c "plausible" retourne 2 ou plus.ir.ui.view héritant de website.layout (injection manuelle) coexiste avec l'intégration native activée.active=False, jamais en vidant l'arch (cf. N007).env['ir.ui.view'].search([('key', '=', 'website.CLIENT_plausible')]).write({'active': False})N009 — Champ inexistant dans le modèle website : rollback en cascade
write() sur un champ inconnu lève une ValueError avant le commit() → rollback de tous les create() précédents de la même transaction.write() douteux dans la même transaction que des create() critiques.if 'MON_CHAMP' in env['website']._fields:
w.write({'MON_CHAMP': valeur})N010 — Partage de res.company : coordonnées et footer identiques
env.company.website.SITE_hide_footer_copyright) ou créer une société distincte par site.N011 — Site servi systématiquement par website_id=1
proxy_set_header Host $host; absent du vhost Nginx → Odoo reçoit Host: 127.0.0.1:8069 et sert le site par défaut ; (2) website.domain contient https://exemple.fr au lieu de exemple.fr ; (3) DNS non propagé (tester curl -H "Host: exemple2.fr" http://IP_SERVEUR).proxy_set_header Host $host; et le champ domain (hostname nu, sans https:// ni www.).N012 — CSS de palette par site : custom_code_head, pas une vue assets
assets_frontend n'est pas chargée (cf. N014).website.custom_code_head (Réglages → Code personnalisé), déjà scopé au site.env['website'].browse(WID).write({'custom_code_head': ''})N013 — data-website-id est sur , pas sur
body[data-website-id="2"] { ... } ne stylise rien.data-website-id sur (via html_data).custom_code_head est déjà scopé au site, écrire simplement body { ... } (sinon html[data-website-id="2"] body).N014 — Vue d'extension jamais appliquée (inherit_id en texte, pas en champ)
inherit_id mis dans le texte XML du , mais le champ inherit_id (Many2one) resté vide + mode='primary' → Odoo ne chaîne jamais la vue.inherit_id (id de la vue parente) et mode='extension'. Attention : l'xpath doit cibler un nœud réel de l'arch propre du parent — website.layout n'a pas de (c'est une extension de portal.frontend_layout), donc //head n'y matche pas.N015 — Markup.replace() échappe les motifs cherchés
str.replace() sur custom_code_head / arch_db ne remplace rien.markupsafe.Markup ; .replace('a="2"', ...) échappe les guillemets (" → ") avant de chercher.css = str(view.custom_code_head).N016 — Override per-site d'une vue partagée : t-if website.id, pas le COW
website_id=False) doit différer par site ; le COW (view.with_context(website_id=N).write(...)) crée une copie mais le rendu sert toujours la vue globale.website.rewrite (301, url_from/url_to, website_id) scopée au site.ir.ui.view) peuvent tarder (cache de page/QWeb des workers) — patienter ou systemctl restart odoo. custom_code_head, website.logo et website.rewrite se reflètent vite.N017 — Logo d'en-tête : website.logo ≠ res.company.logo
website.logo (par site), distinct du logo société (res.company.logo, utilisé sur factures et devis).website.logo pour le site, et adapter la couleur au fond du header : logo clair sur header sombre, logo foncé sur header blanc. Recolorer un SVG (changer les fill) est immédiat ; un SVG rend bien dans un navigateur (contrairement à wkhtmltopdf, qui ne rend pas le SVG).N018 — Racine /doc réservée par le module core api_doc (Odoo 19)
/doc/ avec un seul segment (ex. /doc/ma-doc) renvoie un 303 vers la page de connexion pour un visiteur, alors qu'elle est publiée et publique ; connecté, c'est la documentation de l'API qui répond, jamais la page.api_doc (documentation de l'API REST, intégrée à Odoo 19) déclare la route ['/doc', '/doc/ avec auth='user' — et un contrôleur gagne toujours sur la résolution des pages website. Les URLs à deux segments (/doc/a/b) ne matchent pas ce pattern, d'où des sous-pages qui fonctionnent pendant que l'accueil est intercepté./doc/ ; choisir une autre racine (ex. /documentation/...). Avant d'adopter une racine, vérifier les collisions de routes : grep -rn "route(" addons/*/controllers/.N019 — Menu parent transformé en déroulant : URL remplacée par #
website.menu créé par ORM avec une URL la perd (remplacée par #) dès qu'on lui ajoute des sous-menus ; une recherche d'idempotence par URL ne le retrouve plus et le script en crée un doublon à la ré-exécution — et la page visée par l'ancienne URL n'est plus accessible depuis le menu.(name, parent_id, website_id) et non par URL ; et donner à la page d'accueil de la section sa propre entrée dans le déroulant (ex. « Vue d'ensemble » en première position).