diff --git a/addons/calendar/controllers/main.py b/addons/calendar/controllers/main.py index 47727becde3..0b87bf02bf1 100644 --- a/addons/calendar/controllers/main.py +++ b/addons/calendar/controllers/main.py @@ -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/', 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) diff --git a/addons/calendar/models/calendar_event.py b/addons/calendar/models/calendar_event.py index 083f6232ad1..93d955ef85a 100644 --- a/addons/calendar/models/calendar_event.py +++ b/addons/calendar/models/calendar_event.py @@ -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 """ diff --git a/addons/calendar/static/src/js/calendar_view.js b/addons/calendar/static/src/js/calendar_view.js index 4e67f7f9b25..b21b9699a4e 100644 --- a/addons/calendar/static/src/js/calendar_view.js +++ b/addons/calendar/static/src/js/calendar_view.js @@ -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; diff --git a/addons/calendar/tests/test_calendar.py b/addons/calendar/tests/test_calendar.py index d2897a2f38a..e1d44e04291 100644 --- a/addons/calendar/tests/test_calendar.py +++ b/addons/calendar/tests/test_calendar.py @@ -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') diff --git a/addons/calendar/tests/test_event_notifications.py b/addons/calendar/tests/test_event_notifications.py index 67aab69d5fb..b7833f00342 100644 --- a/addons/calendar/tests/test_event_notifications.py +++ b/addons/calendar/tests/test_event_notifications.py @@ -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) diff --git a/addons/calendar/tests/test_event_recurrence.py b/addons/calendar/tests/test_event_recurrence.py index 2e0736f7423..cb41de9e42c 100644 --- a/addons/calendar/tests/test_event_recurrence.py +++ b/addons/calendar/tests/test_event_recurrence.py @@ -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): diff --git a/addons/calendar/views/calendar_views.xml b/addons/calendar/views/calendar_views.xml index 1eec0b9761e..b1a66d74cca 100644 --- a/addons/calendar/views/calendar_views.xml +++ b/addons/calendar/views/calendar_views.xml @@ -104,7 +104,7 @@ calendar.event -
+

Edit recurring event

@@ -135,6 +135,7 @@
+

The following attendees have invalid email addresses and won't receive any email notifications:

@@ -162,7 +163,19 @@ - + +
+ + +
+ +
@@ -278,6 +291,7 @@ filters="1" widget="many2manyattendee" write_model="calendar.filters" write_field="partner_id" filter_field="partner_checked" avatar_field="avatar_128" /> +