From a2e823858bbd6d5199d71ad40747d6e633dd98fc Mon Sep 17 00:00:00 2001 From: "Gabriel de Paula Felix (gdpf)" Date: Thu, 9 Nov 2023 15:17:07 -0300 Subject: [PATCH] [FIX] microsoft_calendar: problem creating event for another organizer Before this commit, with the synchronization with Outlook active, it was not possible editing the organizer of an event to a different user than yourself or even creating a new event with a different organizer because Outlook didn't accept the change. After this commit, it is possible creating a new event with this characteristic and changing the event's organizer to a different user too. Changing the organizer will delete the current event and create a new one, and creating a brand new event will create the event directly with the different organizer. closes odoo/odoo#140820 Task-id: 3450045 X-original-commit: 81601d5 Related: odoo/enterprise#50080 Signed-off-by: Arnaud Joset (arj) Signed-off-by: Gabriel de Paula Felix (gdpf) --- addons/calendar/models/calendar_event.py | 6 +- .../i18n/microsoft_calendar.pot | 9 +++ addons/microsoft_calendar/models/calendar.py | 54 ++++++++++++++++ .../models/microsoft_sync.py | 6 +- addons/microsoft_calendar/tests/common.py | 6 ++ .../tests/test_create_events.py | 45 ++++++++++++++ .../tests/test_update_events.py | 62 ++++++++++++++++++- 7 files changed, 182 insertions(+), 6 deletions(-) diff --git a/addons/calendar/models/calendar_event.py b/addons/calendar/models/calendar_event.py index 42180c5fed7..50102b0fc5b 100644 --- a/addons/calendar/models/calendar_event.py +++ b/addons/calendar/models/calendar_event.py @@ -791,12 +791,12 @@ class Meeting(models.Model): added_partner_ids = [] for command in partner_commands: op = command[0] - if op in (2, 3): # Remove partner + if op in (2, 3, Command.delete, Command.unlink): # Remove partner removed_partner_ids += [command[1]] - elif op == 6: # Replace all + elif op in (6, Command.set): # Replace all removed_partner_ids += set(self.partner_ids.ids) - set(command[2]) # Don't recreate attendee if partner already attend the event added_partner_ids += set(command[2]) - set(self.partner_ids.ids) - elif op == 4: + elif op in (4, Command.link): added_partner_ids += [command[1]] if command[1] not in self.partner_ids.ids else [] # commands 0 and 1 not supported diff --git a/addons/microsoft_calendar/i18n/microsoft_calendar.pot b/addons/microsoft_calendar/i18n/microsoft_calendar.pot index 6499d74e829..bfb77f7a0ba 100644 --- a/addons/microsoft_calendar/i18n/microsoft_calendar.pot +++ b/addons/microsoft_calendar/i18n/microsoft_calendar.pot @@ -210,6 +210,15 @@ msgid "" "%s" msgstr "" +#. module: microsoft_calendar +#. odoo-python +#: code:addons/microsoft_calendar/models/calendar.py:0 +#, python-format +msgid "" +"For having a different organizer in your event, it is necessary that the " +"organizer have its Odoo Calendar synced with Outlook Calendar." +msgstr "" + #. module: microsoft_calendar #: model:ir.model.fields,field_description:microsoft_calendar.field_microsoft_calendar_account_reset__id #: model:ir.model.fields,field_description:microsoft_calendar.field_microsoft_calendar_credentials__id diff --git a/addons/microsoft_calendar/models/calendar.py b/addons/microsoft_calendar/models/calendar.py index de9d36e1aa0..64ff343d801 100644 --- a/addons/microsoft_calendar/models/calendar.py +++ b/addons/microsoft_calendar/models/calendar.py @@ -82,6 +82,12 @@ class Meeting(models.Model): if self._check_microsoft_sync_status() and not notify_context and recurrency_in_batch: self._forbid_recurrence_creation() + for vals in vals_list: + # If event has a different organizer, check its sync status and verify if the user is listed as attendee. + sender_user, partner_ids = self._get_organizer_user_change_info(vals) + partner_included = partner_ids and len(partner_ids) > 0 and sender_user.partner_id.id in partner_ids + self._check_organizer_validation(sender_user, partner_included) + # for a recurrent event, we do not create events separately but we directly # create the recurrency from the corresponding calendar.recurrence. # That's why, events from a recurrency have their `need_sync_m` attribute set to False. @@ -90,6 +96,21 @@ class Meeting(models.Model): for vals in vals_list ]) + def _check_organizer_validation(self, sender_user, partner_included): + """ Check if the proposed event organizer can be set accordingly. """ + # Edge case: events created or updated from Microsoft should not check organizer validation. + change_from_microsoft = self.env.context.get('dont_notify', False) + if sender_user and sender_user != self.env.user and not change_from_microsoft: + current_sync_status = self._check_microsoft_sync_status() + sender_sync_status = self.with_user(sender_user)._check_microsoft_sync_status() + if not sender_sync_status and current_sync_status: + raise ValidationError( + _("For having a different organizer in your event, it is necessary that " + "the organizer have its Odoo Calendar synced with Outlook Calendar.")) + elif sender_sync_status and not partner_included: + raise ValidationError( + _("It is necessary adding the proposed organizer as attendee before saving the event.")) + def _check_recurrence_overlapping(self, new_start): """ Outlook does not allow to modify time fields of an event if this event crosses @@ -154,6 +175,16 @@ class Meeting(models.Model): if not notify_context and recurrence_update_attempt and not 'active' in values: self._forbid_recurrence_update() + # When changing the organizer, check its sync status and verify if the user is listed as attendee. + change_from_microsoft = self.env.context.get('dont_notify', False) + for event in self: + if values.get('user_id') and event.user_id.id != values['user_id'] and not change_from_microsoft: + sender_user, partner_ids = event._get_organizer_user_change_info(values) + partner_included = sender_user.partner_id in event.attendee_ids.partner_id or sender_user.partner_id.id in partner_ids + event._check_organizer_validation(sender_user, partner_included) + event._recreate_event_different_organizer(values, sender_user) + values['active'] = False + # check a Outlook limitation in overlapping the actual recurrence if recurrence_update_setting == 'self_only' and 'start' in values: self._check_recurrence_overlapping(values['start']) @@ -173,6 +204,29 @@ class Meeting(models.Model): self.recurrence_id.need_sync_m = True return res + def _recreate_event_different_organizer(self, values, sender_user): + """ Copy current event values, delete it and recreate it with the new organizer user. """ + self.ensure_one() + event_copy = {**self.copy_data()[0], 'microsoft_id': False} + self.env['calendar.event'].with_user(sender_user).create({**event_copy, **values}) + if self.ms_universal_event_id: + self._microsoft_delete(self._get_organizer(), self.ms_organizer_event_id) + + @api.model + def _get_organizer_user_change_info(self, values): + """ Return the sender user of the event and the partner ids listed on the event values. """ + sender_user_id = values.get('user_id') + if not sender_user_id: + sender_user_id = self.env.user.id + sender_user = self.env['res.users'].browse(sender_user_id) + attendee_values = self._attendees_values(values['partner_ids']) if 'partner_ids' in values else [] + partner_ids = [] + if attendee_values: + for command in attendee_values: + if len(command) == 3 and isinstance(command[2], dict): + partner_ids.append(command[2].get('partner_id')) + return sender_user, partner_ids + def action_mass_archive(self, recurrence_update_setting): # Do not allow archiving if recurrence is synced with Outlook. Suggest updating directly from Outlook. self.ensure_one() diff --git a/addons/microsoft_calendar/models/microsoft_sync.py b/addons/microsoft_calendar/models/microsoft_sync.py index 8673c0b73a5..198e0978ac8 100644 --- a/addons/microsoft_calendar/models/microsoft_sync.py +++ b/addons/microsoft_calendar/models/microsoft_sync.py @@ -431,7 +431,11 @@ class MicrosoftSync(models.AbstractModel): if not values: return microsoft_service = self._get_microsoft_service() - with microsoft_calendar_token(self.env.user.sudo()) as token: + # Insert using 'self.user_id' if synced with Outlook, otherwise use 'self.env.user'. + sender_user = self.env.user + if self.user_id and self.with_user(self.user_id)._check_microsoft_sync_status(): + sender_user = self.user_id + with microsoft_calendar_token(sender_user.sudo()) as token: if token: self._ensure_attendees_have_email() event_id, uid = microsoft_service.insert(values, token=token, timeout=timeout) diff --git a/addons/microsoft_calendar/tests/common.py b/addons/microsoft_calendar/tests/common.py index ecfa818052a..c8198f95db4 100644 --- a/addons/microsoft_calendar/tests/common.py +++ b/addons/microsoft_calendar/tests/common.py @@ -3,6 +3,8 @@ from datetime import datetime, timedelta from markupsafe import Markup from unittest.mock import patch, MagicMock +from odoo import fields + from odoo.tests.common import HttpCase from odoo.addons.microsoft_calendar.models.microsoft_sync import MicrosoftSync @@ -56,6 +58,10 @@ class TestCommon(HttpCase): 'partner_id': partner.id, }) + # Add token validity with one hour of time window for properly checking the sync status. + for user in [self.organizer_user, self.attendee_user]: + user.microsoft_calendar_token_validity = fields.Datetime.now() + timedelta(hours=1) + # ----------------------------------------------------------------------------------------- # To create Odoo events # ----------------------------------------------------------------------------------------- diff --git a/addons/microsoft_calendar/tests/test_create_events.py b/addons/microsoft_calendar/tests/test_create_events.py index f64c82317d0..f2b857996a9 100644 --- a/addons/microsoft_calendar/tests/test_create_events.py +++ b/addons/microsoft_calendar/tests/test_create_events.py @@ -1,6 +1,8 @@ from unittest.mock import patch from datetime import timedelta, datetime +from odoo import Command + from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService from odoo.addons.microsoft_calendar.utils.microsoft_event import MicrosoftEvent from odoo.addons.microsoft_calendar.models.res_users import User @@ -379,3 +381,46 @@ class TestCreateEvents(TestCommon): # Assert that the Microsoft Insert was called once. mock_insert.assert_called_once() + + @patch.object(MicrosoftCalendarService, 'get_events') + @patch.object(MicrosoftCalendarService, 'insert') + def test_create_event_for_another_user(self, mock_insert, mock_get_events): + """ + Allow the creation of event for another user only if the proposed user have its Odoo Calendar synced. + User A (self.organizer_user) is creating an event with user B as organizer (self.attendee_user). + """ + # Ensure that the calendar synchronization of user A is active. Deactivate user B synchronization for throwing an error. + self.assertTrue(self.env['calendar.event'].with_user(self.organizer_user)._check_microsoft_sync_status()) + self.attendee_user.microsoft_synchronization_stopped = True + + # Try creating an event with the organizer as the user B (self.attendee_user). + # A ValidationError must be thrown because user B's calendar is not synced. + self.simple_event_values['user_id'] = self.attendee_user.id + self.simple_event_values['partner_ids'] = [Command.set([self.organizer_user.partner_id.id])] + with self.assertRaises(ValidationError): + self.env['calendar.event'].with_user(self.organizer_user).create(self.simple_event_values) + + # Activate the calendar synchronization of user B (self.attendee_user). + self.attendee_user.microsoft_synchronization_stopped = False + self.assertTrue(self.env['calendar.event'].with_user(self.attendee_user)._check_microsoft_sync_status()) + + # Try creating an event with organizer as the user B but not inserting B as an attendee. A ValidationError must be thrown. + with self.assertRaises(ValidationError): + self.env['calendar.event'].with_user(self.organizer_user).create(self.simple_event_values) + + # Set mock return values for the event creation. + event_id = "123" + event_iCalUId = "456" + mock_insert.return_value = (event_id, event_iCalUId) + mock_get_events.return_value = ([], None) + + # Create event matching the creation conditions: user B is synced and now listed as an attendee. Set mock return values. + self.simple_event_values['partner_ids'] = [Command.set([self.organizer_user.partner_id.id, self.attendee_user.partner_id.id])] + event = self.env['calendar.event'].with_user(self.organizer_user).create(self.simple_event_values) + self.call_post_commit_hooks() + event.invalidate_recordset() + + # Ensure that event was inserted and user B (self.attendee_user) is the organizer and is also listed as attendee. + mock_insert.assert_called_once() + self.assertEqual(event.user_id, self.attendee_user, "Event organizer must be user B (self.attendee_user) after event creation by user A (self.organizer_user).") + self.assertTrue(self.attendee_user.partner_id.id in event.partner_ids.ids, "User B (self.attendee_user) should be listed as attendee after event creation.") diff --git a/addons/microsoft_calendar/tests/test_update_events.py b/addons/microsoft_calendar/tests/test_update_events.py index 59791a0c6ec..0e8d5dedb87 100644 --- a/addons/microsoft_calendar/tests/test_update_events.py +++ b/addons/microsoft_calendar/tests/test_update_events.py @@ -5,12 +5,14 @@ import logging import pytz from unittest.mock import patch, ANY +from odoo import Command + from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService from odoo.addons.microsoft_calendar.utils.microsoft_event import MicrosoftEvent from odoo.addons.microsoft_calendar.models.res_users import User from odoo.addons.microsoft_calendar.utils.event_id_storage import combine_ids from odoo.addons.microsoft_calendar.tests.common import TestCommon, mock_get_token, _modified_date_in_the_future, patch_api -from odoo.exceptions import UserError +from odoo.exceptions import UserError, ValidationError _logger = logging.getLogger(__name__) @@ -1296,7 +1298,7 @@ class TestUpdateEvents(TestCommon): self.assertFalse(self.env.user.microsoft_synchronization_stopped) # Simulate upgrade of a simple event to recurrent event (forbidden). - simple_event = self.env['calendar.event'].create(self.simple_event_values) + simple_event = self.env['calendar.event'].with_user(self.organizer_user).create(self.simple_event_values) with self.assertRaises(UserError): simple_event.write({ 'recurrency': True, @@ -1335,3 +1337,59 @@ class TestUpdateEvents(TestCommon): self.assertEqual(self.organizer_user._get_microsoft_sync_status(), "sync_paused") self.assertTrue(self.simple_event.need_sync_m, "Sync variable must be true for updating event when sync re-activates") mock_patch.assert_not_called() + + @patch.object(MicrosoftCalendarService, 'get_events') + @patch.object(MicrosoftCalendarService, 'delete') + @patch.object(MicrosoftCalendarService, 'insert') + def test_changing_event_organizer_to_another_user(self, mock_insert, mock_delete, mock_get_events): + """ + Allow editing the event organizer to another user only if the proposed organizer have its Odoo Calendar synced. + The current event is deleted and then recreated with the new organizer. + An event with organizer as user A (self.organizer_user) will have its organizer changed to user B (self.attendee_user). + """ + # Create event with organizer as user A and only the organizer as attendee. + self.assertTrue(self.env['calendar.event'].with_user(self.attendee_user)._check_microsoft_sync_status()) + self.simple_event_values['user_id'] = self.organizer_user.id + self.simple_event_values['partner_ids'] = [Command.set([self.organizer_user.partner_id.id])] + event = self.env['calendar.event'].with_user(self.organizer_user).create(self.simple_event_values) + + # Deactivate user B's calendar synchronization. Try changing the event organizer to user B. + # A ValidationError must be thrown because user B's calendar is not synced. + self.attendee_user.microsoft_synchronization_stopped = True + with self.assertRaises(ValidationError): + event.with_user(self.organizer_user).write({'user_id': self.attendee_user.id}) + + # Activate user B's calendar synchronization and try again without listing user B as an attendee. + # Another ValidationError must be thrown. + self.attendee_user.microsoft_synchronization_stopped = False + with self.assertRaises(ValidationError): + event.with_user(self.organizer_user).write({'user_id': self.attendee_user.id}) + + # Set mock return values for the event re-creation. + event_id = "123" + event_iCalUId = "456" + mock_insert.return_value = (event_id, event_iCalUId) + mock_get_events.return_value = ([], None) + + # Change the event organizer: user B (the organizer) is synced and now listed as an attendee. + event.ms_universal_event_id = "test_id_for_event" + event.ms_organizer_event_id = "test_id_for_organizer" + event.with_user(self.organizer_user).write({ + 'user_id': self.attendee_user.id, + 'partner_ids': [Command.set([self.organizer_user.partner_id.id, self.attendee_user.partner_id.id])] + }) + new_event = self.env["calendar.event"].search([("id", ">", event.id)]) + self.call_post_commit_hooks() + new_event.invalidate_recordset() + + # Ensure that the event was deleted and recreated with the new organizer and the organizer listed as attendee. + mock_delete.assert_any_call( + event.ms_organizer_event_id, + token=mock_get_token(self.attendee_user), + timeout=ANY, + ) + self.assertEqual(len(new_event), 1, "A single event should be created after updating the organizer.") + self.assertEqual(new_event.user_id, self.attendee_user, + "The event organizer must be user B (self.attendee_user) after the event organizer update.") + self.assertTrue(self.attendee_user.partner_id.id in new_event.partner_ids.ids, + "User B (self.attendee_user) should be listed as attendee after the event organizer update.")