diff --git a/addons/microsoft_calendar/i18n/microsoft_calendar.pot b/addons/microsoft_calendar/i18n/microsoft_calendar.pot index e6e6370659c..b7553e02a5a 100644 --- a/addons/microsoft_calendar/i18n/microsoft_calendar.pot +++ b/addons/microsoft_calendar/i18n/microsoft_calendar.pot @@ -150,6 +150,33 @@ msgstr "" msgid "Display Name" msgstr "" +#. module: microsoft_calendar +#. odoo-python +#: code:addons/microsoft_calendar/models/calendar.py:0 +#, python-format +msgid "" +"Due to an Outlook Calendar limitation, recurrence updates must be done " +"directly in Outlook Calendar." +msgstr "" + +#. module: microsoft_calendar +#. odoo-python +#: code:addons/microsoft_calendar/models/calendar.py:0 +#, python-format +msgid "" +"Due to an Outlook Calendar limitation, recurrence updates must be done directly in Outlook Calendar.\n" +"If this recurrence is not shown in Outlook Calendar, you must delete it in Odoo Calendar and recreate it in Outlook Calendar." +msgstr "" + +#. module: microsoft_calendar +#. odoo-python +#: code:addons/microsoft_calendar/models/calendar.py:0 +#, python-format +msgid "" +"Due to an Outlook Calendar limitation, recurrent events must be created " +"directly in Outlook Calendar." +msgstr "" + #. module: microsoft_calendar #: model:ir.model,name:microsoft_calendar.model_calendar_recurrence msgid "Event Recurrence Rule" diff --git a/addons/microsoft_calendar/models/calendar.py b/addons/microsoft_calendar/models/calendar.py index 09fae1be09d..aba66fabb6d 100644 --- a/addons/microsoft_calendar/models/calendar.py +++ b/addons/microsoft_calendar/models/calendar.py @@ -57,10 +57,23 @@ class Meeting(models.Model): 'need_sync_m': True, }) + def _check_microsoft_sync_status(self): + """ + Returns True if synchronization with Outlook Calendar is active and False otherwise. + The 'microsoft_synchronization_stopped' variable needs to be 'False' and Outlook account must be connected. + """ + outlook_connected = self.env.user._get_microsoft_calendar_token() and self.env.user._is_microsoft_calendar_valid() + return outlook_connected and self.env.user.microsoft_synchronization_stopped is False + @api.model_create_multi def create(self, vals_list): notify_context = self.env.context.get('dont_notify', False) + # Forbid recurrence creation in Odoo, suggest its creation in Outlook due to the spam limitation. + recurrency_in_batch = any(vals.get('recurrency') for vals in vals_list) + if self._check_microsoft_sync_status() and not notify_context and recurrency_in_batch: + self._forbid_recurrence_creation() + # 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. @@ -103,8 +116,34 @@ class Meeting(models.Model): return (event_start, event_stop) == (start, stop) + def _forbid_recurrence_update(self): + """ + Suggest user to update recurrences in Outlook due to the Outlook Calendar spam limitation. + """ + error_msg = _("Due to an Outlook Calendar limitation, recurrence updates must be done directly in Outlook Calendar.") + if any(not record.microsoft_id for record in self): + # If any event is not synced, suggest deleting it in Odoo and recreating it in Outlook. + error_msg = _( + "Due to an Outlook Calendar limitation, recurrence updates must be done directly in Outlook Calendar.\n" + "If this recurrence is not shown in Outlook Calendar, you must delete it in Odoo Calendar and recreate it in Outlook Calendar.") + + raise UserError(error_msg) + + def _forbid_recurrence_creation(self): + """ + Suggest user to update recurrences in Outlook due to the Outlook Calendar spam limitation. + """ + raise UserError(_("Due to an Outlook Calendar limitation, recurrent events must be created directly in Outlook Calendar.")) + def write(self, values): recurrence_update_setting = values.get('recurrence_update') + notify_context = self.env.context.get('dont_notify', False) + + # Forbid recurrence updates through Odoo and suggest user to update it in Outlook. + if self._check_microsoft_sync_status(): + recurrency_in_batch = self.filtered(lambda ev: ev.recurrency) + if not notify_context and (recurrence_update_setting or 'recurrency' in values or recurrency_in_batch): + self._forbid_recurrence_update() # check a Outlook limitation in overlapping the actual recurrence if recurrence_update_setting == 'self_only' and 'start' in values: @@ -117,7 +156,6 @@ class Meeting(models.Model): e._microsoft_delete(e._get_organizer(), e.ms_organizer_event_id, timeout=3) e.microsoft_id = False - notify_context = self.env.context.get('dont_notify', False) res = super(Meeting, self.with_context(dont_notify=notify_context)).write(values) if recurrence_update_setting in ('all_events',) and len(self) == 1 \ @@ -125,6 +163,12 @@ class Meeting(models.Model): self.recurrence_id.need_sync_m = True return res + def action_mass_archive(self, recurrence_update_setting): + # Do not allow archiving if recurrence is synced with Outlook. Suggest updating directly from Outlook. + if self._check_microsoft_sync_status() and any(self.microsoft_id): + self._forbid_recurrence_update() + super().action_mass_archive(recurrence_update_setting) + def _get_microsoft_sync_domain(self): # in case of full sync, limit to a range of 1y in past and 1y in the future by default ICP = self.env['ir.config_parameter'].sudo() @@ -135,7 +179,6 @@ class Meeting(models.Model): ('partner_ids.user_ids', 'in', self.env.user.id), ('stop', '>', lower_bound), ('start', '<', upper_bound), - # Do not sync events that follow the recurrence, they are already synced at recurrence creation '!', '&', '&', ('recurrency', '=', True), ('recurrence_id', '!=', False), ('follow_recurrence', '=', True) ] return self._extend_microsoft_domain(domain) diff --git a/addons/microsoft_calendar/models/calendar_recurrence_rule.py b/addons/microsoft_calendar/models/calendar_recurrence_rule.py index 4c1113e3333..46642f17063 100644 --- a/addons/microsoft_calendar/models/calendar_recurrence_rule.py +++ b/addons/microsoft_calendar/models/calendar_recurrence_rule.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models +from odoo.osv import expression class RecurrenceRule(models.Model): @@ -121,7 +122,7 @@ class RecurrenceRule(models.Model): | self.env["calendar.event"]._get_recurrent_fields() ) # We avoid to write time_fields because they are not shared between events. - self._write_events(dict({ + self.with_context(dont_notify=True)._write_events(dict({ field: value for field, value in new_event_values.items() if field not in time_fields @@ -135,15 +136,12 @@ class RecurrenceRule(models.Model): detached_events.unlink() def _get_microsoft_sync_domain(self): - # Empty rrule may exists in historical data. It is not a desired behavior but it could have been created with - # older versions of the module. When synced, these recurrence may come back from Microsoft after database cleaning - # and trigger errors as the records are not properly populated. - # We also prevent sync of other user recurrent events. - domain = [('calendar_event_ids.user_id', '=', self.env.user.id), ('rrule', '!=', False)] + # Do not sync Odoo recurrences with Outlook Calendar anymore. + domain = expression.FALSE_DOMAIN return self._extend_microsoft_domain(domain) def _cancel_microsoft(self): - self.calendar_event_ids._cancel_microsoft() + self.calendar_event_ids.with_context(dont_notify=True)._cancel_microsoft() super()._cancel_microsoft() @api.model diff --git a/addons/microsoft_calendar/models/microsoft_sync.py b/addons/microsoft_calendar/models/microsoft_sync.py index 45157324dac..0a99f63b3d7 100644 --- a/addons/microsoft_calendar/models/microsoft_sync.py +++ b/addons/microsoft_calendar/models/microsoft_sync.py @@ -164,11 +164,11 @@ class MicrosoftSync(models.AbstractModel): return super().unlink() def _write_from_microsoft(self, microsoft_event, vals): - self.write(vals) + self.with_context(dont_notify=True).write(vals) @api.model def _create_from_microsoft(self, microsoft_event, vals_list): - return self.create(vals_list) + return self.with_context(dont_notify=True).create(vals_list) def _sync_odoo2microsoft(self): if not self: @@ -236,7 +236,7 @@ class MicrosoftSync(models.AbstractModel): to_create_values += [dict(value, need_sync_m=False)] new_calendar_recurrence['calendar_event_ids'] = [(0, 0, to_create_value) for to_create_value in to_create_values] - new_recurrence_odoo = self.env['calendar.recurrence'].create(new_calendar_recurrence) + new_recurrence_odoo = self.env['calendar.recurrence'].with_context(dont_notify=True).create(new_calendar_recurrence) new_recurrence_odoo.base_event_id = new_recurrence_odoo.calendar_event_ids[0] if new_recurrence_odoo.calendar_event_ids else False new_recurrence |= new_recurrence_odoo @@ -305,11 +305,11 @@ class MicrosoftSync(models.AbstractModel): odoo_event = self.env['calendar.event'].browse(e.odoo_id(self.env)).exists().with_context( no_mail_to_attendees=True, mail_create_nolog=True ) - odoo_event.write(dict(event_values, need_sync_m=False)) + odoo_event.with_context(dont_notify=True).write(dict(event_values, need_sync_m=False)) update_events |= odoo_event # update the recurrence - detached_events = self._apply_recurrence(rec_values) + detached_events = self.with_context(dont_notify=True)._apply_recurrence(rec_values) detached_events._cancel_microsoft() return update_events @@ -368,7 +368,7 @@ class MicrosoftSync(models.AbstractModel): if ms_event_updated_time >= odoo_event_updated_time: vals = dict(odoo_event._microsoft_to_odoo_values(mevent), need_sync_m=False) - odoo_event._write_from_microsoft(mevent, vals) + odoo_event.with_context(dont_notify=True)._write_from_microsoft(mevent, vals) if odoo_event._name == 'calendar.recurrence': update_events = odoo_event._update_microsoft_recurrence(mevent, microsoft_events) diff --git a/addons/microsoft_calendar/tests/common.py b/addons/microsoft_calendar/tests/common.py index 84e0bd011c3..9a370f1e3fd 100644 --- a/addons/microsoft_calendar/tests/common.py +++ b/addons/microsoft_calendar/tests/common.py @@ -406,6 +406,13 @@ class TestCommon(HttpCase): ] self.env.cr.postcommit.clear() + def sync_odoo_recurrences_with_outlook_feature(self): + """ + Returns the status of the recurrence synchronization feature with Outlook. + True if it is active and False otherwise. This function guides previous tests to abort before they are checked. + """ + return False + def create_events_for_tests(self): """ Create some events for test purpose @@ -443,27 +450,35 @@ class TestCommon(HttpCase): ) already_created = self.recurrent_base_event + # Currently, it is forbidden to create recurrences in Odoo. A trick for deactivating the checking + # is needed below in this test setup: deactivating the synchronization during recurrences creation. + sync_previous_state = self.env.user.microsoft_synchronization_stopped + self.env.user.microsoft_synchronization_stopped = False + if not already_created: - self.recurrent_base_event = self.env["calendar.event"].with_user(self.organizer_user).create( + self.recurrent_base_event = self.env["calendar.event"].with_context(dont_notify=True).with_user(self.organizer_user).create( self.recurrent_event_values ) self.recurrence = self.env["calendar.recurrence"].search([("base_event_id", "=", self.recurrent_base_event.id)]) # set ids set by Outlook if not already_created: - self.recurrence.write({ + self.recurrence.with_context(dont_notify=True).write({ "microsoft_id": combine_ids("REC123", "REC456"), }) for i, e in enumerate(self.recurrence.calendar_event_ids.sorted(key=lambda r: r.start)): - e.write({ + e.with_context(dont_notify=True).write({ "microsoft_id": combine_ids(f"REC123_EVENT_{i+1}", f"REC456_EVENT_{i+1}"), "microsoft_recurrence_master_id": "REC123", }) self.recurrence.invalidate_recordset() self.recurrence.calendar_event_ids.invalidate_recordset() - self.recurrent_events = self.recurrence.calendar_event_ids.sorted(key=lambda r: r.start) - self.recurrent_events_count = len(self.recurrent_events) + self.recurrent_events = self.recurrence.calendar_event_ids.sorted(key=lambda r: r.start) + self.recurrent_events_count = len(self.recurrent_events) + + # Rollback the synchronization status after setup. + self.env.user.microsoft_synchronization_stopped = sync_previous_state def assert_odoo_event(self, odoo_event, expected_values): """ diff --git a/addons/microsoft_calendar/tests/test_create_events.py b/addons/microsoft_calendar/tests/test_create_events.py index 605335cd690..43568784a5c 100644 --- a/addons/microsoft_calendar/tests/test_create_events.py +++ b/addons/microsoft_calendar/tests/test_create_events.py @@ -1,15 +1,15 @@ +from datetime import datetime, timedelta from unittest.mock import patch 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.tests.common import TestCommon, mock_get_token -from odoo.exceptions import ValidationError +from odoo.exceptions import ValidationError, UserError @patch.object(User, '_get_microsoft_calendar_token', mock_get_token) class TestCreateEvents(TestCommon): - @patch.object(MicrosoftCalendarService, 'insert') def test_create_simple_event_without_sync(self, mock_insert): """ @@ -84,9 +84,7 @@ class TestCreateEvents(TestCommon): self.assertEqual(new_records.user_id, self.organizer_user) @patch.object(MicrosoftCalendarService, 'get_events') - def test_create_simple_event_from_outlook_attendee_calendar_and_organizer_does_not_exist_in_odoo( - self, mock_get_events - ): + def test_create_simple_event_from_outlook_attendee_calendar_and_organizer_does_not_exist_in_odoo(self, mock_get_events): """ An event has been created in Outlook and synced in the Odoo attendee calendar. no Odoo user that matches with the organizer email address. @@ -116,6 +114,8 @@ class TestCreateEvents(TestCommon): """ A Odoo recurrent event is created when Outlook sync is not enabled. """ + if not self.sync_odoo_recurrences_with_outlook_feature(): + return # arrange self.organizer_user.microsoft_synchronization_stopped = True @@ -135,6 +135,8 @@ class TestCreateEvents(TestCommon): """ A Odoo recurrent event is created when Outlook sync is enabled. """ + if not self.sync_odoo_recurrences_with_outlook_feature(): + return # >>> first phase: create the recurrence @@ -178,7 +180,8 @@ class TestCreateEvents(TestCommon): should happen as it we prevent sync of recurrences from other users ( see microsoft_calendar/models/calendar_recurrence_rule.py::_get_microsoft_sync_domain() ) """ - + if not self.sync_odoo_recurrences_with_outlook_feature(): + return # >>> first phase: create the recurrence # act @@ -257,3 +260,21 @@ class TestCreateEvents(TestCommon): self.assert_odoo_recurrence(new_recurrences, self.expected_odoo_recurrency_from_outlook) for i, e in enumerate(sorted(new_events, key=lambda e: e.id)): self.assert_odoo_event(e, self.expected_odoo_recurrency_events_from_outlook[i]) + + @patch.object(MicrosoftCalendarService, 'insert') + def test_forbid_recurrences_creation_synced_outlook_calendar(self, mock_insert): + """ + Forbids new recurrences creation in Odoo due to Outlook spam limitation of updating recurrent events. + """ + # Set custom calendar token validity to simulate real scenario. + self.env.user.microsoft_calendar_token_validity = datetime.now() + timedelta(minutes=5) + + # Assert that synchronization with Outlook is active. + self.assertFalse(self.env.user.microsoft_synchronization_stopped) + + with self.assertRaises(UserError): + self.env["calendar.event"].create( + self.recurrent_event_values + ) + # Assert that no insert call was made. + mock_insert.assert_not_called() diff --git a/addons/microsoft_calendar/tests/test_delete_events.py b/addons/microsoft_calendar/tests/test_delete_events.py index 71896521493..4fed7cd5120 100644 --- a/addons/microsoft_calendar/tests/test_delete_events.py +++ b/addons/microsoft_calendar/tests/test_delete_events.py @@ -86,17 +86,19 @@ class TestDeleteEvents(TestCommon): """ Archive several events at once should not produce any exception. """ + # arrange + several_simple_events = self.several_events.filtered(lambda ev: not ev.recurrency and ev.microsoft_id) # act - self.several_events.action_archive() + several_simple_events.action_archive() self.call_post_commit_hooks() - self.several_events.invalidate_recordset() + several_simple_events.invalidate_recordset() # assert - self.assertFalse(all(e.active for e in self.several_events)) + self.assertFalse(all(e.active for e in several_simple_events)) mock_delete.assert_has_calls([ call(e.ms_organizer_event_id, token=ANY, timeout=ANY) - for e in self.several_events + for e in several_simple_events ]) @patch.object(MicrosoftCalendarService, 'get_events') @@ -132,7 +134,6 @@ class TestDeleteEvents(TestCommon): @patch.object(MicrosoftCalendarService, 'delete') def test_delete_one_event_from_recurrence_from_odoo_calendar(self, mock_delete): - # arrange idx = 2 event_id = self.recurrent_events[idx].ms_organizer_event_id @@ -199,7 +200,6 @@ class TestDeleteEvents(TestCommon): @patch.object(MicrosoftCalendarService, 'get_events') def test_delete_first_event_from_recurrence_from_outlook_calendar(self, mock_get_events): - # arrange rec_values = [ dict( @@ -223,6 +223,8 @@ class TestDeleteEvents(TestCommon): @patch.object(MicrosoftCalendarService, 'get_events') def test_delete_one_event_and_future_from_recurrence_from_outlook_calendar(self, mock_get_events): + if not self.sync_odoo_recurrences_with_outlook_feature(): + return # arrange idx = range(4, self.recurrent_events_count) rec_values = [ @@ -259,7 +261,7 @@ class TestDeleteEvents(TestCommon): ) # act - self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + self.organizer_user.with_context(dont_notify=True).with_user(self.organizer_user).sudo()._sync_microsoft_calendar() # assert self.assertFalse(self.recurrence.exists()) @@ -276,6 +278,8 @@ class TestDeleteEvents(TestCommon): """ Deletes the base_event of a recurrence and checks if the event was archived and the recurrence was updated. """ + if not self.sync_odoo_recurrences_with_outlook_feature(): + return # arrange idx = 0 event_id = self.recurrent_events[idx].ms_organizer_event_id diff --git a/addons/microsoft_calendar/tests/test_update_events.py b/addons/microsoft_calendar/tests/test_update_events.py index 86a9bf7337b..bd025dc0d8d 100644 --- a/addons/microsoft_calendar/tests/test_update_events.py +++ b/addons/microsoft_calendar/tests/test_update_events.py @@ -102,7 +102,8 @@ class TestUpdateEvents(TestCommon): """ Update one Odoo event name from a recurrence from the organizer calendar. """ - + if not self.sync_odoo_recurrences_with_outlook_feature(): + return # arrange new_name = "my specific event in recurrence" modified_event_id = 4 @@ -135,7 +136,8 @@ class TestUpdateEvents(TestCommon): """ Update one Odoo event start date from a recurrence from the organizer calendar. """ - + if not self.sync_odoo_recurrences_with_outlook_feature(): + return # arrange new_date = datetime(2021, 9, 29, 10, 0, 0) modified_event_id = 4 @@ -182,6 +184,8 @@ class TestUpdateEvents(TestCommon): Update one Odoo event start date from a recurrence from the organizer calendar, in order to overlap another existing event. """ + if not self.sync_odoo_recurrences_with_outlook_feature(): + return # arrange new_date = datetime(2021, 9, 27, 10, 0, 0) modified_event_id = 4 @@ -203,7 +207,8 @@ class TestUpdateEvents(TestCommon): """ Update one Odoo event name from a recurrence from the atendee calendar. """ - + if not self.sync_odoo_recurrences_with_outlook_feature(): + return # arrange new_name = "my specific event in recurrence" modified_event_id = 4 @@ -238,7 +243,8 @@ class TestUpdateEvents(TestCommon): """ Update a Odoo event name and future events from a recurrence from the organizer calendar. """ - + if not self.sync_odoo_recurrences_with_outlook_feature(): + return # arrange new_name = "my specific event in recurrence" modified_event_id = 4 @@ -277,7 +283,8 @@ class TestUpdateEvents(TestCommon): """ Update a Odoo event start date and future events from a recurrence from the organizer calendar. """ - + if not self.sync_odoo_recurrences_with_outlook_feature(): + return # When a time-related field is changed, the event does not follow the recurrence scheme anymore. # With Outlook, another constraint is that the new start of the event cannot overlap/cross the start # date of another event of the recurrence (see microsoft_calendar/models/calendar.py @@ -354,7 +361,8 @@ class TestUpdateEvents(TestCommon): Update a Odoo event start date and future events from a recurrence from the organizer calendar, overlapping an existing event. """ - + if not self.sync_odoo_recurrences_with_outlook_feature(): + return # arrange new_date = datetime(2021, 9, 27, 10, 0, 0) modified_event_id = 4 @@ -425,7 +433,8 @@ class TestUpdateEvents(TestCommon): """ Update a Odoo event name and future events from a recurrence from the attendee calendar. """ - + if not self.sync_odoo_recurrences_with_outlook_feature(): + return # arrange new_date = datetime(2021, 9, 29, 10, 0, 0) modified_event_id = 4 @@ -494,7 +503,8 @@ class TestUpdateEvents(TestCommon): """ Update all events name from a recurrence from the organizer calendar. """ - + if not self.sync_odoo_recurrences_with_outlook_feature(): + return # arrange new_name = "my specific event in recurrence" @@ -528,7 +538,8 @@ class TestUpdateEvents(TestCommon): """ Update all events start date from a recurrence from the organizer calendar. """ - + if not self.sync_odoo_recurrences_with_outlook_feature(): + return # arrange new_date = datetime(2021, 9, 25, 10, 0, 0) existing_recurrences = self.env["calendar.recurrence"].search([]) @@ -591,7 +602,8 @@ class TestUpdateEvents(TestCommon): """ Update all events start date from a recurrence from the attendee calendar. """ - + if not self.sync_odoo_recurrences_with_outlook_feature(): + return # arrange new_date = datetime(2021, 9, 25, 10, 0, 0) existing_recurrences = self.env["calendar.recurrence"].search([]) @@ -1271,3 +1283,34 @@ class TestUpdateEvents(TestCommon): e.start.strftime("%Y-%m-%dT%H:%M:%S.0000000"), ms_events_to_update[e.ms_organizer_event_id]["dateTime"] ) + + @patch.object(MicrosoftCalendarService, 'patch') + def test_forbid_simple_event_become_recurrence_sync_on(self, mock_patch): + """ + Forbid in Odoo simple event becoming a recurrence when Outlook Calendar sync is active. + """ + # Set custom calendar token validity to simulate real scenario. + self.env.user.microsoft_calendar_token_validity = datetime.now() + timedelta(minutes=5) + + # Assert that synchronization with Outlook Calendar is active. + 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) + with self.assertRaises(UserError): + simple_event.write({ + 'recurrency': True, + 'rrule_type': 'weekly', + 'event_tz': 'America/Sao_Paulo', + 'end_type': 'count', + 'interval': 1, + 'count': 1, + 'fri': True, + 'month_by': 'date', + 'day': 1, + 'weekday': 'FRI', + 'byday': '2' + }) + + # Assert that no patch call was made due to the recurrence update forbiddance. + mock_patch.assert_not_called()