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 @@
+
+
+
+