diff --git a/addons/website_jitsi/__init__.py b/addons/website_jitsi/__init__.py index 67dee8c60db..7d34c7c054a 100644 --- a/addons/website_jitsi/__init__.py +++ b/addons/website_jitsi/__init__.py @@ -1,2 +1,5 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controllers +from . import models diff --git a/addons/website_jitsi/__manifest__.py b/addons/website_jitsi/__manifest__.py index cec3158a81a..af6638c3739 100644 --- a/addons/website_jitsi/__manifest__.py +++ b/addons/website_jitsi/__manifest__.py @@ -13,6 +13,9 @@ "website" ], "data": [ + 'views/assets.xml', + 'views/chat_room_templates.xml', + 'views/chat_room_views.xml', ], 'application': False, } diff --git a/addons/website_jitsi/controllers/__init__.py b/addons/website_jitsi/controllers/__init__.py new file mode 100644 index 00000000000..5d4b25db9c0 --- /dev/null +++ b/addons/website_jitsi/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import main diff --git a/addons/website_jitsi/controllers/main.py b/addons/website_jitsi/controllers/main.py new file mode 100644 index 00000000000..b7c91ea7f9f --- /dev/null +++ b/addons/website_jitsi/controllers/main.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from werkzeug.exceptions import NotFound + +from odoo import http +from odoo.http import request + + +class WebsiteJitsiController(http.Controller): + + @http.route(["/jitsi/update_status"], type="json", auth="public") + def jitsi_update_status(self, room_name, participant_count, joined): + """ Update room status: participant count, max reached + + Use the SQL keywords "FOR UPDATE SKIP LOCKED" in order to skip if the row + is locked (instead of raising an exception, wait for a moment and retry). + This endpoint may be called multiple times and we don't care having small + errors in participant count compared to performance issues. + + :raise ValueError: wrong participant count + :raise NotFound: wrong room name + """ + if participant_count < 0: + raise ValueError() + + chat_room = self._chat_room_exists(room_name) + if not chat_room: + raise NotFound() + + request.env.cr.execute( + """ + WITH req AS ( + SELECT id + FROM chat_room + -- Can not update the chat room if we do not have its name + WHERE name = %s + FOR UPDATE SKIP LOCKED + ) + UPDATE chat_room AS wcr + SET participant_count = %s, + last_activity = NOW(), + max_participant_reached = GREATEST(max_participant_reached, %s) + FROM req + WHERE wcr.id = req.id; + """, + [room_name, participant_count, participant_count] + ) + + @http.route(["/jitsi/is_full"], type="json", auth="public") + def jitsi_is_full(self, room_name): + return self._chat_room_exists(room_name).is_full + + # ------------------------------------------------------------ + # TOOLS + # ------------------------------------------------------------ + + def _chat_room_exists(self, room_name): + return request.env["chat.room"].sudo().search([("name", "=", room_name)], limit=1) diff --git a/addons/website_jitsi/models/__init__.py b/addons/website_jitsi/models/__init__.py new file mode 100644 index 00000000000..222bc1df76a --- /dev/null +++ b/addons/website_jitsi/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import chat_room +from . import chat_room_mixin diff --git a/addons/website_jitsi/models/chat_room.py b/addons/website_jitsi/models/chat_room.py new file mode 100644 index 00000000000..8494f01c01d --- /dev/null +++ b/addons/website_jitsi/models/chat_room.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import re + +from uuid import uuid4 + +from odoo import api, fields, models +from odoo.tools import remove_accents + + +class ChatRoom(models.Model): + """ Store all useful information to manage chat room (currently limited + to Jitsi). This model embeds all information about the chat room. We do not + store them in the related mixin (see chat.room.mixin) to avoid to add too + many fields on the models which want to use the chat room mixin as the + behavior can be optional in those models. + + The participant count is automatically updated thanks to the chat room widget + to avoid having a costly computed field with a members model. + """ + _name = "chat.room" + _description = "Chat Room" + + def _default_name(self): + return "odoo-room-%s" % str(uuid4())[:8] + + name = fields.Char( + "Room Name", required=True, copy=False, + default=lambda self: self._default_name()) + is_full = fields.Boolean("Full", compute="_compute_is_full") + lang_id = fields.Many2one( + "res.lang", "Language", + default=lambda self: self.env["res.lang"].search([("code", "=", self.env.user.lang)], limit=1)) + max_capacity = fields.Selection( + [("4", "4"), ("8", "8"), ("12", "12"), ("16", "16"), + ("20", "20"), ("no_limit", "No limit")], string="Max capacity", + default="8", required=True) + participant_count = fields.Integer("Participant count", default=0, copy=False) + # reporting fields + last_activity = fields.Datetime( + "Last Activity", copy=False, readonly=True, + default=lambda self: fields.Datetime.now()) + max_participant_reached = fields.Integer( + "Max participant reached", copy=False, readonly=True, + help="Maximum number of participant reached in the room at the same time") + + @api.depends("max_capacity", "participant_count") + def _compute_is_full(self): + for room in self: + if room.max_capacity == "no_limit": + room.is_full = False + else: + room.is_full = room.participant_count >= int(room.max_capacity) + + def _jitsi_sanitize_name(self, name): + return re.sub(r'[^\w+.]+', '-', remove_accents(name).lower()) diff --git a/addons/website_jitsi/models/chat_room_mixin.py b/addons/website_jitsi/models/chat_room_mixin.py new file mode 100644 index 00000000000..6aedb60bc6f --- /dev/null +++ b/addons/website_jitsi/models/chat_room_mixin.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class ChatRoomMixin(models.AbstractModel): + """Add the chat room configuration (`chat.room`) on the needed models. + + The chat room configuration contains all information about the room. So, we store + all the chat room logic at the same place, for all models. + Embed chat room related fields prefixed with `room_`. + """ + _name = "chat.room.mixin" + _description = "Chat Room Mixin" + ROOM_CONFIG_FIELDS = [ + ('room_name', 'name'), + ('room_lang_id', 'lang_id'), + ('room_max_capacity', 'max_capacity'), + ('room_participant_count', 'participant_count') + ] + + chat_room_id = fields.Many2one("chat.room", "Chat Room", readonly=True, copy=False, ondelete="set null") + # chat room related fields + room_name = fields.Char("Room Name", related="chat_room_id.name") + room_is_full = fields.Boolean("Room Is Full", related="chat_room_id.is_full") + room_lang_id = fields.Many2one("res.lang", "Language", related="chat_room_id.lang_id", readonly=False) + room_max_capacity = fields.Selection(string="Max capacity", related="chat_room_id.max_capacity", readonly=False, required=True) + room_participant_count = fields.Integer("Participant count", related="chat_room_id.participant_count", readonly=False) + room_last_activity = fields.Datetime("Last activity", related="chat_room_id.last_activity") + room_max_participant_reached = fields.Integer("Max participant reached", related="chat_room_id.max_participant_reached") + + @api.model_create_multi + def create(self, values_list): + for values in values_list: + if any(values.get(fmatch[0]) for fmatch in self.ROOM_CONFIG_FIELDS) and not values.get('chat_room_id'): + room_values = dict((fmatch[1], values[fmatch[0]]) for fmatch in self.ROOM_CONFIG_FIELDS if values.get(fmatch[0])) + values['chat_room_id'] = self.env['chat.room'].create(room_values).id + return super(ChatRoomMixin, self).create(values_list) + + def write(self, values): + if any(values.get(fmatch[0]) for fmatch in self.ROOM_CONFIG_FIELDS): + for document in self.filtered(lambda doc: not doc.chat_room_id): + room_values = dict((fmatch[1], values[fmatch[0]]) for fmatch in self.ROOM_CONFIG_FIELDS if values.get(fmatch[0])) + document.chat_room_id = self.env['chat.room'].create(room_values).id + return super(ChatRoomMixin, self).write(values) + + def copy(self, default=None): + if self.chat_room_id: + chat_room_id = self.chat_room_id.copy() + default = dict(default or {}, chat_room_id=chat_room_id.id) + return super().copy(default) + + def unlink(self): + if self.chat_room_id: + self.chat_room_id.unlink() + return super(ChatRoomMixin, self).unlink() diff --git a/addons/website_jitsi/security/ir.model.access.csv b/addons/website_jitsi/security/ir.model.access.csv new file mode 100644 index 00000000000..eacbd51548b --- /dev/null +++ b/addons/website_jitsi/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_chat_room_all,access_chat_room,model_chat_room,,0,0,0,0 +access_chat_room_user,access_chat_room,model_chat_room,base.group_user,1,0,0,0 +access_chat_room_system,access_chat_room,model_chat_room,base.group_system,1,1,1,1 diff --git a/addons/website_jitsi/static/src/css/chat_room.css b/addons/website_jitsi/static/src/css/chat_room.css new file mode 100644 index 00000000000..ad0b2b8b0d5 --- /dev/null +++ b/addons/website_jitsi/static/src/css/chat_room.css @@ -0,0 +1,17 @@ +.o_wjitsi_room_modal .modal-dialog { + top: 10%; + max-width: 100vw; + max-height: 100vh; + width: 80%; + margin: auto; +} + +.o_wjitsi_room_modal .modal-body { + height: 70vh; + overflow: hidden; + margin: 0; +} + +.o_wjitsi_chat_room_loading { + top: 50%; +} diff --git a/addons/website_jitsi/static/src/js/chat_room.js b/addons/website_jitsi/static/src/js/chat_room.js new file mode 100644 index 00000000000..f57a67a0a54 --- /dev/null +++ b/addons/website_jitsi/static/src/js/chat_room.js @@ -0,0 +1,271 @@ +odoo.define('website_jitsi.chat_room', function (require) { +'use strict'; + +const config = require("web.config"); +const core = require('web.core'); +const Dialog = require('web.Dialog'); +const publicWidget = require('web.public.widget'); +const QWeb = core.qweb; +const _t = core._t; + +publicWidget.registry.ChatRoom = publicWidget.Widget.extend({ + selector: '.o_wjitsi_room_widget', + xmlDependencies: ['/website_jitsi/static/src/xml/chat_room_modal.xml'], + events: { + 'click .o_wjitsi_room_link': '_onChatRoomClick', + }, + + start: async function () { + await this._super.apply(this, arguments); + this.roomName = this.$el.data('room-name'); + this.chatRoomId = parseInt(this.$el.data('chat-room-id')); + // automatically open the current room + this.autoOpen = parseInt(this.$el.data('auto-open') || 0); + // before joining, perform a RPC call to verify that the chat room is not full + this.checkFull = parseInt(this.$el.data('check-full') || 0); + // query selector of the element on which we attach the Jitsi iframe + // if not defined, the widget will pop in a modal instead + this.attachTo = this.$el.data('attach-to') || false; + // default username for jitsi + this.defaultUsername = this.$el.data('default-username') || false; + + if (this.autoOpen) { + await this._onChatRoomClick(); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Click on a chat room to join it. + * + * @private + */ + _onChatRoomClick: async function () { + if (this.checkFull) { + // maybe we didn't refresh the page for a while and so we might join a room + // which is full, so we perform a RPC call to verify that we can really join + let isChatRoomFull = await this._rpc({ + route: '/jitsi/is_full', + params: { + room_name: this.roomName, + }, + }); + + if (isChatRoomFull) { + Dialog.alert(this, _t("Sorry, this room is full"), { + title: _t("Warning"), + // reload the page to refresh the participant count + confirm_callback: () => window.location.reload(), + }); + return; + } + } + + if (await this._openMobileApplication(this.roomName)) { + // we opened the mobile application + return; + } + + await this._loadJisti(); + + if (this.attachTo) { + // attach the Jitsi iframe on the given parent node + let $parentNode = $(this.attachTo); + $parentNode.find("iframe").trigger("empty"); + $parentNode.empty(); + + let jitsiRoom = await this._joinJitsiRoom($parentNode); + + $(jitsiRoom._frame).on("empty", async () => { + // we opened an other Jitsi room on the same parent node + await this._updatePartitipantCountIfEmpty(); + }); + + jitsiRoom.addEventListener('videoConferenceLeft', async () => { + await this._updatePartitipantCountIfEmpty(); + }); + } else { + // create a model and append the Jitsi iframe in it + let $jitsiModal = $(QWeb.render('chat_room_modal', {})); + $("body").append($jitsiModal); + $jitsiModal.modal('show'); + + let jitsiRoom = await this._joinJitsiRoom($jitsiModal.find('.modal-body')); + + // close the modal when hanging up + jitsiRoom.addEventListener('videoConferenceLeft', async () => { + $('.o_wjitsi_room_modal').modal('hide'); + }); + + // when the modal is closed, delete the Jitsi room object and clear the DOM + $jitsiModal.on('hidden.bs.modal', async () => { + jitsiRoom.dispose(); + $(".o_wjitsi_room_modal").remove(); + await this._updatePartitipantCountIfEmpty(); + }); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Jitsi do not provide an REST API to get the number of participant in a room. + * The only way to get the number of participant is to be in the room and to use + * the Javascript API. So, to update the participant count on the server side, + * the participant have to send the count in RPC... + * + * When leaving a room, the event "participantLeft" is called for the current user + * once per participant in the room (like if all other participants were leaving the + * room and then the current user himself). + * + * "participantLeft" is called only one time for the other participant who are still + * in the room. + * + * We can not ask the user who is leaving the room to update the participant count + * because user might close their browser tab without hanging up (and so without + * triggering the event "videoConferenceLeft"). So, we wait for a moment (because the + * event "participantLeft" is called many time for the participant who is leaving) + * and the first participant send the new participant count (so we avoid spamming the + * server with HTTP requests). + * + * We use "setTimout" to send maximum one HTTP request per interval, even if multiple + * participants join/leave at the same time in the defined interval. + * + * Update on the 29 June 2020 + * + * @private + * @param {jQuery} $jitsiModal, jQuery modal element in which we add the Jitsi room + * @returns {JitsiRoom} the newly created Jitsi room + */ + _joinJitsiRoom: async function ($parentNode) { + let jitsiRoom = await this._createJitsiRoom(this.roomName, $parentNode); + + if (this.defaultUsername) { + jitsiRoom.executeCommand("displayName", this.defaultUsername); + } + + let timeoutCall = null; + const updateParticipantCount = (joined) => { + // we clear the old timeout to be sure to call it only once each 2 seconds + // (so if 2 participants join/leave in this interval, we will perform only + // one HTTP request for both). + clearTimeout(timeoutCall); + timeoutCall = setTimeout(() => { + this.allParticipantIds = Object.keys(jitsiRoom._participants).sort(); + if (this.participantId === this.allParticipantIds[0]) { + // only the first participant of the room send the new participant + // count so we avoid to send to many HTTP requests + this._updateParticipantCount(this.allParticipantIds.length, joined); + } + }, 2000); + }; + + jitsiRoom.addEventListener('participantJoined', () => updateParticipantCount(true)); + jitsiRoom.addEventListener('participantLeft', () => updateParticipantCount(false)); + + // update the participant count when joining the room + jitsiRoom.addEventListener('videoConferenceJoined', async (event) => { + this.participantId = event.id; + updateParticipantCount(true); + $('.o_wjitsi_chat_room_loading').addClass('d-none'); + }); + + return jitsiRoom; + }, + + /** + * If we are the last participant in the room, we are the only one who can send the + * "zero participant" update to the server. + * + * @private + */ + _updatePartitipantCountIfEmpty: async function () { + if (this.allParticipantIds && this.allParticipantIds.length === 1 && this.allParticipantIds[0] === this.participantId) { + // we are the last participant in the room and we left it + await this._updateParticipantCount(0, false); + } + }, + + /** + * Perform an HTTP request to update the participant count on the server side. + * + * @private + * @param {integer} count, current number of participant in the room + * @param {boolean} joined, true if someone joined the room + */ + _updateParticipantCount: async function (count, joined) { + await this._rpc({ + route: '/jitsi/update_status', + params: { + room_name: this.roomName, + participant_count: count, + joined: joined, + }, + }); + }, + + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Redirect on the Jitsi mobile application if we are on mobile. + * + * @private + * @param {string} roomName + * @returns {boolean} true is we were redirected to the mobile application + */ + _openMobileApplication: async function (roomName) { + if (config.device.isMobile) { + // we are on mobile, open the room in the application + window.location = `intent://meet.jit.si/${roomName}#Intent;scheme=org.jitsi.meet;package=org.jitsi.meet;end`; + return true; + } + return false; + }, + + /** + * Create a Jitsi room on the given DOM element. + * + * @private + * @param {string} roomName + * @param {jQuery} $parentNode + * @returns {JitsiRoom} the newly created Jitsi room + */ + _createJitsiRoom: async function (roomName, $parentNode) { + await this._loadJisti(); + const domain = "meet.jit.si"; + const options = { + roomName: roomName, + width: "100%", + height: "100%", + parentNode: $parentNode[0], + configOverwrite: {disableDeepLinking: true}, + }; + return new window.JitsiMeetExternalAPI(domain, options); + }, + + /** + * Load the Jitsi external library if necessary. + * + * @private + */ + _loadJisti: async function () { + if (!window.JitsiMeetExternalAPI) { + await $.ajax({ + url: "https://meet.jit.si/external_api.js", + dataType: "script", + }); + } + }, +}); + +return publicWidget.registry.ChatRoom; + +}); diff --git a/addons/website_jitsi/static/src/xml/chat_room_modal.xml b/addons/website_jitsi/static/src/xml/chat_room_modal.xml new file mode 100644 index 00000000000..d076a31739f --- /dev/null +++ b/addons/website_jitsi/static/src/xml/chat_room_modal.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/addons/website_jitsi/views/assets.xml b/addons/website_jitsi/views/assets.xml new file mode 100644 index 00000000000..ced7415c40e --- /dev/null +++ b/addons/website_jitsi/views/assets.xml @@ -0,0 +1,11 @@ + + +