[FIX] microsoft_calendar: no sync for recurrent events

Before this commit, updating recurring events was troublesome due to an Outlook limitation, which was sending spam to attendees. After this commit, when updating recurring events, it is suggested for users to update recurrences directly in Outlook Calendar to handle this limitation. It is not allowed anymore creating recurrences in Odoo when the sync with Outlook is active (although recurrences created in Outlook are still synchronized in Odoo). Recurrent events that were created before the synchronization start (which are not synced) can still be deleted in Odoo (then a suggestion of recreating them in Outlook is shown).

Previous unit tests regarding the synchronization of recurrences from Odoo to Outlook were deactivated. New tests were added asserting the forbiddance of this recurrence creation and update flow.

closes odoo/odoo#131806

Task-id: 3204905
X-original-commit: 27ee51029c1d4e9164b9705306b59b26598bcf7e
Signed-off-by: Arnaud Joset (arj) <arj@odoo.com>
Signed-off-by: Gabriel de Paula Felix (gdpf) <gdpf@odoo.com>
This commit is contained in:
Gabriel de Paula Felix (gdpf)
2023-08-11 22:50:24 +02:00
parent 31ff397851
commit 29ce2f0451
8 changed files with 194 additions and 43 deletions
@@ -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"
+45 -2
View File
@@ -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)
@@ -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
@@ -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)
+20 -5
View File
@@ -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):
"""
@@ -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()
@@ -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
@@ -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()