[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) <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-12-03 15:23:20 +00:00
parent 2ac98dc2e5
commit 55b452ac00
7 changed files with 105 additions and 44 deletions
+1 -1
View File
@@ -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
+27 -2
View File
@@ -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()
@@ -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)
@@ -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):
"""
@@ -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
)
@@ -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)
@@ -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):
"""