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().
Prérequis
- Odoo 19 CE installé (T01) avec le daemon
bodoo19.serviceactif — le longpolling WebSocket est géré nativement. - Module
odooskills_helpdeskv19.0.1.17.0 installé (widgetsla_badgedu T26). - Connaissance des surcharges CRUD (T15) et des widgets OWL (T26).
- Un navigateur moderne — Firefox, Chrome, Edge. Le bus passe par un SharedWorker.
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.busroute viaPostgreSQL 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.
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 tablebus_busau precommit. - Le postcommit envoie un
pg_notify('imbus', payload)— cela réveille le threadImDispatch(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 unEventBusOWL — 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. _sendonene fait pas l'insertion immédiatement ; il pousse la ligne dansself.env.cr.precommit.data. Tout est écrit dansbus_busen 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 :
useServicedoit être appelé danssetup(), pas dansonMounted. 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 àsubscribeet àunsubscribe— sinon,unsubscribecherche 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 derenderoutriggerexplicite.
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 :
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 :
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 appelleaddChannel(), 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é commeodooskills.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_sendonen'écrit jamais dansbus_bus. Pour vérifier un push, on patche_sendoneet on compte les appels, pas la table. - Ne pas binder la callback — passer
this._onSlaChangedàsubscribesans.bind(this)fonctionne au premier coup (grâce à la wrap desubscribe) mais leunsubscribesuivant 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, dixrecord.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'
addChannelclient est un no-op silencieux. - SharedWorker : le
bus_servicepartage 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