Cheat sheets
imprimer pdf sans passer par action
http://localhost:8069/report/pdf/c_purchase.report_buybackoffer/8177
surcharge méthode JS :
/** @odoo-module **/
import { ProductLabelSectionAndNoteField } from '@account/components/product_label_section_and_note_field/product_label_section_and_note_field';
// Récupère le getter original
const originalDescriptor = Object.getOwnPropertyDescriptor(
ProductLabelSectionAndNoteField.prototype,
'isProductClickable'
);
Object.defineProperty(ProductLabelSectionAndNoteField.prototype, 'isProductClickable', {
get: function () {
const parent = this.props.record.evalContext?.parent;
if (!parent) {
// Safe pour Studio
return false;
}
// Fallback sur le getter original si parent existe
return originalDescriptor?.get?.call(this) ?? parent.state !== "draft";
},
configurable: true,
});
- Object.getOwnPropertyDescriptor récupère le getter original, ce qui est nécessaire pour les getters Owl.
- this.props.record.evalContext?.parent vérifie que parent existe avant d’accéder à state.
- Si parent est undefined (cas Studio), on retourne false → plus de crash.
- Si parent existe, on appelle le getter original → comportement standard conservé.
- configurable: true permet des modifications futures sans conflit.
class ProductTemplate(models.Model):
_inherit = "product.template"
mandatory_associate_products = fields.Many2many(
'product.product', 'product_mandatory_rel_w', 'src_id', 'dest_id', check_company=True,
string='Mandatory associate product', help='Add automatically products to a website sale')
| Bundle | Utilisé pour |
| web.assets_backend | Interface backend |
| web.assets_frontend | Site web |
| web.report_assets_common | Rapports PDF / QWeb |
XML cibler un élément dynamique
ex: t-attf-class {company_id} est une expression dynamique.
//div[@t-attf-class='footer o_company_#{company.id}_layout']Il faut cibler un élément stable, par exemple la classe statique footer.
Cibler avec un contains:
<template id="external_layout_boxed_inherit"
inherit_id="web.external_layout_boxed">
<xpath expr="//div[contains(@t-attf-class, 'footer o_company_')]" position="attributes">
<attribute name="t-if">not without_header_footer</attribute>
</xpath>
</template>
SUDO:
def _check_validity(self):
if self.env.user == self.employee_id.leave_manager_id.leave_manager_id:
return super(HolidaysRequest, self.sudo())._check_validity()
1. Menus — Structure & pièges
1.1 Règle d'or : ordre de chargement dans __manifest__.py
Les menus référencent des actions (action=). Si le fichier menu est chargé avant le fichier qui déclare l'action, Odoo lève immédiatement :
|
ValueError: External ID not found in the system: my_module.my_action |
Ordre obligatoire dans 'data' :
|
'data': [ 'security/res_groups.xml', # 1 — groupes en premier 'security/ir.model.access.csv', # 2 — droits d'accès 'views/stock_quant_views.xml', # 3 — vues sans actions custom 'reports/my_report_views.xml', # 4 — VUES + ACTIONS 'views/menu.xml', # 5 — menus EN DERNIER 'data/ir_cron.xml', # 6 — données ], |
1.2 Règle d'or : groups= sur les menus
|
⚠️ PIÈGE Ne jamais mettre groups= sur le menu racine. Odoo masque en cascade : si le parent est invisible, tous ses enfants disparaissent aussi, même si l'utilisateur a les droits. |
Structure correcte :
|
<!-- Racine : PAS de groups= --> <menuitem id="my_menu_root" name="Mon Module" sequence="5"/>
<!-- Sous-menus : groups= ici --> <menuitem id="my_report_menu" parent="my_module.my_menu_root" action="my_module.my_report_action" groups="my_module.res_groups_my_user" sequence="10" /> |
Comportement d'Odoo : si tous les enfants d'un parent sont invisibles pour l'utilisateur, Odoo masque automatiquement le parent — pas besoin de le gérer manuellement.
1.3 Ne jamais dupliquer les déclarations
|
🚨 BUG CRITIQUE Si un même id XML (ex: res_groups_virtual_stock_user) est déclaré dans deux fichiers différents avec des valeurs différentes, le second écrase le premier. Le conflit de données peut faire disparaître silencieusement tous les menus qui en dépendent. |
|
Fichier |
Contenu autorisé |
|
res_groups.xml |
Déclaration des groupes — une seule fois |
|
ir.model.access.csv |
Règles d'accès — une seule fois |
|
menu.xml |
Tous les menuitem — une seule fois |
|
*_views.xml |
ir.ui.view + ir.actions.act_window uniquement — jamais de groupes, jamais de menus |
2. Droits d'accès
2.1 Les trois niveaux
|
Niveau |
Fichier |
|
Groupes (profils) |
security/res_groups.xml |
|
Accès modèles |
security/ir.model.access.csv |
|
Visibilité menus |
views/menu.xml (groups=) |
2.2 res_groups.xml — hiérarchie par implied_ids
Un groupe peut impliquer un autre : l'utilisateur du groupe enfant hérite automatiquement des droits du groupe parent.
|
<!-- Groupe de base --> <record id="res_groups_my_user" model="res.groups"> <field name="name">My Module / User</field> <field name="category_id" ref="base.module_category_inventory"/> <field name="implied_ids" eval="[(4, ref('stock.group_stock_user'))]"/> </record>
<!-- Groupe avancé — hérite du groupe de base --> <record id="res_groups_my_manager" model="res.groups"> <field name="name">My Module / Manager</field> <field name="category_id" ref="base.module_category_inventory"/> <field name="implied_ids" eval="[(4, ref('my_module.res_groups_my_user'))]"/> </record> |
2.3 ir.model.access.csv — obligatoire pour chaque modèle custom
|
⚠️ ATTENTION Sans ce fichier, AUCUN utilisateur ne peut accéder au modèle, même l'administrateur. Le message d'erreur est : "Vous n'êtes pas autorisé à accéder aux enregistrements 'X'." |
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_my_report_admin,my.report admin,model_my_report,base.group_system,1,1,1,1 access_my_report_user,my.report user,model_my_report,my_module.res_groups_my_user,1,0,0,0 |
Convention model_id : remplacer les . par _ et préfixer de model_. Ainsi my.report.part1 devient model_my_report_part1.
2.4 Tableau récapitulatif groupes / accès
|
Groupe |
Accès |
|
base.group_system |
Admin — droits complets (1,1,1,1) |
|
groupe métier user |
Lecture seule — rapports (1,0,0,0) |
|
groupe métier manager |
Lecture + écriture (1,1,0,0) |
|
(aucun groupe) |
Accès public — toujours vide pour les rapports |
3. Rapports avec _auto = False
3.1 _auto = True vs _auto = False
|
_auto = True (défaut) |
|
|
Qui crée la table ? |
L'ORM Odoo, automatiquement au -u |
|
Structure DDL |
Générée depuis les champs Python |
|
init() appelé ? |
Oui, après la création auto |
|
Usage typique |
Modèles CRUD standards |
|
_auto = False |
|
|
Qui crée la table ? |
Vous — dans init() via CREATE TABLE |
|
Structure DDL |
100% sous votre contrôle |
|
init() appelé ? |
Oui, c'est le seul hook disponible |
|
Usage typique |
Rapports, vues SQL, tables peuplées par cron |
|
🚨 BUG CLASSIQUE _auto = True + init() surchargé sans super().init() = table créée par l'ORM mais mal gérée. _auto = False + pas de CREATE TABLE dans init() = table inexistante, module plante au premier accès. |
3.2 Pattern correct : _auto = False
|
class MyReport(models.Model): _name = 'my.report' _auto = False # <-- Odoo ne touche pas à la table _description = 'My Report'
name = fields.Char() value = fields.Float() # NE PAS mettre translate=True sur un champ stocké en TEXT # translate=True => Odoo attend du JSONB en base description = fields.Html(translate=True) # => colonne JSONB
def init(self): self.env.cr.execute(''' CREATE TABLE IF NOT EXISTS my_report ( id SERIAL PRIMARY KEY, name VARCHAR, value NUMERIC, description JSONB -- translate=True => JSONB obligatoire ) ''') # Migration si la table existait avec TEXT self.env.cr.execute(''' SELECT data_type FROM information_schema.columns WHERE table_name = 'my_report' AND column_name = 'description' ''') row = self.env.cr.fetchone() if row and row[0].lower() != 'jsonb': self.env.cr.execute(''' ALTER TABLE my_report ALTER COLUMN description TYPE JSONB USING description::jsonb ''') # Index self.env.cr.execute( 'CREATE INDEX IF NOT EXISTS my_report_name_idx ON my_report(name)' ) |
3.3 translate=True et JSONB — règle absolue
|
fields.Html() |
Colonne TEXT en base — simple |
|
fields.Html(translate=True) |
Colonne JSONB en base — Odoo génère ->>'lang' |
|
fields.Char(translate=True) |
Colonne JSONB en base |
|
⚠️ ERREUR Si la déclaration Python dit translate=True mais la colonne SQL est TEXT, Odoo lève : "operator does not exist: text ->> unknown". Le type SQL doit correspondre. |
3.4 Refresh atomique — staging swap
Pattern pour peupler la table sans laisser un état vide visible si l'INSERT échoue :
|
@api.model def refresh_my_report(self): params = {'companies': list(self.env.user.get_grouped_company_ids())} cr = self.env.cr try: # 1. Table de staging temporaire (détruite en fin de transaction) cr.execute(''' CREATE TEMP TABLE IF NOT EXISTS my_report_staging (LIKE my_report INCLUDING ALL) ON COMMIT DROP ''') cr.execute('TRUNCATE my_report_staging') # 2. Insérer dans le staging cr.execute( 'INSERT INTO my_report_staging (name, value) ' + my_query, params, ) # 3. Swap atomique cr.execute('TRUNCATE my_report') cr.execute('INSERT INTO my_report (name, value) SELECT name, value FROM my_report_staging') except Exception: _logger.exception('Refresh failed — table left intact') raise |
3.5 uninstall_hook — nettoyer les tables _auto=False
Sans hook, Odoo tente des ALTER TABLE DROP COLUMN individuels à la désinstallation, ce qui provoque des timeouts de verrou en production.
|
# hooks.py (à la racine du module) _CUSTOM_TABLES = ['my_report', 'my_report_part1']
def uninstall_hook(env): for table in _CUSTOM_TABLES: env.cr.execute(f'DROP TABLE IF EXISTS "{table}" CASCADE') |
|
# __manifest__.py { ... 'uninstall_hook': 'uninstall_hook', } |
4. CTEs SQL dans un UNION ALL — piège classique
4.1 Le problème
|
🚨 ERREUR PostgreSQL SyntaxError: syntax error at or near "WITH" — se produit quand un WITH (CTE) apparaît à l'intérieur d'un membre de UNION ALL. |
Code bogué :
|
-- ❌ INTERDIT : WITH à l'intérieur d'un UNION ALL SELECT * FROM ( WITH cte1 AS (...) SELECT ... -- invalide ici UNION ALL WITH cte2 AS (...) SELECT ... -- invalide ici ) t |
4.2 La solution : WITH une seule fois au sommet
|
-- ✅ CORRECT : WITH déclaré UNE SEULE FOIS avant tout WITH cte_rental AS ( -- partagée par tous les SELECT SELECT product_id, MIN(return_date) AS return_date FROM sale_order_line ... GROUP BY product_id ), cte_rates AS ( -- partagée par tous les SELECT SELECT currency_id, rate FROM res_currency_rate ... ) SELECT * FROM ( SELECT ... FROM stock_quant LEFT JOIN cte_rental ON ... UNION ALL SELECT ... FROM deposit_sale_contract_item LEFT JOIN cte_rates ON ... UNION ALL SELECT ... FROM purchase_order_line LEFT JOIN cte_rates ON ... ) virtual_stock |
4.3 Pattern Python — méthodes séparées
Principe : chaque méthode _*_query() ne retourne que le SELECT pur, sans WITH. Le WITH est assemblé une seule fois dans refresh_virtual_stock().
|
def _cte_rates(self): # Retourne uniquement le CORPS de la CTE (sans WITH) return ''' currency_rates AS ( SELECT currency_id, rate FROM res_currency_rate ... ) '''
def _query_deposit(self): # SELECT pur — référence currency_rates sans le déclarer return ''' SELECT ..., price / NULLIF(currency_rates.rate, 0) AS value_eur FROM deposit_sale_contract_item dsi LEFT JOIN currency_rates ON currency_rates.currency_id = dsi.currency_id WHERE ... '''
@api.model def refresh_my_report(self): union_query = f''' WITH {self._cte_rates()} -- déclaré ici, une seule fois SELECT * FROM ( {self._query_stock()} -- SELECT pur UNION ALL {self._query_deposit()} -- SELECT pur, consomme currency_rates UNION ALL {self._query_purchase()} -- SELECT pur, consomme currency_rates ) t ''' # ... INSERT INTO staging ... |
5. Récapitulatif — checklist module custom Odoo 18
|
□ |
security/res_groups.xml déclaré en premier dans 'data' |
|
□ |
security/ir.model.access.csv présent pour chaque modèle custom |
|
□ |
views/menu.xml déclaré en DERNIER dans 'data' (après les vues+actions) |
|
□ |
Menu racine sans groups= (les sous-menus portent les groups=) |
|
□ |
Groupes et menus déclarés dans UN seul fichier chacun (pas de doublons) |
|
□ |
_auto = False sur tout modèle peuplé par SQL custom |
|
□ |
CREATE TABLE IF NOT EXISTS dans init() pour chaque modèle _auto=False |
|
□ |
translate=True → colonne JSONB (pas TEXT) dans le DDL |
|
□ |
Migration TEXT→JSONB dans init() si la table peut exister en ancien format |
|
□ |
CTEs déclarées UNE seule fois au niveau du union_query, pas dans les sous-requêtes |
|
□ |
Paramètres SQL bindés : ANY(%(companies)s) — jamais d'interpolation f-string |
|
□ |
uninstall_hook dans hooks.py + manifest pour DROP TABLE propre |
|
□ |
Refresh via staging swap — la table reste cohérente même si le refresh échoue |
WTF : related, store, compute, precompute
Impact des différentes options
related ex:
warehouse_id = fields.Many2one(
'stock.warehouse',
related='location_id.warehouse_id'
)
champ proxy (pas stocké par défaut)
Avantages
- Très simple
- Toujours à jour (pas de synchro à gérer)
Inconvénients (importants)
- JOIN SQL à chaque lecture
- Mauvais pour les listes / filtres / group_by
- Mauvais si utilisé massivement (tree view, report, compute…)
=> coût à la lecture
store=True sur un related
related='location_id.warehouse_id',
store=True
champ en colonne stockée
Avantages
- Lecture ultra rapide (pas de join)
- Indexable
- Filtrable / groupable efficacement
Inconvénients
- Coût à l’écriture (recompute)
- dépendances à maintenir
coût d’écriture
compute
warehouse_id = fields.Many2one(
'stock.warehouse',
compute='_compute_warehouse'
)
Calcul Python
Avantages
- Flexible (logique custom)
Inconvénients
- recalcul fréquent
- lent si non stocké
- difficile à optimiser
À éviter si un related suffit
store=True sur compute
compute='_compute_warehouse',
store=True
équivalent à un related store… mais en plus lent
Avantages
- Flexible
Inconvénients
- recompute coûteux
- dépendances parfois mal gérées
- moins optimisé qu’un related
Un related store est presque toujours meilleur qu’un compute store
precompute=True
permet de calculer avant insert (bulk create)
Avantages
- utile sur gros imports
- évite recompute après create
Inconvénients
- peu utile pour un related simple
- surtout utile pour compute complexes
warehouse_id = fields.Many2one(
'stock.warehouse',
related='location_id.warehouse_id',
store=True
)
Car Odoo fait :
record → location → warehouse
Sans store=True :
- JOIN location_id
- puis JOIN warehouse_id
- à CHAQUE lecture
Couteux sur list view / search / report
Avec store=True :
- valeur stockée directement
- aucun JOIN
- index possible
Les seuls cas où ce n’est PAS optimal
Si location_id change très souvent
→ beaucoup de recompute
Si champ très peu utilisé
→ stockage inutile
Optimisation possible:
Ajoute un index :
warehouse_id = fields.Many2one(
'stock.warehouse',
related='location_id.warehouse_id',
store=True,
index=True
)
énorme gain sur :
- search
- group_by
- read_group
Résumé clair
| Option | Lecture | Écriture | Cas d’usage |
|---|---|---|---|
| related | ❌ lent | ✅ rapide | petit volume |
| related + store | ✅ rapide | ⚠️ moyen | meilleur choix |
| compute | ❌ lent | ❌ lent | logique custom |
| compute + store | ✅ rapide | ❌ lourd | cas complexe |
| precompute | ⚡ create | — | bulk |
| Situation | Meilleur choix |
|---|
| Beaucoup de lecture | ✅ related + store=True |
| Beaucoup d’écriture, peu de lecture | ✅ related sans store |
| Gros volume + besoin perf lecture | ✅ store + batch recompute |
| Logique simple | ❌ éviter compute |
Droits d'accès vs Règles sur les enregistrements
Droits d'accès (ir.model.access)
C'est le filtre de niveau modèle. Ils répondent à la question : "cet utilisateur a-t-il le droit de toucher ce modèle ?"
Les 4 permissions :
- Lecture — peut lire des enregistrements
- Écriture — peut modifier des enregistrements existants
- Création — peut créer de nouveaux enregistrements
- Suppression — peut supprimer des enregistrements
Ces droits s'appliquent sur tout le modèle, sans distinction d'enregistrement.
Exemple concret avec easi_crm_visit :
group_crm_visit_user → crm.visit : lire=✓ écrire=✓ créer=✓ supprimer=✗
group_crm_visit_manager → crm.visit : lire=✓ écrire=✓ créer=✓ supprimer=✓
Un commercial (group_crm_visit_user) peut créer et modifier des visites, mais ne peut en supprimer aucune — pas même les siennes.
Règles sur les enregistrements (ir.rule)
C'est le filtre de niveau ligne. Elles répondent à : "parmi les enregistrements que cet utilisateur a le droit de toucher, lesquels peut-il voir ?"
Une règle est un domaine SQL appliqué dynamiquement à chaque requête.
Exemple avec easi_crm_visit :
group_crm_visit_user → domain: [('user_id', '=', user.id)]
group_crm_visit_manager → domain: [(1, '=', 1)] ← tout voirLe commercial peut lire/écrire des visites, mais uniquement les siennes (user_id = lui). Le manager voit tout.
Qui prime sur qui ?
Les droits d'accès sont vérifiés en premier. Si l'utilisateur n'a pas le droit de lire le modèle, Odoo lève une erreur immédiatement — les règles ne sont jamais évaluées.
Requête utilisateur
↓
[1] Droits d'accès → pas de lecture ? → AccessError (fin)
↓ OK
[2] Règles sur enregistrements → filtre les lignes retournées
↓
Résultat filtré
Cas particuliers importants
Plusieurs règles pour le même groupe → combinées en ET (AND). L'enregistrement doit satisfaire toutes les règles.
Règles sur des groupes différents pour le même utilisateur → combinées en OU (OR). L'enregistrement n'a besoin de satisfaire qu'une seule.
C'est pour ça que dans easi_crm_visit, un manager (qui est aussi implicitement dans group_crm_visit_user) voit tout : ses deux règles sont combinées en OR → user_id = lui OR 1=1 → tout passe.
Administrateur (base.group_system) → bypass total des droits d'accès ET des règles. C'est pour ça qu'un vrai admin ne peut jamais être bloqué par un ir.rule.
Tableau récapitulatif
| Droits d'accès | Règles enregistrements | |
|---|---|---|
| Niveau | Modèle entier | Ligne par ligne |
| Question | "Peut-il toucher ce modèle ?" | "Quelles lignes peut-il voir ?" |
| Effet si bloqué | AccessError | Enregistrement invisible (ou vide) |
| Combinaison | Par groupe (indépendant) | AND (même groupe) / OR (groupes différents) |
| Admin bypass | Oui | Oui |
| Priorité | 1er | 2e |
En pratique : les droits d'accès définissent ce qu'on peut faire, les règles définissent sur quoi on peut le faire. Un commercial avec écriture sur crm.visit mais une règle user_id = lui peut modifier des visites — uniquement les siennes.