[IMP] calendar: add discuss video url in calendar

This commit aims to integrate the calendar module with discuss video calls.
Now it is possible to add discuss videocalls by clicking on a button below
the videocall_location field. The discuss videocall_location is a URL that
is computed and it creates a discuss channel only when someone accesses that
URL.

For recurring events, the same discuss channel is used for all events.
However, each event in the recurrency has its own videocall_location URL.
This is done on purpose in order to allow the functionality even if the base
event of the recurrency is deleted. For single events, each event will create
a discuss channel.

task-2685472

Part-of: odoo/odoo#79654
This commit is contained in:
Leonardo Pavan Rocha
2022-04-01 10:36:09 +02:00
parent a338b66bf0
commit 1744a800dc
7 changed files with 198 additions and 4 deletions
+12
View File
@@ -101,3 +101,15 @@ class CalendarController(http.Controller):
@http.route('/calendar/notify_ack', type='json', auth="user")
def notify_ack(self):
return request.env['res.partner'].sudo()._set_calendar_last_notif_ack()
@http.route('/calendar/join_videocall/<string:access_token>', type='http', auth='public')
def calendar_join_videocall(self, access_token):
event = request.env['calendar.event'].sudo().search([('access_token', '=', access_token)])
if not event:
return request.not_found()
# if channel doesn't exist
if not event.videocall_channel_id:
event._create_videocall_channel()
return request.redirect(event.videocall_channel_id.invitation_url)
+85 -1
View File
@@ -6,6 +6,7 @@ from datetime import timedelta
import math
import logging
import pytz
import uuid
from odoo import api, fields, models, Command
from odoo.osv.expression import AND
@@ -30,6 +31,8 @@ SORT_ALIASES = {
'start_date': 'sort_start',
}
DISCUSS_ROUTE = 'calendar/join_videocall'
def get_weekday_occurence(date):
"""
:returns: ocurrence
@@ -89,7 +92,10 @@ class Meeting(models.Model):
partner_id = fields.Many2one(
'res.partner', string='Scheduled by', related='user_id.partner_id', readonly=True)
location = fields.Char('Location', tracking=True, help="Location of Event")
videocall_location = fields.Char('Meeting URL')
videocall_location = fields.Char('Meeting URL', compute='_compute_videocall_location', store=True, copy=True)
access_token = fields.Char('Invitation Token', store=True, copy=False)
videocall_source = fields.Selection([('discuss', 'Discuss'), ('custom', 'Custom')], compute='_compute_videocall_source')
videocall_channel_id = fields.Many2one('mail.channel', 'Discuss Channel')
# visibility
privacy = fields.Selection(
[('public', 'Public'),
@@ -352,6 +358,36 @@ class Meeting(models.Model):
for event in self:
event.display_description = not is_html_empty(event.description)
@api.depends('videocall_source', 'access_token')
def _compute_videocall_location(self):
for event in self:
if event.videocall_source == 'discuss':
event._set_discuss_videocall_location()
@api.depends('videocall_location')
def _compute_videocall_source(self):
for event in self:
if event.videocall_location and DISCUSS_ROUTE in event.videocall_location:
event.videocall_source = 'discuss'
else:
event.videocall_source = 'custom'
def _set_discuss_videocall_location(self):
"""
This method sets the videocall_location to a discuss route.
If no access_token exists for this event, we create one.
Note that recurring events will have different access_tokens.
This is done by design to prevent users not being able to join a discuss meeting because the base event of the recurrency was deleted.
"""
if not self.access_token:
self.access_token = uuid.uuid4().hex
self.videocall_location = f"{self.get_base_url()}/{DISCUSS_ROUTE}/{self.access_token}"
@api.model
def get_discuss_videocall_location(self):
access_token = uuid.uuid4().hex
return f"{self.get_base_url()}/{DISCUSS_ROUTE}/{access_token}"
# ------------------------------------------------------------
# CRUD
# ------------------------------------------------------------
@@ -487,6 +523,14 @@ class Meeting(models.Model):
if 'partner_ids' in values:
values['attendee_ids'] = self._attendees_values(values['partner_ids'])
update_alarms = True
if self.videocall_channel_id:
new_partner_ids = []
for command in values['partner_ids']:
if command[0] == Command.LINK:
new_partner_ids.append(command[1])
elif command[0] == Command.SET:
new_partner_ids.extend(command[2])
self.videocall_channel_id.add_members(new_partner_ids)
time_fields = self.env['calendar.event']._get_time_fields()
if any([values.get(key) for key in time_fields]) or 'alarm_ids' in values:
@@ -507,6 +551,8 @@ class Meeting(models.Model):
else:
future_update_start = self.start if recurrence_update_setting == 'future_events' else None
time_values = {field: values.pop(field) for field in time_fields if field in values}
if 'access_token' in values:
values.pop('access_token') # prevents copying access_token to other events in recurrency
if recurrence_update_setting == 'all_events':
# Update all events: we create a new reccurrence and dismiss the existing events
self._rewrite_recurrence(values, time_values, recurrence_values)
@@ -642,10 +688,41 @@ class Meeting(models.Model):
]
return attendee_commands
def _create_videocall_channel(self):
if self.recurrency:
# check if any of the events have videocall_channel_id, if not create one
event_with_channel = self.env['calendar.event'].search([
('recurrence_id', '=', self.recurrence_id.id),
('videocall_channel_id', '!=', False)
], limit=1)
if event_with_channel:
self.videocall_channel_id = event_with_channel.videocall_channel_id
return
self.videocall_channel_id = self._create_videocall_channel_id(self.name, self.partner_ids.ids)
self.videocall_channel_id.channel_change_description(self.recurrence_id.name if self.recurrency else self.display_time)
def _create_videocall_channel_id(self, name, partner_ids):
videocall_channel_id = self.env['mail.channel'].create_group(partner_ids, default_display_mode='video_full_screen', name=name)
# if recurrent event, set channel to all other records of the same recurrency
if self.recurrency:
recurrent_events_without_channel = self.env['calendar.event'].search([
('recurrence_id', '=', self.recurrence_id.id), ('videocall_channel_id', '=', False)
])
recurrent_events_without_channel.videocall_channel_id = videocall_channel_id['id']
return videocall_channel_id['id']
# ------------------------------------------------------------
# ACTIONS
# ------------------------------------------------------------
# dummy method. this method is intercepted in the frontend and the value is set locally
def set_discuss_videocall_location(self):
return True
# dummy method. this method is intercepted in the frontend and the value is set locally
def clear_videocall_location(self):
return True
def action_open_calendar_event(self):
if self.res_model and self.res_id:
return self.env[self.res_model].browse(self.res_id).get_formview_action()
@@ -686,6 +763,13 @@ class Meeting(models.Model):
'context': compose_ctx,
}
def action_join_video_call(self):
return {
'type': 'ir.actions.act_url',
'url': self.videocall_location,
'target': 'self' if self.videocall_source == 'discuss' else 'new'
}
def action_join_meeting(self, partner_id):
""" Method used when an existing user wants to join
"""
@@ -6,6 +6,11 @@ import AttendeeCalendarRenderer from '@calendar/js/calendar_renderer';
import CalendarView from 'web.CalendarView';
import viewRegistry from 'web.view_registry';
import rpc from 'web.rpc';
import FormView from 'web.FormView';
import FormController from 'web.FormController';
const CalendarRenderer = AttendeeCalendarRenderer.AttendeeCalendarRenderer;
var AttendeeCalendarView = CalendarView.extend({
@@ -18,4 +23,48 @@ var AttendeeCalendarView = CalendarView.extend({
viewRegistry.add('attendee_calendar', AttendeeCalendarView);
const CalendarFormController = FormController.extend({
start: async function () {
rpc.query({
model: 'calendar.event',
method: 'get_discuss_videocall_location'
}).then((discussVideocallLocation) => {
this.discussVideocallLocation = discussVideocallLocation
});
return this._super.apply(this, arguments);
},
_onButtonClicked: function (ev) {
const action = ev.data.attrs.name;
if (action == 'clear_videocall_location' || action === 'set_discuss_videocall_location') {
let newVal = false;
let videoCallSource = 'custom'
let changes = {};
if (action === 'set_discuss_videocall_location') {
newVal = this.discussVideocallLocation;
videoCallSource = 'discuss';
changes.access_token = this.discussVideocallLocation.split('/').pop();
}
changes = Object.assign(changes, {
videocall_location: newVal,
videocall_source: videoCallSource,
});
this.trigger_up('field_changed', {
dataPointID: ev.data.record.id,
changes
});
return;
}
return this._super.apply(this, arguments);
},
});
export const CalendarFormView = FormView.extend({
config: _.extend({}, FormView.prototype.config, {
Controller: CalendarFormController,
}),
});
viewRegistry.add('calendar_form', CalendarFormView);
export default AttendeeCalendarView;
+17 -1
View File
@@ -4,7 +4,7 @@ import datetime
from datetime import datetime, timedelta, time
from odoo import fields
from odoo import fields, Command
from odoo.addons.base.tests.common import SavepointCaseWithUserDemo
import pytz
import re
@@ -359,3 +359,19 @@ class TestCalendar(SavepointCaseWithUserDemo):
self.assertEqual(len(event.attendee_ids), 2)
self.assertTrue(self.partner_demo in event.attendee_ids.mapped('partner_id'))
self.assertTrue(self.env.user.partner_id in event.attendee_ids.mapped('partner_id'))
def test_discuss_videocall(self):
self.event_tech_presentation._set_discuss_videocall_location()
self.assertFalse(self.event_tech_presentation.videocall_channel_id.id, 'No channel should be set before the route is accessed')
# create the first channel
self.event_tech_presentation._create_videocall_channel()
self.assertNotEqual(self.event_tech_presentation.videocall_channel_id.id, False)
partner1 = self.env['res.partner'].create({'name': 'Bob', 'email': u'bob@gm.co'})
partner2 = self.env['res.partner'].create({'name': 'Jack', 'email': u'jack@gm.co'})
new_partners = [partner1.id, partner2.id]
# invite partners to meeting
self.event_tech_presentation.write({
'partner_ids': [Command.link(new_partner) for new_partner in new_partners]
})
self.assertTrue(set(new_partners) == set(self.event_tech_presentation.videocall_channel_id.channel_partner_ids.ids), 'new partners must be invited to the channel')
@@ -160,6 +160,7 @@ class TestEventNotifications(TransactionCase, MailCase, CronMixinCase):
'partner_ids': [fields.Command.link(self.partner.id)],
'alarm_ids': [fields.Command.link(alarm.id)],
})
self.event.flush() # flush is required to make partner_ids be present in the event
capt.records.ensure_one()
self.assertLessEqual(capt.records.call_at, now)
@@ -288,6 +288,24 @@ class TestCreateRecurrentEvents(TestRecurrentEvents):
(datetime(2020, 3, 30, 0, 0), datetime(2020, 3, 30, 23, 59)),
])
def test_videocall_recurrency(self):
self.event._set_discuss_videocall_location()
self.event._apply_recurrence_values({
'interval': 1,
'rrule_type': 'weekly',
'mon': True,
'count': 2,
})
recurrent_events = self.event.recurrence_id.calendar_event_ids
detached_events = self.event.recurrence_id.calendar_event_ids - self.event
rec_events_videocall_locations = recurrent_events.mapped('videocall_location')
self.assertEqual(len(rec_events_videocall_locations), len(set(rec_events_videocall_locations)), 'Recurrent events should have different videocall locations')
self.assertEqual(not any(recurrent_events.videocall_channel_id), True, 'No channel should be set before the route is accessed')
# create the first channel
detached_events[0]._create_videocall_channel()
# after channel is created, all other events should have the same channel
self.assertEqual(detached_events[0].videocall_channel_id.id, self.event.videocall_channel_id.id)
class TestUpdateRecurrentEvents(TestRecurrentEvents):
+16 -2
View File
@@ -104,7 +104,7 @@
<field name="model">calendar.event</field>
<field name="priority" eval="1"/>
<field name="arch" type="xml">
<form string="Meetings">
<form string="Meetings" js_class="calendar_form">
<div attrs="{'invisible': [('recurrence_id','=',False)]}" class="alert alert-info oe_edit_only" role="status">
<p>Edit recurring event</p>
<field name="recurrence_update" widget="radio"/>
@@ -135,6 +135,7 @@
<div name="send_buttons" class="sm-2">
<button name="action_open_composer" help="Send Email to attendees" type="object" string=" EMAIL" icon="fa-envelope"/>
</div>
<button name="action_join_video_call" class="btn-primary" help="Join Video Call" type="object" string="Join Video Call" attrs="{'invisible': [('videocall_location', '=', False)]}"/>
</div>
<div class="alert alert-warning o_form_header mt-2" attrs="{'invisible': [('invalid_email_partner_ids', '=', [])]}" role="status">
<p><strong>The following attendees have invalid email addresses and won't receive any email notifications:</strong></p>
@@ -162,7 +163,19 @@
<group>
<field name="alarm_ids" widget="many2many_tags" options="{'no_quick_create': True}"/>
<field name="location" />
<field name="videocall_location"/>
<field name="videocall_location" class="oe_inline" string="Videocall URL" widget="CopyClipboardChar" force_save="1" attrs="{'readonly':[('videocall_source','=', 'discuss')]}"/>
<div colspan="12" class="d-flex justify-content-center">
<button name="clear_videocall_location" type="object" class="btn btn-link"
attrs="{'invisible': [('videocall_location', '=', False)]}" context="{'recurrence_update': recurrence_update}">
<span class="fa fa-times"></span><span> Clear meeting</span>
</button>
<button name="set_discuss_videocall_location" type="object" class="btn btn-link"
attrs="{'invisible': [('videocall_location', '!=', False)]}" context="{'recurrence_update': recurrence_update}">
<span class="fa fa-plus"></span><span> Add Odoo meeting</span>
</button>
</div>
<field name="videocall_source" invisible="1"/>
<field name="access_token" invisible="1" force_save="1"/>
<field name="categ_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}"/>
</group>
</group>
@@ -278,6 +291,7 @@
filters="1" widget="many2manyattendee" write_model="calendar.filters"
write_field="partner_id" filter_field="partner_checked" avatar_field="avatar_128"
/>
<field name="videocall_location" widget="url" text="Join Video Call" options="{'icon': 'fa fa-lg fa-video-camera'}" attrs="{'invisible': [('videocall_location', '=', False)]}"/>
<field name="is_highlighted" invisible="1"/>
<field name="is_organizer_alone" invisible="1"/>
<field name="display_description" invisible="1"/>