Se rendre au contenu

bus.bus + WebSocket temps réel en Odoo 19

Saison « Dépassement tech v19 » · Article 4/5
26 avril 2026 par
bus.bus + WebSocket temps réel en Odoo 19
B.Mustapha

Saison « Dépassement tech v19 » · Article 4/5

bus.bus + WebSocket temps réel — rafraîchir un widget OWL sans F5

De l'override write() Python à la pastille qui change de couleur dans l'onglet voisin en moins de 500 ms — le cycle complet _sendone → PostgreSQL NOTIFY → WebSocket → bus_service → record.load() appliqué au widget sla_badge du T26.

~12 minutes de lecture · niveau avancé · parcours Web/UI

Widget sla_badge Odoo 19 après push bus.bus temps réel — pastille orange 'Proche échéance' rafraîchie sans F5
Objectif de l'article — obtenir ce changement de pastille sans action utilisateur sur l'onglet affiché. Un autre onglet a modifié sla_hours, le serveur a poussé un événement bus, et le widget OWL s'est rechargé tout seul.

Ce que tu vas apprendre

Pousser côté serveur

Émettre un événement depuis un write() override avec env['bus.bus']._sendone(channel, type, payload) — et comprendre pourquoi la magie arrive au postcommit.

Autoriser le canal

Étendre ir.websocket._build_bus_channel_list pour whitelister un canal string — sans cette ACL, l'addChannel client est silencieusement ignoré.

Écouter côté OWL

Utiliser useService("bus_service") avec subscribe() / unsubscribe() dans les hooks onMounted / onWillUnmount, et déclencher this.props.record.load().

1. Pourquoi bus.bus plutôt que du polling HTTP ?

La première question que pose tout débutant : « pourquoi ne pas juste faire un setInterval(() => record.load(), 5000) et terminer ? ». Trois raisons pragmatiques :

  • Coût. 1 000 utilisateurs qui interrogent le serveur toutes les 5 s = 200 requêtes ORM/s permanentes, même si rien n'a changé. En v19 avec bus.bus, le serveur ne parle que quand il a vraiment quelque chose à dire.
  • Latence. Un polling de 5 s affiche la mise à jour en moyenne 2,5 s après le changement. Avec WebSocket, c'est < 500 ms dans un réseau décent — le même ordre de grandeur qu'un clic.
  • Intégrité. bus.bus route via PostgreSQL NOTIFY → un seul mécanisme, cohérent pour tous les workers Odoo, y compris le multi-process gunicorn.

Odoo 19 expose tout le nécessaire dans odoo/addons/bus/. Côté Python : bus.bus._sendone (API bas niveau) et bus.listener.mixin._bus_send (API haute, record-scoped). Côté JS : le service bus_service gère un SharedWorker qui partage une unique WebSocket entre tous les onglets d'un même navigateur.

2. Le cycle complet en un coup d'œil

Ce diagramme de séquence suit un écrit Python jusqu'au re-render OWL. Les étapes marquées precommit et postcommit sont l'orchestration par laquelle bus.bus garantit qu'un onglet abonné ne reçoit jamais une notification avant que la transaction soit effectivement committée.

Diagramme séquence bus.bus + WebSocket — 11 acteurs de write() Python jusqu'au re-render OWL
Cycle complet — du write() Python au re-render OWL sans F5 — en 11 acteurs et 15 échanges. Agrandir (SVG)

Trois points importants :

  • Le tuple (channel, notification_type, payload) est sérialisé en JSON dans la table bus_bus au precommit.
  • Le postcommit envoie un pg_notify('imbus', payload) — cela réveille le thread ImDispatch (le dispatcher interne d'Odoo chargé de router les notifications bus vers les sockets ouvertes) qui relaie aux WebSockets.
  • Le SharedWorker bus_service (un par navigateur, pas par onglet) rebroadcast via un EventBus OWL — chaque widget abonné reçoit son callback.

3. Étape 1 — Pousser depuis write()

Le champ sla_status est computed/stored — il dépend de sla_deadline, lui-même fonction de sla_hours et create_date. Autrement dit, un write({'sla_hours': 1}) peut déclencher indirectement un changement de sla_status. Pour détecter ce changement, on prend un snapshot avant, on appelle super().write(vals), puis on compare après.

# addons/odooskills_helpdesk/models/helpdesk_ticket.py (extrait T27)
def write(self, vals):
    # ...logique T15 (protection d'état) inchangée...

    # T27 — snapshot sla_status AVANT écriture pour détecter les changements
    pre_status = {rec.id: rec.sla_status for rec in self}

    result = super().write(vals)

    # T27 — push bus.bus pour chaque ticket dont sla_status a changé
    changed = self.filtered(lambda r: r.sla_status != pre_status.get(r.id))
    for ticket in changed:
        self.env['bus.bus']._sendone(
            'odooskills.sla',
            'sla_status_changed',
            {
                'id': ticket.id,
                'reference': ticket.reference or '',
                'new_status': ticket.sla_status,
                'name': ticket.name,
            },
        )

    return result

Trois détails qui ne sautent pas aux yeux :

  • Le snapshot lit rec.sla_status — donc déclenche potentiellement un premier calcul si le champ n'était pas encore matérialisé dans le cache ORM. C'est volontaire : on veut une base fiable de comparaison.
  • _sendone ne fait pas l'insertion immédiatement ; il pousse la ligne dans self.env.cr.precommit.data. Tout est écrit dans bus_bus en un batch au commit — performant.
  • Si la transaction rollback, la ligne n'est jamais insérée : zéro notification fantôme à reconcilier. Exactly-once par construction.

4. Étape 2 — Whitelister le canal

Le client (bus_service.addChannel("odooskills.sla")) ne peut pas s'abonner librement à un canal arbitraire : cela ouvrirait une fuite d'info évidente. Odoo 19 force chaque canal à passer par un filtre côté serveur — ir.websocket._build_bus_channel_list. On ajoute le nôtre via un _inherit, pattern copié de odoo/addons/im_livechat :

# addons/odooskills_helpdesk/models/ir_websocket.py
from odoo import models


class IrWebsocket(models.AbstractModel):
    _inherit = 'ir.websocket'

    def _build_bus_channel_list(self, channels):
        """Ajoute le canal 'odooskills.sla' pour tout user authentifié."""
        channels = list(channels)  # ne pas altérer la liste originale

        if self.env.user and not self.env.user._is_public():
            if 'odooskills.sla' not in channels:
                channels.append('odooskills.sla')

        return super()._build_bus_channel_list(channels)

La logique est permissive : tout utilisateur authentifié écoute. En production sur un vrai back-office, il est plus sain de restreindre par groupe, par exemple self.env.user.has_group('base.group_user'), ou mieux par groupe helpdesk dédié.

À quel moment _build_bus_channel_list est-il appelé ? À chaque subscription WebSocket — typiquement au chargement de la page, et à chaque addChannel client. Le client envoie la liste qu'il demande, le serveur la réécrit selon ses règles, et c'est cette liste filtrée qui est enregistrée pour ce socket.

5. Étape 3 — Abonner le widget OWL

Le widget SlaBadgeField (T26) devient réactif. On ajoute trois imports OWL, un setup(), et deux callbacks de cycle de vie :

// addons/odooskills_helpdesk/static/src/views/fields/sla_badge/sla_badge.js (extrait T27)
/** @odoo-module **/
import { Component, onMounted, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";

export class SlaBadgeField extends Component {
    static template = "odooskills_helpdesk.SlaBadge";
    static props = {
        ...standardFieldProps,
        icon: { type: Boolean, optional: true },
        compact: { type: Boolean, optional: true },
        live: { type: Boolean, optional: true },
    };
    static defaultProps = { icon: true, compact: false, live: true };

    setup() {
        if (this.props.live) {
            this.busService = useService("bus_service");
            this._onSlaChanged = this._onSlaChanged.bind(this);

            onMounted(() => {
                this.busService.addChannel("odooskills.sla");
                this.busService.subscribe("sla_status_changed", this._onSlaChanged);
            });

            onWillUnmount(() => {
                this.busService.unsubscribe("sla_status_changed", this._onSlaChanged);
            });
        }
    }

    _onSlaChanged(payload) {
        if (payload && payload.id === this.props.record.resId) {
            this.props.record.load();
        }
    }

    // getters `value`, `label`, `iconClass`, `pillClass` inchangés — voir T26.
}

Quatre réflexes d'ingénieur OWL à internaliser :

  • useService doit être appelé dans setup(), pas dans onMounted. Les hooks OWL doivent voir le composant dans son contexte de création.
  • Toujours désabonner dans onWillUnmount — sinon, naviguer entre tickets laisse des callbacks orphelins qui s'empilent et tirent sur la mémoire.
  • Binder la référence (.bind(this)) avant de la passer à subscribe et à unsubscribe — sinon, unsubscribe cherche une référence qui n'est plus la même et rate silencieusement.
  • record.load() recharge ce record ; les props OWL étant réactives, le template se re-rend automatiquement. Pas besoin de render ou trigger explicite.

6. Étape 4 — Tester ce qui ne devrait jamais régresser

Tester un push bus sans démarrer un vrai WebSocket demande un mock. On patche bus.bus._sendone et on vérifie les appels. Extrait d'un test sur cinq du fichier tests/test_sla_bus_push.py :

from unittest.mock import patch
from odoo.tests.common import TransactionCase, tagged


@tagged('post_install', '-at_install', 'odooskills_t27')
class TestSlaBusPush(TransactionCase):

    def test_write_no_sla_change_no_push(self):
        """Écriture d'un champ sans effet sur sla_status → aucun push bus."""
        ticket = self.env['helpdesk.ticket'].with_context(skip_mail=True).create({
            'name': 'T27 no-push', 'sla_hours': 48,
        })
        with patch.object(type(self.env['bus.bus']), '_sendone') as mock_send:
            ticket.write({'description': 'Mise à jour description uniquement'})

        sla_calls = [c for c in mock_send.call_args_list
                     if c.args and c.args[0] == 'odooskills.sla']
        self.assertFalse(sla_calls, "Aucun push bus ne devait être émis")

La technique — patcher la classe, pas l'instance — est décrite plus en détail dans l'article T24 sur les tests. Au total, la suite T27 ajoute cinq tests : push effectif, absence de push pour un write non-SLA, présence du canal pour user authentifié, non-duplication du canal, et un cas de double écriture consécutive.

Pour lancer cette suite uniquement, utilise le tag odooskills_t27 :

venv/bin/python odoo/odoo-bin \
  --addons-path=odoo/addons,addons/odooskills-blog/modules \
  -d vs19_odooskills_test \
  -u odooskills_helpdesk \
  --test-enable --test-tags=odooskills_t27 \
  --stop-after-init

7. Démo — voir le widget se recharger sans F5

État initial — onglet A ouvre un ticket avec sla_hours=48, pastille verte Dans les temps :

Ticket helpdesk Odoo 19 — widget sla_badge en état initial, pastille verte 'Dans les temps' avec SLA 48 heures
Onglet A — ticket HLP/2026/00005, badge initial vert.

L'onglet B, dans une autre session navigateur, modifie sla_hours de 48 à 1. Le write() Python recompute sla_status et pousse le message bus. Trois secondes plus tard, l'onglet A — sans aucune action utilisateur — affiche la pastille orange Proche échéance :

Widget sla_badge Odoo 19 — pastille orange 'Proche échéance' après push bus.bus temps réel, sans F5
Onglet A — même ticket, même URL, badge passé à orange après le push bus. Aucun rafraîchissement manuel.

Sur un VPS correctement configuré, le délai observé entre le write() sur l'onglet B et le changement visible sur l'onglet A tourne autour de 300–500 ms. La latence est dominée par la sérialisation JSON et le tour du SharedWorker.

Les cinq pièges qui font perdre une après-midi

  • Oublier _build_bus_channel_list — le client appelle addChannel(), le navigateur fait bien son boulot, mais le serveur ignore silencieusement la demande. Zéro log d'erreur, zéro message reçu. Toujours vérifier le filtre côté serveur.
  • Channel string guessable — la doc v19 (odoo/addons/bus/models/bus.py, méthode _sendone) rappelle que pour un canal sensible, il faut utiliser une chaîne aléatoire (token). Pour un canal métier partagé comme odooskills.sla, c'est OK car on filtre déjà côté ACL.
  • Tester sans comprendre pre/post-commit — en test TransactionCase, la transaction n'est jamais committée ; donc _sendone n'écrit jamais dans bus_bus. Pour vérifier un push, on patche _sendone et on compte les appels, pas la table.
  • Ne pas binder la callback — passer this._onSlaChanged à subscribe sans .bind(this) fonctionne au premier coup (grâce à la wrap de subscribe) mais le unsubscribe suivant rate car la Map interne cherche la référence exacte.
  • Oublier onWillUnmount — chaque navigation entre tickets crée un nouveau widget, qui s'abonne à nouveau. Dix navigations = dix callbacks qui tournent, dix record.load() à chaque notification. La fuite mémoire est subtile mais réelle.

En v19 — bus.listener.mixin existe aussi

À côté de l'API bas niveau _sendone utilisée ici, Odoo 19 expose bus.listener.mixin dans odoo/addons/bus/models/bus_listener_mixin.py. La méthode _bus_send(notification_type, message) pousse sur un canal indexé par record — idéal pour notifier les abonnés d'un chat, d'un thread ou d'un document précis.

Pour cet article on a choisi le canal string global 'odooskills.sla' pour sa simplicité pédagogique : une ligne serveur, une ligne client, une démo spectaculaire. Pour un use-case réel avec plusieurs milliers de tickets actifs, passer au mixin record-scoped ramène le trafic à ce que chaque onglet a vraiment besoin d'entendre.

À retenir

  • Trois fichiers suffisent pour passer d'un widget statique à un widget réactif : un override write(), un _build_bus_channel_list, et trois hooks OWL.
  • Snapshot avant / après : pour un champ computed/stored, capturer l'état avant super().write() reste la méthode la plus fiable pour détecter un changement.
  • bus.bus = precommit + postcommit : le push est exactly-once par construction. Rollback = aucune notification partie.
  • _build_bus_channel_list est obligatoire : sans cette ACL, l'addChannel client est un no-op silencieux.
  • SharedWorker : le bus_service partage une WebSocket unique entre tous les onglets d'un navigateur. record.load() rafraîchit chaque widget individuellement.
  • Cinq pièges à connaître : ACL oubliée, bind manquant, unsubscribe oublié, test sans mock du commit, canal string trop devinable.

Voir aussi — Parcours Web/UI

T26 — OWL composants custom

Le widget sla_badge statique dont cet article rend le comportement réactif — point de départ indispensable.

T06 — Vues form/list/kanban

Le XPath d'héritage qui injecte widget="sla_badge" dans la form — la base de tout override de vue.

T09 — Héritage XPath

Comment <attribute name="decoration-X"/> en self-closing supprime un attribut hérité — pattern v19 utilisé par T26.

Articles complémentaires

T24 — Tests automatisés

Le pattern de mock patch.object(type(…), '_sendone') vient directement du playbook de tests de cette série.

T25 — Migration 18 → 19

Les breaking changes v18 → v19 à anticiper avant d'ajouter un pattern bus.bus custom à une base existante.

T15 — create/write/unlink

Le pattern d'override write() auquel cet article greffe le push bus — le snapshot avant/après est une application directe.

Télécharge le Guide Technique Odoo

Module fil rouge, 20+ articles techniques, environnements de dev complet — PDF à télécharger.

Télécharger le guide
OWL composants custom Odoo 19
Saison « Dépassement tech v19 » · Article 3/5