From 55b452ac0056c7075097fa1324ef53fc5090fb7a Mon Sep 17 00:00:00 2001 From: "Gabriel de Paula Felix (gdpf)" Date: Fri, 1 Dec 2023 14:03:11 -0300 Subject: [PATCH] [FIX] microsoft_calendar: missing attendees confirmation Before this commit, when a user was invited to an event from an account outside Outlook (e.g. Google account), accepting the invitation in Outlook Calendar didn't send the 'accept' status to Odoo Calendar, and vice-versa. This problem was also happening with recurrent events accepted in Outlook, the events weren't being accepted in Odoo Calendar correctly. Additionaly, when 2+ attendees from Odoo were synced (having the organizer as an external user), only the last user to sync the event was able to answer it in Odoo due to its outdated 'microsoft_id'. After this commit, both ways are fixed: accepting an event in Outlook Calendar or Odoo Calendar notifies the other calendar correctly. For recurrent events, accepting them in Outlook Calendar also updates the events status to 'accept' in Odoo Calendar (reminder: due to a limitation in Outlook Calendar synchronization, when the synchronization is active recurrent events acceptance can only be done through Outlook). In addition, all attendees from Odoo can now answer an event that came from external organizers. closes odoo/odoo#144690 Task-id: 3290922 X-original-commit: a0e628985a19f732830a1f4a732ee35e5f779fe1 Signed-off-by: Arnaud Joset (arj) Signed-off-by: Gabriel de Paula Felix (gdpf) --- addons/calendar/models/calendar_event.py | 2 +- addons/microsoft_calendar/models/calendar.py | 29 ++++++- .../models/calendar_attendee.py | 11 ++- .../models/microsoft_sync.py | 18 +++-- .../tests/test_answer_events.py | 79 ++++++++++++------- .../tests/test_update_events.py | 2 +- .../utils/microsoft_calendar.py | 8 ++ 7 files changed, 105 insertions(+), 44 deletions(-) diff --git a/addons/calendar/models/calendar_event.py b/addons/calendar/models/calendar_event.py index 50102b0fc5b..fdc52eff4e4 100644 --- a/addons/calendar/models/calendar_event.py +++ b/addons/calendar/models/calendar_event.py @@ -681,7 +681,7 @@ class Meeting(models.Model): self.recurrence_id._setup_alarms(recurrence_update=True) if not self.recurrence_id: self._setup_alarms() - attendee_update_events = self.filtered(lambda ev: ev.user_id != self.env.user) + attendee_update_events = self.filtered(lambda ev: ev.user_id and ev.user_id != self.env.user) if update_time and attendee_update_events: # Another user update the event time fields. It should not be auto accepted for the organizer. # This prevent weird behavior when a user modified future events time fields and diff --git a/addons/microsoft_calendar/models/calendar.py b/addons/microsoft_calendar/models/calendar.py index 565b65da93c..b46705888dd 100644 --- a/addons/microsoft_calendar/models/calendar.py +++ b/addons/microsoft_calendar/models/calendar.py @@ -200,6 +200,11 @@ class Meeting(models.Model): event.microsoft_id = False deactivated_events = self.browse(deactivated_events_ids) + # Update attendee status before 'values' variable is overridden in super. + attendee_ids = values.get('attendee_ids') + if attendee_ids and values.get('partner_ids'): + (self - deactivated_events)._update_attendee_status(attendee_ids) + res = super(Meeting, (self - deactivated_events).with_context(dont_notify=notify_context)).write(values) # Deactivate events that were recreated after changing organizer. @@ -234,6 +239,20 @@ class Meeting(models.Model): partner_ids.append(command[2].get('partner_id')) return sender_user, partner_ids + def _update_attendee_status(self, attendee_ids): + """ Merge current status from 'attendees_ids' with new attendees values for avoiding their info loss in write(). + Create a dict getting the state of each attendee received from 'attendee_ids' variable and then update their state. + :param attendee_ids: List of attendee commands carrying a dict with 'partner_id' and 'state' keys in its third position. + """ + state_by_partner = {} + for cmd in attendee_ids: + if len(cmd) == 3 and isinstance(cmd[2], dict) and all(key in cmd[2] for key in ['partner_id', 'state']): + state_by_partner[cmd[2]['partner_id']] = cmd[2]['state'] + for attendee in self.attendee_ids: + state_update = state_by_partner.get(attendee.partner_id.id) + if state_update: + attendee.state = state_update + 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() @@ -360,7 +379,13 @@ class Meeting(models.Model): partners = self.env['mail.thread']._mail_find_partner_from_emails(emails, records=self, force_create=True) attendees_by_emails = {a.email: a for a in existing_attendees} for email, partner, attendee_info in zip(emails, partners, microsoft_attendees): - state = ATTENDEE_CONVERTER_M2O.get(attendee_info.get('status').get('response'), 'needsAction') + # Responses from external invitations are stored in the 'responseStatus' field. + # This field only carries the current user's event status because Microsoft hides other user's status. + if self.env.user.email == email and microsoft_event.responseStatus: + attendee_microsoft_status = microsoft_event.responseStatus.get('response', 'none') + else: + attendee_microsoft_status = attendee_info.get('status').get('response') + state = ATTENDEE_CONVERTER_M2O.get(attendee_microsoft_status, 'needsAction') if email in attendees_by_emails: # Update existing attendees @@ -608,7 +633,7 @@ class Meeting(models.Model): 2) the organizer is NOT an Odoo user: any attendee should remove the Odoo event. """ user = self.env.user - records = self.filtered(lambda e: not e.user_id or e.user_id == user) + records = self.filtered(lambda e: not e.user_id or e.user_id == user or user.partner_id in e.partner_ids) super(Meeting, records)._cancel_microsoft() attendees = (self - records).attendee_ids.filtered(lambda a: a.partner_id == user.partner_id) attendees.do_decline() diff --git a/addons/microsoft_calendar/models/calendar_attendee.py b/addons/microsoft_calendar/models/calendar_attendee.py index 61b73282702..b6e52cefb66 100644 --- a/addons/microsoft_calendar/models/calendar_attendee.py +++ b/addons/microsoft_calendar/models/calendar_attendee.py @@ -43,9 +43,8 @@ class Attendee(models.Model): params = {"comment": "", "sendResponse": True} # Microsoft prevent user to answer the meeting when they are the organizer linked_events = self.event_id._get_synced_events() - for event in linked_events.filtered(lambda e: e.user_id != self.env.user): - event._microsoft_patch( - event._get_organizer(), - event.ms_organizer_event_id, - event._microsoft_values(["attendee_ids"]), - ) + for event in linked_events: + if event._check_microsoft_sync_status() and self.env.user != event.user_id and self.env.user.partner_id in event.partner_ids: + if event.recurrency: + event._forbid_recurrence_update() + event._microsoft_attendee_answer(answer, params) diff --git a/addons/microsoft_calendar/models/microsoft_sync.py b/addons/microsoft_calendar/models/microsoft_sync.py index a23adb07350..f173faf4bb4 100644 --- a/addons/microsoft_calendar/models/microsoft_sync.py +++ b/addons/microsoft_calendar/models/microsoft_sync.py @@ -451,13 +451,17 @@ class MicrosoftSync(models.AbstractModel): with microsoft_calendar_token(self.env.user.sudo()) as token: if token: self._ensure_attendees_have_email() - microsoft_service.answer( - self.ms_organizer_event_id, - answer, params, token=token, timeout=timeout - ) - self.write({ - 'need_sync_m': False, - }) + # Fetch the event's id (ms_organizer_event_id) using its iCalUId (ms_universal_event_id) since the + # former differs for each attendee. This info is required for sending the event answer and Odoo currently + # saves the event's id of the last user who synced the event (who might be or not the current user). + status, event = microsoft_service._get_single_event(self.ms_universal_event_id, token=token) + if status and event and event.get('value') and len(event.get('value')) == 1: + # Send the attendee answer with its own ms_organizer_event_id. + res = microsoft_service.answer( + event.get('value')[0].get('id'), + answer, params, token=token, timeout=timeout + ) + self.need_sync_m = not res def _get_microsoft_records_to_sync(self, full_sync=False): """ diff --git a/addons/microsoft_calendar/tests/test_answer_events.py b/addons/microsoft_calendar/tests/test_answer_events.py index 9c8e2910a0d..1adfed716c8 100644 --- a/addons/microsoft_calendar/tests/test_answer_events.py +++ b/addons/microsoft_calendar/tests/test_answer_events.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from unittest.mock import patch, ANY +from datetime import datetime, timedelta from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService from odoo.addons.microsoft_calendar.utils.microsoft_event import MicrosoftEvent @@ -27,51 +28,47 @@ class TestAnswerEvents(TestCommon): microsoft_id=combine_ids("123", "456"), ) ) + (self.organizer_user | self.attendee_user).microsoft_calendar_token_validity = datetime.now() + timedelta(hours=1) - @patch.object(MicrosoftCalendarService, 'patch') - def test_attendee_accepts_event_from_odoo_calendar(self, mock_patch): + @patch.object(MicrosoftCalendarService, '_get_single_event') + @patch.object(MicrosoftCalendarService, 'answer') + def test_attendee_accepts_event_from_odoo_calendar(self, mock_answer, mock_get_single_event): attendee = self.env["calendar.attendee"].search([ ('event_id', '=', self.simple_event.id), ('partner_id', '=', self.attendee_user.partner_id.id) ]) - + attendee_ms_organizer_event_id = 100 + mock_get_single_event.return_value = (True, {'value': [{'id': attendee_ms_organizer_event_id}]}) attendee.with_user(self.attendee_user).do_accept() self.call_post_commit_hooks() self.simple_event.invalidate_recordset() - mock_patch.assert_called_once_with( - self.simple_event.ms_organizer_event_id, - { - "attendees": [{ - 'emailAddress': {'address': attendee.email or '', 'name': attendee.display_name or ''}, - 'status': {'response': 'accepted'} - }] - }, - token=mock_get_token(self.organizer_user), - timeout=ANY, + mock_answer.assert_called_once_with( + attendee_ms_organizer_event_id, + 'accept', + {"comment": "", "sendResponse": True}, + token=mock_get_token(self.attendee_user), + timeout=20, ) - @patch.object(MicrosoftCalendarService, 'patch') - def test_attendee_declines_event_from_odoo_calendar(self, mock_patch): + @patch.object(MicrosoftCalendarService, '_get_single_event') + @patch.object(MicrosoftCalendarService, 'answer') + def test_attendee_declines_event_from_odoo_calendar(self, mock_answer, mock_get_single_event): attendee = self.env["calendar.attendee"].search([ ('event_id', '=', self.simple_event.id), ('partner_id', '=', self.attendee_user.partner_id.id) ]) - + attendee_ms_organizer_event_id = 100 + mock_get_single_event.return_value = (True, {'value': [{'id': attendee_ms_organizer_event_id}]}) attendee.with_user(self.attendee_user).do_decline() self.call_post_commit_hooks() self.simple_event.invalidate_recordset() - - mock_patch.assert_called_once_with( - self.simple_event.ms_organizer_event_id, - { - "attendees": [{ - 'emailAddress': {'address': attendee.email or '', 'name': attendee.display_name or ''}, - 'status': {'response': 'declined'} - }] - }, - token=mock_get_token(self.organizer_user), - timeout=ANY, + mock_answer.assert_called_once_with( + attendee_ms_organizer_event_id, + 'decline', + {"comment": "", "sendResponse": True}, + token=mock_get_token(self.attendee_user), + timeout=20, ) @patch.object(MicrosoftCalendarService, 'get_events') @@ -180,3 +177,31 @@ class TestAnswerEvents(TestCommon): ).json() # the status must be sync_stopped self.assertEqual(response['result']['status'], 'sync_stopped') + + @patch.object(MicrosoftCalendarService, '_get_single_event') + @patch.object(MicrosoftCalendarService, 'answer') + def test_answer_event_with_external_organizer(self, mock_answer, mock_get_single_event): + """ Answer an event invitation from an outsider user and check if it was patched on Outlook side. """ + # Simulate an event that came from an external provider: the organizer isn't registered in Odoo. + self.simple_event.write({'user_id': False, 'partner_id': False}) + self.simple_event.attendee_ids.state = 'needsAction' + + # Accept the event using the admin account and ensure that answer request is called. + attendee_ms_organizer_event_id = 100 + mock_get_single_event.return_value = (True, {'value': [{'id': attendee_ms_organizer_event_id}]}) + self.simple_event.attendee_ids[0].with_user(self.organizer_user)._microsoft_sync_event('accept') + mock_answer.assert_called_once_with( + attendee_ms_organizer_event_id, + 'accept', {'comment': '', 'sendResponse': True}, + token=mock_get_token(self.organizer_user), + timeout=20 + ) + + # Decline the event using the admin account and ensure that answer request is called. + self.simple_event.attendee_ids[0].with_user(self.organizer_user)._microsoft_sync_event('decline') + mock_answer.assert_called_with( + attendee_ms_organizer_event_id, + 'decline', {'comment': '', 'sendResponse': True}, + token=mock_get_token(self.organizer_user), + timeout=20 + ) diff --git a/addons/microsoft_calendar/tests/test_update_events.py b/addons/microsoft_calendar/tests/test_update_events.py index 0e8d5dedb87..b87ba70e899 100644 --- a/addons/microsoft_calendar/tests/test_update_events.py +++ b/addons/microsoft_calendar/tests/test_update_events.py @@ -697,7 +697,7 @@ class TestUpdateEvents(TestCommon): new_name = "update simple event" mock_get_events.return_value = ( MicrosoftEvent([dict( - dict(self.simple_event_from_outlook_organizer, id=789), # same iCalUId but different id + dict(self.simple_event_from_outlook_organizer, id='789'), # same iCalUId but different id subject=new_name, type="exception", lastModifiedDateTime=_modified_date_in_the_future(self.simple_event) diff --git a/addons/microsoft_calendar/utils/microsoft_calendar.py b/addons/microsoft_calendar/utils/microsoft_calendar.py index 62cb416b09a..a76fad07dcc 100644 --- a/addons/microsoft_calendar/utils/microsoft_calendar.py +++ b/addons/microsoft_calendar/utils/microsoft_calendar.py @@ -44,6 +44,14 @@ class MicrosoftCalendarService(): def __init__(self, microsoft_service): self.microsoft_service = microsoft_service + @requires_auth_token + def _get_single_event(self, iCalUId, token, timeout=TIMEOUT): + """ Fetch a single event from Graph API filtered by its iCalUId. """ + url = "/v1.0/me/events?$filter=iCalUId eq '%s'" % iCalUId + headers = {'Content-type': 'application/json', 'Authorization': 'Bearer %s' % token} + status, event, _dummy = self.microsoft_service._do_request(url, {}, headers, method='GET', timeout=timeout) + return status not in RESOURCE_NOT_FOUND_STATUSES, event + @requires_auth_token def _get_events_from_paginated_url(self, url, token=None, params=None, timeout=TIMEOUT): """