[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:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
Reference in New Issue
Block a user