⚙️ TIPS dev
⚙️

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.
Many2many : 
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')
On a la class product template, le commodel  et la table relationnelle avec la définittion de la source et de la destination. 
Many2many field; the value of such a field is the recordset.
The attributes relation, column1 and column2 are optional. If not given, names are automatically generated from model names, provided model_name and comodel_name are different!
Note that having several fields with implicit relation parameters on a given model with the same comodel is not accepted by the ORM, since those field would use the same table. The ORM prevents two many2many fields to use the same relation parameters, except if
both fields use the same model, comodel, and relation parameters are explicit; or
at least one field belongs to a model with _auto = False.
Params:
comodel_name – name of the target model (string) mandatory except in the case of related or extended fields
relation – optional name of the table that stores the relation in the database
column1 – optional name of the column referring to "these" records in the table relation
column2 – optional name of the column referring to "those" records in the table relation


Attention en scss dans le manifest :
BundleUtilisé pour
web.assets_backendInterface backend
web.assets_frontendSite web
web.report_assets_commonRapports PDF / QWeb
Si assets_backend pour un rapport ... ça ne marchera pas :) 

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:


si on veut surcharger un flux avec du sudo; On ne peut pas appeler un super en sudo super().sudo() .... 
Donc si on veut passer un sudo à une class, il faut l'indiquer dans le constructeur: 
&nbsp;def _check_validity(self):

&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;if self.env.user == self.employee_id.leave_manager_id.leave_manager_id:

&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;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
Dans notre exemple, il faut utiliser
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

OptionLectureÉcritureCas d’usage
related❌ lent✅ rapidepetit volume
related + store✅ rapide⚠️ moyenmeilleur choix
compute❌ lent❌ lentlogique custom
compute + store✅ rapide❌ lourdcas complexe
precompute⚡ createbulk


SituationMeilleur 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 voir

Le 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èsRègles enregistrements
NiveauModèle entierLigne par ligne
Question"Peut-il toucher ce modèle ?""Quelles lignes peut-il voir ?"
Effet si bloquéAccessErrorEnregistrement invisible (ou vide)
CombinaisonPar groupe (indépendant)AND (même groupe) / OR (groupes différents)
Admin bypassOuiOui
Priorité1er2e

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.