mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-23 02:32:04 +02:00
Initial commit: Core packages
This commit is contained in:
commit
12c29a983b
9512 changed files with 8379910 additions and 0 deletions
7
odoo-bringout-oca-ocb-bus/bus/models/__init__.py
Normal file
7
odoo-bringout-oca-ocb-bus/bus/models/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import bus
|
||||
from . import bus_presence
|
||||
from . import ir_model
|
||||
from . import ir_websocket
|
||||
from . import res_users
|
||||
from . import res_partner
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
238
odoo-bringout-oca-ocb-bus/bus/models/bus.py
Normal file
238
odoo-bringout-oca-ocb-bus/bus/models/bus.py
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import contextlib
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import selectors
|
||||
import threading
|
||||
import time
|
||||
from psycopg2 import InterfaceError, sql
|
||||
|
||||
import odoo
|
||||
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
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# longpolling timeout connection
|
||||
TIMEOUT = 50
|
||||
|
||||
# custom function to call instead of NOTIFY postgresql command (opt-in)
|
||||
ODOO_NOTIFY_FUNCTION = os.environ.get('ODOO_NOTIFY_FUNCTION')
|
||||
|
||||
|
||||
def get_notify_payload_max_length(default=8000):
|
||||
try:
|
||||
length = int(os.environ.get('ODOO_NOTIFY_PAYLOAD_MAX_LENGTH', default))
|
||||
except ValueError:
|
||||
_logger.warning("ODOO_NOTIFY_PAYLOAD_MAX_LENGTH has to be an integer, "
|
||||
"defaulting to %d bytes", default)
|
||||
length = default
|
||||
return length
|
||||
|
||||
|
||||
# max length in bytes for the NOTIFY query payload
|
||||
NOTIFY_PAYLOAD_MAX_LENGTH = get_notify_payload_max_length()
|
||||
|
||||
|
||||
#----------------------------------------------------------
|
||||
# Bus
|
||||
#----------------------------------------------------------
|
||||
def json_dump(v):
|
||||
return json.dumps(v, separators=(',', ':'), default=date_utils.json_default)
|
||||
|
||||
def hashable(key):
|
||||
if isinstance(key, list):
|
||||
key = tuple(key)
|
||||
return key
|
||||
|
||||
|
||||
def channel_with_db(dbname, channel):
|
||||
if isinstance(channel, models.Model):
|
||||
return (dbname, channel._name, channel.id)
|
||||
if isinstance(channel, str):
|
||||
return (dbname, channel)
|
||||
return channel
|
||||
|
||||
|
||||
def get_notify_payloads(channels):
|
||||
"""
|
||||
Generates the json payloads for the imbus NOTIFY.
|
||||
Splits recursively payloads that are too large.
|
||||
|
||||
:param list channels:
|
||||
:return: list of payloads of json dumps
|
||||
:rtype: list[str]
|
||||
"""
|
||||
if not channels:
|
||||
return []
|
||||
payload = json_dump(channels)
|
||||
if len(channels) == 1 or len(payload.encode()) < NOTIFY_PAYLOAD_MAX_LENGTH:
|
||||
return [payload]
|
||||
else:
|
||||
pivot = math.ceil(len(channels) / 2)
|
||||
return (get_notify_payloads(channels[:pivot]) +
|
||||
get_notify_payloads(channels[pivot:]))
|
||||
|
||||
|
||||
class ImBus(models.Model):
|
||||
|
||||
_name = 'bus.bus'
|
||||
_description = 'Communication Bus'
|
||||
|
||||
channel = fields.Char('Channel')
|
||||
message = fields.Char('Message')
|
||||
|
||||
@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()
|
||||
|
||||
@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:
|
||||
# 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))
|
||||
for payload in payloads:
|
||||
cr.execute(query, (payload,))
|
||||
|
||||
@api.model
|
||||
def _sendone(self, channel, notification_type, message):
|
||||
self._sendmany([[channel, notification_type, message]])
|
||||
|
||||
@api.model
|
||||
def _poll(self, channels, last=0):
|
||||
# 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))]
|
||||
else: # else returns the unread notifications
|
||||
domain = [('id', '>', last)]
|
||||
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)
|
||||
# list of notification to return
|
||||
result = []
|
||||
for notif in notifications:
|
||||
result.append({
|
||||
'id': notif['id'],
|
||||
'message': json.loads(notif['message']),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
#----------------------------------------------------------
|
||||
# Dispatcher
|
||||
#----------------------------------------------------------
|
||||
|
||||
class BusSubscription:
|
||||
def __init__(self, channels, last):
|
||||
self.last_notification_id = last
|
||||
self.channels = channels
|
||||
|
||||
|
||||
class ImDispatch(threading.Thread):
|
||||
def __init__(self):
|
||||
super().__init__(daemon=True, name=f'{__name__}.Bus')
|
||||
self._channels_to_ws = {}
|
||||
|
||||
def subscribe(self, channels, last, db, websocket):
|
||||
"""
|
||||
Subcribe to bus notifications. Every notification related to the
|
||||
given channels will be sent through the websocket. If a subscription
|
||||
is already present, overwrite it.
|
||||
"""
|
||||
channels = {hashable(channel_with_db(db, c)) for c in channels}
|
||||
for channel in channels:
|
||||
self._channels_to_ws.setdefault(channel, set()).add(websocket)
|
||||
outdated_channels = websocket._channels - channels
|
||||
self._clear_outdated_channels(websocket, outdated_channels)
|
||||
websocket.subscribe(channels, last)
|
||||
with contextlib.suppress(RuntimeError):
|
||||
if not self.is_alive():
|
||||
self.start()
|
||||
|
||||
def unsubscribe(self, websocket):
|
||||
self._clear_outdated_channels(websocket, websocket._channels)
|
||||
|
||||
def _clear_outdated_channels(self, websocket, outdated_channels):
|
||||
""" Remove channels from channel to websocket map. """
|
||||
for channel in outdated_channels:
|
||||
self._channels_to_ws[channel].remove(websocket)
|
||||
if not self._channels_to_ws[channel]:
|
||||
self._channels_to_ws.pop(channel)
|
||||
|
||||
def loop(self):
|
||||
""" Dispatch postgres notifications to the relevant websockets """
|
||||
_logger.info("Bus.loop listen imbus on db postgres")
|
||||
with odoo.sql_db.db_connect('postgres').cursor() as cr, \
|
||||
selectors.DefaultSelector() as sel:
|
||||
cr.execute("listen imbus")
|
||||
cr.commit()
|
||||
conn = cr._cnx
|
||||
sel.register(conn, selectors.EVENT_READ)
|
||||
while not stop_event.is_set():
|
||||
if sel.select(TIMEOUT):
|
||||
conn.poll()
|
||||
channels = []
|
||||
while conn.notifies:
|
||||
channels.extend(json.loads(conn.notifies.pop().payload))
|
||||
# relay notifications to websockets that have
|
||||
# subscribed to the corresponding channels.
|
||||
websockets = set()
|
||||
for channel in channels:
|
||||
websockets.update(self._channels_to_ws.get(hashable(channel), []))
|
||||
for websocket in websockets:
|
||||
websocket.trigger_notification_dispatching()
|
||||
|
||||
def run(self):
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
self.loop()
|
||||
except Exception as exc:
|
||||
if isinstance(exc, InterfaceError) and stop_event.is_set():
|
||||
continue
|
||||
_logger.exception("Bus.loop error, sleep and retry")
|
||||
time.sleep(TIMEOUT)
|
||||
|
||||
# Partially undo a2ed3d3d5bdb6025a1ba14ad557a115a86413e65
|
||||
# IMDispatch has a lazy start, so we could initialize it anyway
|
||||
# And this avoids the Bus unavailable error messages
|
||||
dispatch = ImDispatch()
|
||||
stop_event = threading.Event()
|
||||
CommonServer.on_stop(stop_event.set)
|
||||
72
odoo-bringout-oca-ocb-bus/bus/models/bus_presence.py
Normal file
72
odoo-bringout-oca-ocb-bus/bus/models/bus_presence.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# -*- 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)
|
||||
32
odoo-bringout-oca-ocb-bus/bus/models/ir_model.py
Normal file
32
odoo-bringout-oca-ocb-bus/bus/models/ir_model.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class IrModel(models.Model):
|
||||
_inherit = 'ir.model'
|
||||
|
||||
def _get_model_definitions(self, model_names_to_fetch):
|
||||
fields_by_model_names = {}
|
||||
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']
|
||||
).items()
|
||||
if not field_data.get('relation') or field_data['relation'] in model_names_to_fetch
|
||||
}
|
||||
for fname, field_data in fields_data_by_fname.items():
|
||||
if fname in model._fields:
|
||||
inverse_fields = [
|
||||
field for field in model.pool.field_inverses[model._fields[fname]]
|
||||
if field.model_name in model_names_to_fetch
|
||||
]
|
||||
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
|
||||
60
odoo-bringout-oca-ocb-bus/bus/models/ir_websocket.py
Normal file
60
odoo-bringout-oca-ocb-bus/bus/models/ir_websocket.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
from odoo import models
|
||||
from odoo.http import SessionExpiredException
|
||||
from odoo.service import security
|
||||
from ..models.bus import dispatch
|
||||
from ..websocket import wsrequest
|
||||
|
||||
|
||||
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
|
||||
method to add channels in addition to the ones the client
|
||||
sent.
|
||||
|
||||
:param channels: The channel list sent by the client.
|
||||
"""
|
||||
channels.append('broadcast')
|
||||
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 _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)
|
||||
|
||||
@classmethod
|
||||
def _authenticate(cls):
|
||||
if wsrequest.session.uid is not None:
|
||||
if not security.check_session(wsrequest.session, wsrequest.env):
|
||||
wsrequest.session.logout(keep_db=True)
|
||||
raise SessionExpiredException()
|
||||
else:
|
||||
public_user = wsrequest.env.ref('base.public_user')
|
||||
wsrequest.update_env(user=public_user.id)
|
||||
29
odoo-bringout-oca-ocb-bus/bus/models/res_partner.py
Normal file
29
odoo-bringout-oca-ocb-bus/bus/models/res_partner.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
28
odoo-bringout-oca-ocb-bus/bus/models/res_users.py
Normal file
28
odoo-bringout-oca-ocb-bus/bus/models/res_users.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
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
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
|
||||
_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')
|
||||
Loading…
Add table
Add a link
Reference in a new issue