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): """