19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

View file

@ -1,7 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import bus
from . import bus_presence
from . import bus_listener_mixin
from . import ir_attachment
from . import ir_http
from . import ir_model
from . import ir_websocket
from . import res_groups
from . import res_users
from . import res_users_settings
from . import res_partner

View file

@ -5,25 +5,28 @@ import json
import logging
import math
import os
import random
import selectors
import threading
import time
from psycopg2 import InterfaceError, sql
from psycopg2 import InterfaceError
from psycopg2.pool import PoolError
import odoo
from ..tools import orjson
from odoo import api, fields, models
from odoo.service.server import CommonServer
from odoo.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT
from odoo.tools import date_utils
from odoo.tools import json_default, SQL
from odoo.tools.constants import GC_UNLINK_LIMIT
from odoo.tools.misc import OrderedSet
_logger = logging.getLogger(__name__)
# longpolling timeout connection
TIMEOUT = 50
DEFAULT_GC_RETENTION_SECONDS = 60 * 60 * 24 # 24 hours
# custom function to call instead of NOTIFY postgresql command (opt-in)
ODOO_NOTIFY_FUNCTION = os.environ.get('ODOO_NOTIFY_FUNCTION')
# custom function to call instead of default PostgreSQL's `pg_notify`
ODOO_NOTIFY_FUNCTION = os.getenv('ODOO_NOTIFY_FUNCTION', 'pg_notify')
def get_notify_payload_max_length(default=8000):
@ -40,11 +43,11 @@ def get_notify_payload_max_length(default=8000):
NOTIFY_PAYLOAD_MAX_LENGTH = get_notify_payload_max_length()
#----------------------------------------------------------
# ---------------------------------------------------------
# Bus
#----------------------------------------------------------
# ---------------------------------------------------------
def json_dump(v):
return json.dumps(v, separators=(',', ':'), default=date_utils.json_default)
return json.dumps(v, separators=(',', ':'), default=json_default)
def hashable(key):
if isinstance(key, list):
@ -55,6 +58,8 @@ def hashable(key):
def channel_with_db(dbname, channel):
if isinstance(channel, models.Model):
return (dbname, channel._name, channel.id)
if isinstance(channel, tuple) and len(channel) == 2 and isinstance(channel[0], models.Model):
return (dbname, channel[0]._name, channel[0].id, channel[1])
if isinstance(channel, str):
return (dbname, channel)
return channel
@ -80,9 +85,9 @@ def get_notify_payloads(channels):
get_notify_payloads(channels[pivot:]))
class ImBus(models.Model):
class BusBus(models.Model):
_name = 'bus.bus'
_description = 'Communication Bus'
channel = fields.Char('Channel')
@ -90,75 +95,107 @@ class ImBus(models.Model):
@api.autovacuum
def _gc_messages(self):
timeout_ago = datetime.datetime.utcnow()-datetime.timedelta(seconds=TIMEOUT*2)
domain = [('create_date', '<', timeout_ago.strftime(DEFAULT_SERVER_DATETIME_FORMAT))]
records = self.search(domain, limit=models.GC_UNLINK_LIMIT)
if len(records) >= models.GC_UNLINK_LIMIT:
self.env.ref('base.autovacuum_job')._trigger()
return records.unlink()
gc_retention_seconds = int(
self.env["ir.config_parameter"]
.sudo()
.get_param("bus.gc_retention_seconds", DEFAULT_GC_RETENTION_SECONDS)
)
timeout_ago = fields.Datetime.now() - datetime.timedelta(seconds=gc_retention_seconds)
# Direct SQL to avoid ORM overhead; this way we can delete millions of rows quickly.
# This is a low-level table with no expected references, and doing this avoids
# the need to split or reschedule this GC job.
self.env.cr.execute("DELETE FROM bus_bus WHERE create_date < %s", (timeout_ago,))
@api.model
def _sendmany(self, notifications):
channels = set()
values = []
for target, notification_type, message in notifications:
channel = channel_with_db(self.env.cr.dbname, target)
channels.add(channel)
values.append({
'channel': json_dump(channel),
'message': json_dump({
'type': notification_type,
'payload': message,
})
})
self.sudo().create(values)
if channels:
def _sendone(self, target, notification_type, message):
"""Low-level method to send ``notification_type`` and ``message`` to ``target``.
Using ``_bus_send()`` from ``bus.listener.mixin`` is recommended for simplicity and
security.
When using ``_sendone`` directly, ``target`` (if str) should not be guessable by an
attacker.
"""
self._ensure_hooks()
channel = channel_with_db(self.env.cr.dbname, target)
self.env.cr.precommit.data["bus.bus.values"].append(
{
"channel": json_dump(channel),
"message": json_dump(
{
"type": notification_type,
"payload": message,
}
),
}
)
self.env.cr.postcommit.data["bus.bus.channels"].add(channel)
def _ensure_hooks(self):
if "bus.bus.values" not in self.env.cr.precommit.data:
self.env.cr.precommit.data["bus.bus.values"] = []
@self.env.cr.precommit.add
def create_bus():
self.sudo().create(self.env.cr.precommit.data.pop("bus.bus.values"))
if "bus.bus.channels" not in self.env.cr.postcommit.data:
self.env.cr.postcommit.data["bus.bus.channels"] = OrderedSet()
# We have to wait until the notifications are commited in database.
# When calling `NOTIFY imbus`, notifications will be fetched in the
# bus table. If the transaction is not commited yet, there will be
# nothing to fetch, and the websocket will return no notification.
@self.env.cr.postcommit.add
def notify():
with odoo.sql_db.db_connect('postgres').cursor() as cr:
if ODOO_NOTIFY_FUNCTION:
query = sql.SQL("SELECT {}('imbus', %s)").format(sql.Identifier(ODOO_NOTIFY_FUNCTION))
else:
query = "NOTIFY imbus, %s"
payloads = get_notify_payloads(list(channels))
if len(payloads) > 1:
_logger.info("The imbus notification payload was too large, "
"it's been split into %d payloads.", len(payloads))
payloads = get_notify_payloads(
list(self.env.cr.postcommit.data.pop("bus.bus.channels"))
)
if len(payloads) > 1:
_logger.info(
"The imbus notification payload was too large, it's been split into %d payloads.",
len(payloads),
)
with odoo.sql_db.db_connect("postgres").cursor() as cr:
for payload in payloads:
cr.execute(query, (payload,))
cr.execute(
SQL(
"SELECT %s('imbus', %s)",
SQL.identifier(ODOO_NOTIFY_FUNCTION),
payload,
)
)
@api.model
def _sendone(self, channel, notification_type, message):
self._sendmany([[channel, notification_type, message]])
@api.model
def _poll(self, channels, last=0):
def _poll(self, channels, last=0, ignore_ids=None):
# first poll return the notification in the 'buffer'
if last == 0:
timeout_ago = datetime.datetime.utcnow()-datetime.timedelta(seconds=TIMEOUT)
domain = [('create_date', '>', timeout_ago.strftime(DEFAULT_SERVER_DATETIME_FORMAT))]
timeout_ago = fields.Datetime.now() - datetime.timedelta(seconds=TIMEOUT)
domain = [('create_date', '>', timeout_ago)]
else: # else returns the unread notifications
domain = [('id', '>', last)]
if ignore_ids:
domain.append(("id", "not in", ignore_ids))
channels = [json_dump(channel_with_db(self.env.cr.dbname, c)) for c in channels]
domain.append(('channel', 'in', channels))
notifications = self.sudo().search_read(domain)
notifications = self.sudo().search_read(domain, ["message"])
# list of notification to return
result = []
for notif in notifications:
result.append({
'id': notif['id'],
'message': json.loads(notif['message']),
'message': orjson.loads(notif['message']),
})
return result
def _bus_last_id(self):
last = self.env['bus.bus'].search([], order='id desc', limit=1)
return last.id if last else 0
#----------------------------------------------------------
# ---------------------------------------------------------
# Dispatcher
#----------------------------------------------------------
# ---------------------------------------------------------
class BusSubscription:
def __init__(self, channels, last):
@ -211,7 +248,7 @@ class ImDispatch(threading.Thread):
conn.poll()
channels = []
while conn.notifies:
channels.extend(json.loads(conn.notifies.pop().payload))
channels.extend(orjson.loads(conn.notifies.pop().payload))
# relay notifications to websockets that have
# subscribed to the corresponding channels.
websockets = set()
@ -225,7 +262,7 @@ class ImDispatch(threading.Thread):
try:
self.loop()
except Exception as exc:
if isinstance(exc, InterfaceError) and stop_event.is_set():
if isinstance(exc, (InterfaceError, PoolError)) and stop_event.is_set():
continue
_logger.exception("Bus.loop error, sleep and retry")
time.sleep(TIMEOUT)

View file

@ -0,0 +1,30 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class BusListenerMixin(models.AbstractModel):
"""Allow sending messages related to the current model via as a bus.bus channel.
The model needs to be allowed as a valid channel for the bus in `_build_bus_channel_list`.
"""
_name = 'bus.listener.mixin'
_description = "Can send messages via bus.bus"
def _bus_send(self, notification_type, message, /, *, subchannel=None):
"""Send a notification to the webclient."""
for record in self:
main_channel = record
while (new_main_channel := main_channel._bus_channel()) != main_channel:
main_channel = new_main_channel
assert isinstance(main_channel, models.Model)
if not main_channel:
continue
main_channel.ensure_one()
channel = main_channel if subchannel is None else (main_channel, subchannel)
# _sendone: channel is safe (record or tuple with record)
self.env["bus.bus"]._sendone(channel, notification_type, message)
def _bus_channel(self):
return self

View file

@ -1,72 +0,0 @@
# -*- coding: utf-8 -*-
import datetime
import time
from psycopg2 import OperationalError
from odoo import api, fields, models
from odoo import tools
from odoo.service.model import PG_CONCURRENCY_ERRORS_TO_RETRY
from odoo.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT
UPDATE_PRESENCE_DELAY = 60
DISCONNECTION_TIMER = UPDATE_PRESENCE_DELAY + 5
AWAY_TIMER = 1800 # 30 minutes
class BusPresence(models.Model):
""" User Presence
Its status is 'online', 'away' or 'offline'. This model should be a one2one, but is not
attached to res_users to avoid database concurrence errors. Since the 'update' method is executed
at each poll, if the user have multiple opened tabs, concurrence errors can happend, but are 'muted-logged'.
"""
_name = 'bus.presence'
_description = 'User Presence'
_log_access = False
user_id = fields.Many2one('res.users', 'Users', ondelete='cascade')
last_poll = fields.Datetime('Last Poll', default=lambda self: fields.Datetime.now())
last_presence = fields.Datetime('Last Presence', default=lambda self: fields.Datetime.now())
status = fields.Selection([('online', 'Online'), ('away', 'Away'), ('offline', 'Offline')], 'IM Status', default='offline')
def init(self):
self.env.cr.execute("CREATE UNIQUE INDEX IF NOT EXISTS bus_presence_user_unique ON %s (user_id) WHERE user_id IS NOT NULL" % self._table)
@api.model
def update(self, inactivity_period, identity_field, identity_value):
""" Updates the last_poll and last_presence of the current user
:param inactivity_period: duration in milliseconds
"""
# This method is called in method _poll() and cursor is closed right
# after; see bus/controllers/main.py.
try:
# Hide transaction serialization errors, which can be ignored, the presence update is not essential
# The errors are supposed from presence.write(...) call only
with tools.mute_logger('odoo.sql_db'):
self._update(inactivity_period=inactivity_period, identity_field=identity_field, identity_value=identity_value)
# commit on success
self.env.cr.commit()
except OperationalError as e:
if e.pgcode in PG_CONCURRENCY_ERRORS_TO_RETRY:
# ignore concurrency error
return self.env.cr.rollback()
raise
@api.model
def _update(self, inactivity_period, identity_field, identity_value):
presence = self.search([(identity_field, '=', identity_value)], limit=1)
# compute last_presence timestamp
last_presence = datetime.datetime.now() - datetime.timedelta(milliseconds=inactivity_period)
values = {
'last_poll': time.strftime(DEFAULT_SERVER_DATETIME_FORMAT),
}
# update the presence or a create a new one
if not presence: # create a new presence for the user
values[identity_field] = identity_value
values['last_presence'] = last_presence
self.create(values)
else: # update the last_presence if necessary, and write values
if presence.last_presence < last_presence:
values['last_presence'] = last_presence
presence.write(values)

View file

@ -0,0 +1,11 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class IrAttachment(models.Model):
_name = 'ir.attachment'
_inherit = ["ir.attachment", "bus.listener.mixin"]
def _bus_channel(self):
return self.env.user

View file

@ -0,0 +1,19 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
from ..websocket import WebsocketConnectionHandler
class IrHttp(models.AbstractModel):
_inherit = "ir.http"
@api.model
def get_frontend_session_info(self):
session_info = super().get_frontend_session_info()
session_info["websocket_worker_version"] = WebsocketConnectionHandler._VERSION
return session_info
def session_info(self):
session_info = super().session_info()
session_info["websocket_worker_version"] = WebsocketConnectionHandler._VERSION
return session_info

View file

@ -7,14 +7,17 @@ class IrModel(models.Model):
_inherit = 'ir.model'
def _get_model_definitions(self, model_names_to_fetch):
fields_by_model_names = {}
model_definitions = {}
for model_name in model_names_to_fetch:
model = self.env[model_name]
# get fields, relational fields are kept only if the related model is in model_names_to_fetch
fields_data_by_fname = {
fname: field_data
for fname, field_data in model.fields_get(
attributes=['name', 'type', 'relation', 'required', 'readonly', 'selection', 'string']
attributes={
'name', 'type', 'relation', 'required', 'readonly', 'selection',
'string', 'definition_record', 'definition_record_field', 'model_field',
},
).items()
if not field_data.get('relation') or field_data['relation'] in model_names_to_fetch
}
@ -23,10 +26,11 @@ class IrModel(models.Model):
inverse_fields = [
field for field in model.pool.field_inverses[model._fields[fname]]
if field.model_name in model_names_to_fetch
and model.env[field.model_name]._has_field_access(field, 'read')
]
if inverse_fields:
field_data['inverse_fname_by_model_name'] = {field.model_name: field.name for field in inverse_fields}
if field_data['type'] == 'many2one_reference':
field_data['model_name_ref_fname'] = model._fields[fname].model_field
fields_by_model_names[model_name] = fields_data_by_fname
return fields_by_model_names
model_definitions[model_name] = {"fields": fields_data_by_fname}
return model_definitions

View file

@ -1,5 +1,8 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.http import SessionExpiredException
from odoo.http import request, SessionExpiredException
from odoo.tools.misc import OrderedSet
from odoo.service import security
from ..models.bus import dispatch
from ..websocket import wsrequest
@ -9,15 +12,6 @@ class IrWebsocket(models.AbstractModel):
_name = 'ir.websocket'
_description = 'websocket message handling'
def _get_im_status(self, im_status_ids_by_model):
im_status = {}
if 'res.partner' in im_status_ids_by_model:
im_status['partners'] = self.env['res.partner'].with_context(active_test=False).search_read(
[('id', 'in', im_status_ids_by_model['res.partner'])],
['im_status']
)
return im_status
def _build_bus_channel_list(self, channels):
"""
Return the list of channels to subscribe to. Override this
@ -26,33 +20,62 @@ class IrWebsocket(models.AbstractModel):
:param channels: The channel list sent by the client.
"""
req = request or wsrequest
channels.append('broadcast')
channels.extend(self.env.user.all_group_ids)
if req.session.uid:
channels.append(self.env.user.partner_id)
return channels
def _subscribe(self, data):
if not all(isinstance(c, str) for c in data['channels']):
raise ValueError("bus.Bus only string channels are allowed.")
last_known_notification_id = self.env['bus.bus'].sudo().search([], limit=1, order='id desc').id or 0
if data['last'] > last_known_notification_id:
data['last'] = 0
channels = set(self._build_bus_channel_list(data['channels']))
dispatch.subscribe(channels, data['last'], self.env.registry.db_name, wsrequest.ws)
def _serve_ir_websocket(self, event_name, data):
"""Process websocket events.
Modules can override this method to handle their own events. But overriding this method is
not recommended and should be carefully considered, because at the time of writing this
message, Odoo.sh does not use this method. Each new event should have a corresponding http
route and Odoo.sh infrastructure should be updated to reflect it. On top of that, the
event processing is very time, ressource and error sensitive."""
def _update_bus_presence(self, inactivity_period, im_status_ids_by_model):
if self.env.user and not self.env.user._is_public():
self.env['bus.presence'].update(
inactivity_period,
identity_field='user_id',
identity_value=self.env.uid
)
im_status_notification = self._get_im_status(im_status_ids_by_model)
if im_status_notification:
self.env['bus.bus']._sendone(self.env.user.partner_id, 'bus/im_status', im_status_notification)
def _prepare_subscribe_data(self, channels, last):
"""
Parse the data sent by the client and return the list of channels
and the last known notification id. This will be used both by the
websocket controller and the websocket request class when the
`subscribe` event is received.
:param typing.List[str] channels: List of channels to subscribe to sent
by the client.
:param int last: Last known notification sent by the client.
:return:
A dict containing the following keys:
- channels (set of str): The list of channels to subscribe to.
- last (int): The last known notification id.
:raise ValueError: If the list of channels is not a list of strings.
"""
if not all(isinstance(c, str) for c in channels):
raise ValueError("bus.Bus only string channels are allowed.")
# sudo - bus.bus: reading non-sensitive last bus id.
last = 0 if last > self.env["bus.bus"].sudo()._bus_last_id() else last
return {"channels": OrderedSet(self._build_bus_channel_list(list(channels))), "last": last}
def _after_subscribe_data(self, data):
"""Function invoked after subscribe data have been processed.
Modules can override this method to add custom behavior."""
def _subscribe(self, og_data):
data = self._prepare_subscribe_data(og_data["channels"], og_data["last"])
dispatch.subscribe(data["channels"], data["last"], self.env.registry.db_name, wsrequest.ws)
self._after_subscribe_data(data)
def _on_websocket_closed(self, cookies):
"""Function invoked upon WebSocket termination.
Modules can override this method to add custom behavior."""
@classmethod
def _authenticate(cls):
if wsrequest.session.uid is not None:
if not security.check_session(wsrequest.session, wsrequest.env):
if not security.check_session(wsrequest.session, wsrequest.env, wsrequest):
wsrequest.session.logout(keep_db=True)
raise SessionExpiredException()
else:

View file

@ -0,0 +1,8 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class ResGroups(models.Model):
_name = 'res.groups'
_inherit = ["res.groups", "bus.listener.mixin"]

View file

@ -1,29 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.addons.bus.models.bus_presence import AWAY_TIMER
from odoo.addons.bus.models.bus_presence import DISCONNECTION_TIMER
from odoo import models
class ResPartner(models.Model):
_inherit = 'res.partner'
im_status = fields.Char('IM Status', compute='_compute_im_status')
def _compute_im_status(self):
self.env.cr.execute("""
SELECT
U.partner_id as id,
CASE WHEN max(B.last_poll) IS NULL THEN 'offline'
WHEN age(now() AT TIME ZONE 'UTC', max(B.last_poll)) > interval %s THEN 'offline'
WHEN age(now() AT TIME ZONE 'UTC', max(B.last_presence)) > interval %s THEN 'away'
ELSE 'online'
END as status
FROM bus_presence B
RIGHT JOIN res_users U ON B.user_id = U.id
WHERE U.partner_id IN %s AND U.active = 't'
GROUP BY U.partner_id
""", ("%s seconds" % DISCONNECTION_TIMER, "%s seconds" % AWAY_TIMER, tuple(self.ids)))
res = dict(((status['id'], status['status']) for status in self.env.cr.dictfetchall()))
for partner in self:
partner.im_status = res.get(partner.id, 'im_partner') # if not found, it is a partner, useful to avoid to refresh status in js
_name = "res.partner"
_inherit = ["res.partner", "bus.listener.mixin"]

View file

@ -1,28 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.addons.bus.models.bus_presence import AWAY_TIMER
from odoo.addons.bus.models.bus_presence import DISCONNECTION_TIMER
from odoo import models
class ResUsers(models.Model):
_name = "res.users"
_inherit = ["res.users", "bus.listener.mixin"]
_inherit = "res.users"
im_status = fields.Char('IM Status', compute='_compute_im_status')
def _compute_im_status(self):
""" Compute the im_status of the users """
self.env.cr.execute("""
SELECT
user_id as id,
CASE WHEN age(now() AT TIME ZONE 'UTC', last_poll) > interval %s THEN 'offline'
WHEN age(now() AT TIME ZONE 'UTC', last_presence) > interval %s THEN 'away'
ELSE 'online'
END as status
FROM bus_presence
WHERE user_id IN %s
""", ("%s seconds" % DISCONNECTION_TIMER, "%s seconds" % AWAY_TIMER, tuple(self.ids)))
res = dict(((status['id'], status['status']) for status in self.env.cr.dictfetchall()))
for user in self:
user.im_status = res.get(user.id, 'offline')
def _bus_channel(self):
return self.partner_id

View file

@ -0,0 +1,11 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class ResUsersSettings(models.Model):
_name = 'res.users.settings'
_inherit = ["res.users.settings", "bus.listener.mixin"]
def _bus_channel(self):
return self.user_id