From 2fbfbf0dce6819b719bdfa674f29772fbed3b167 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Baranx=20=28bar=29?= Date: Thu, 24 Mar 2022 07:58:00 +0000 Subject: [PATCH] [FIX] calendar, microsoft_calendar: backport bugfixes from master As several customers complained about lot of bugs in microsoft_calendar in 14.0, It has been decided to backport bug fixes of the model layer from master to 14.0, without the need of an upgrade script (no new field, ...). In master, we use 2 ids (organizer event id + universal id) instead of only one, to handle Odoo <-> Outlook sync correctly when several attendees sync their Outlook calendar with their Odoo calendar. For that, we have added a new field. To report this bug fix in 14.0, the existing field which stores the organizer event id, is now a string storing both ids separated by a ':' as follow: 'organizer_event_id:universal_id'. 2 new compute fields have been added to be able to use these 2 ids more easily. (all commits from the original PR have been squashed to ease forward-port) closes odoo/odoo#95736 X-original-commit: 5e83318a7240585371efd31e407829793f3e732f Signed-off-by: Arnaud Joset Signed-off-by: Yannick Tivisse (yti) --- addons/calendar/models/calendar_event.py | 28 +- addons/calendar/models/calendar_recurrence.py | 7 + .../calendar/tests/test_event_recurrence.py | 2 +- .../tests/test_sync_odoo2google.py | 4 +- .../models/microsoft_service.py | 5 +- addons/microsoft_calendar/controllers/main.py | 12 +- addons/microsoft_calendar/models/calendar.py | 143 +- .../models/calendar_attendee.py | 10 +- .../models/calendar_recurrence_rule.py | 101 +- .../models/microsoft_sync.py | 420 ++++-- addons/microsoft_calendar/models/res_users.py | 25 +- addons/microsoft_calendar/tests/__init__.py | 8 +- addons/microsoft_calendar/tests/common.py | 503 +++++++ .../tests/test_answer_events.py | 156 ++ .../tests/test_create_events.py | 259 ++++ .../tests/test_delete_events.py | 254 ++++ .../tests/test_microsoft_event.py | 290 ++++ .../tests/test_microsoft_service.py | 439 ++++++ .../tests/test_update_events.py | 1273 +++++++++++++++++ addons/microsoft_calendar/utils/__init__.py | 1 + .../utils/event_id_storage.py | 10 + .../utils/microsoft_calendar.py | 145 +- .../utils/microsoft_event.py | 174 ++- .../wizard/reset_account.py | 7 +- 24 files changed, 3902 insertions(+), 374 deletions(-) create mode 100644 addons/microsoft_calendar/tests/common.py create mode 100644 addons/microsoft_calendar/tests/test_answer_events.py create mode 100644 addons/microsoft_calendar/tests/test_create_events.py create mode 100644 addons/microsoft_calendar/tests/test_delete_events.py create mode 100644 addons/microsoft_calendar/tests/test_microsoft_event.py create mode 100644 addons/microsoft_calendar/tests/test_microsoft_service.py create mode 100644 addons/microsoft_calendar/tests/test_update_events.py create mode 100644 addons/microsoft_calendar/utils/event_id_storage.py diff --git a/addons/calendar/models/calendar_event.py b/addons/calendar/models/calendar_event.py index 8a07ef7cd4d..e0c276ca395 100644 --- a/addons/calendar/models/calendar_event.py +++ b/addons/calendar/models/calendar_event.py @@ -14,7 +14,14 @@ from odoo import api, fields, models, Command from odoo.osv.expression import AND from odoo.addons.base.models.res_partner import _tz_get from odoo.addons.calendar.models.calendar_attendee import Attendee -from odoo.addons.calendar.models.calendar_recurrence import weekday_to_field, RRULE_TYPE_SELECTION, END_TYPE_SELECTION, MONTH_BY_SELECTION, WEEKDAY_SELECTION, BYDAY_SELECTION +from odoo.addons.calendar.models.calendar_recurrence import ( + weekday_to_field, + RRULE_TYPE_SELECTION, + END_TYPE_SELECTION, + MONTH_BY_SELECTION, + WEEKDAY_SELECTION, + BYDAY_SELECTION +) from odoo.tools.translate import _ from odoo.tools.misc import get_lang from odoo.tools import pycompat, html2plaintext, is_html_empty, single_email_re @@ -404,7 +411,7 @@ class Meeting(models.Model): defaults = self.default_get(['activity_ids', 'res_model_id', 'res_id', 'user_id', 'res_model', 'partner_ids']) meeting_activity_type = self.env['mail.activity.type'].search([('category', '=', 'meeting')], limit=1) # get list of models ids and filter out None values directly - model_ids = list(filter(bool, {values.get('res_model_id', defaults.get('res_model_id')) for values in vals_list})) + model_ids = list(filter(None, {values.get('res_model_id', defaults.get('res_model_id')) for values in vals_list})) model_name = defaults.get('res_model') valid_activity_model_ids = model_name and self.env[model_name].sudo().browse(model_ids).filtered(lambda m: 'activity_ids' in m).ids or [] if meeting_activity_type and not defaults.get('activity_ids'): @@ -550,6 +557,9 @@ class Meeting(models.Model): (detached_events & self).active = False (detached_events - self).with_context(archive_on_error=True).unlink() + + # Notify attendees if there is an alarm on the modified event, or if there was an alarm + # that has just been removed, as it might have changed their next event notification if not self.env.context.get('dont_notify') and update_alarms: self._setup_alarms() attendee_update_events = self.filtered(lambda ev: ev.user_id != self.env.user) @@ -608,13 +618,25 @@ class Meeting(models.Model): return super(Meeting, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy) def unlink(self): + if not self: + return super().unlink() + # Get concerned attendees to notify them if there is an alarm on the unlinked events, # as it might have changed their next event notification events = self.filtered_domain([('alarm_ids', '!=', False)]) partner_ids = events.mapped('partner_ids').ids + # don't forget to update recurrences if there are some base events in the set to unlink, + # but after having removed the events ;-) + recurrences = self.env["calendar.recurrence"].search([ + ('base_event_id.id', 'in', [e.id for e in self]) + ]) + result = super().unlink() + if recurrences: + recurrences._select_new_base_event() + # Notify the concerned attendees (must be done after removing the events) self.env['calendar.alarm_manager']._notify_next_alarm(partner_ids) return result @@ -896,7 +918,7 @@ class Meeting(models.Model): # When we try to change recurrence values of an event not following the recurrence, we get the parameters from # the base_event previous_week_day_field = weekday_to_field(self.recurrence_id.base_event_id._get_start_date().weekday()) - self.write({**time_values}) + self.write(time_values) return self._apply_recurrence_values({ previous_week_day_field: False, **self._get_recurrence_params(), diff --git a/addons/calendar/models/calendar_recurrence.py b/addons/calendar/models/calendar_recurrence.py index d8a94e22c21..e714b45c029 100644 --- a/addons/calendar/models/calendar_recurrence.py +++ b/addons/calendar/models/calendar_recurrence.py @@ -199,6 +199,13 @@ class RecurrenceRule(models.Model): ranges_to_create = (event_range for event_range in ranges if event_range not in existing_ranges) return synced_events, ranges_to_create + def _select_new_base_event(self): + """ + when the base event is no more available (archived, deleted, etc.), a new one should be selected + """ + for recurrence in self: + recurrence.base_event_id = recurrence._get_first_event() + def _apply_recurrence(self, specific_values_creation=None, no_send_edit=False, generic_values_creation=None): """Create missing events in the recurrence and detach events which no longer follow the recurrence rules. diff --git a/addons/calendar/tests/test_event_recurrence.py b/addons/calendar/tests/test_event_recurrence.py index cb41de9e42c..7ffd752d68e 100644 --- a/addons/calendar/tests/test_event_recurrence.py +++ b/addons/calendar/tests/test_event_recurrence.py @@ -13,7 +13,7 @@ class TestRecurrentEvents(TransactionCase): @classmethod def setUpClass(cls): - super().setUpClass() + super(TestRecurrentEvents, cls).setUpClass() lang = cls.env['res.lang']._lang_get(cls.env.user.lang) lang.week_start = '1' # Monday diff --git a/addons/google_calendar/tests/test_sync_odoo2google.py b/addons/google_calendar/tests/test_sync_odoo2google.py index b43c1601df8..539c6ab7c60 100644 --- a/addons/google_calendar/tests/test_sync_odoo2google.py +++ b/addons/google_calendar/tests/test_sync_odoo2google.py @@ -85,7 +85,7 @@ class TestSyncOdoo2Google(TestSyncGoogle): events._sync_odoo2google(self.google_service) - with self.assertQueryCount(__system__=129): + with self.assertQueryCount(__system__=130): events.unlink() @@ -119,7 +119,7 @@ class TestSyncOdoo2Google(TestSyncGoogle): 'res_id': partner.id, }) - with self.assertQueryCount(__system__=29): + with self.assertQueryCount(__system__=35): event.unlink() def test_event_without_user(self): diff --git a/addons/microsoft_account/models/microsoft_service.py b/addons/microsoft_account/models/microsoft_service.py index bbceefbaeba..38a7b9452a8 100644 --- a/addons/microsoft_account/models/microsoft_service.py +++ b/addons/microsoft_account/models/microsoft_service.py @@ -17,6 +17,7 @@ TIMEOUT = 20 DEFAULT_MICROSOFT_AUTH_ENDPOINT = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize' DEFAULT_MICROSOFT_TOKEN_ENDPOINT = 'https://login.microsoftonline.com/common/oauth2/v2.0/token' +RESOURCE_NOT_FOUND_STATUSES = (204, 404) class MicrosoftService(models.AbstractModel): _name = 'microsoft.service' @@ -149,7 +150,7 @@ class MicrosoftService(models.AbstractModel): res.raise_for_status() status = res.status_code - if int(status) in (204, 404): # Page not found, no response + if int(status) in RESOURCE_NOT_FOUND_STATUSES: response = False else: # Some answers return empty content @@ -160,7 +161,7 @@ class MicrosoftService(models.AbstractModel): except: pass except requests.HTTPError as error: - if error.response.status_code in (204, 404): + if error.response.status_code in RESOURCE_NOT_FOUND_STATUSES: status = error.response.status_code response = "" else: diff --git a/addons/microsoft_calendar/controllers/main.py b/addons/microsoft_calendar/controllers/main.py index 954d9af3103..b27d3583e47 100644 --- a/addons/microsoft_calendar/controllers/main.py +++ b/addons/microsoft_calendar/controllers/main.py @@ -3,7 +3,6 @@ from odoo import http from odoo.http import request -from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService class MicrosoftCalendarController(http.Controller): @@ -18,7 +17,7 @@ class MicrosoftCalendarController(http.Controller): this URL for authorization for example """ if model == 'calendar.event': - MicrosoftCal = MicrosoftCalendarService(request.env['microsoft.service']) + MicrosoftCal = request.env["calendar.event"]._get_microsoft_service() # Checking that admin have already configured Microsoft API for microsoft synchronization ! client_id = request.env['ir.config_parameter'].sudo().get_param('microsoft_calendar_client_id') @@ -41,14 +40,7 @@ class MicrosoftCalendarController(http.Controller): "url": url } # If App authorized, and user access accepted, We launch the synchronization - need_refresh = request.env.user.sudo()._sync_microsoft_calendar(MicrosoftCal) - - # If synchronization has been stopped - if not need_refresh and request.env.user.microsoft_synchronization_stopped: - return { - "status": "sync_stopped", - "url": '' - } + need_refresh = request.env.user.sudo()._sync_microsoft_calendar() return { "status": "need_refresh" if need_refresh else "no_new_event_from_microsoft", "url": '' diff --git a/addons/microsoft_calendar/models/calendar.py b/addons/microsoft_calendar/models/calendar.py index bb48a0dabce..0e877b21862 100644 --- a/addons/microsoft_calendar/models/calendar.py +++ b/addons/microsoft_calendar/models/calendar.py @@ -1,14 +1,17 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import logging import pytz import re +from datetime import datetime from dateutil.parser import parse from dateutil.relativedelta import relativedelta from odoo import api, fields, models, _ from odoo.exceptions import UserError, ValidationError -from odoo.tools import is_html_empty +from odoo.tools import is_html_empty, email_normalize +from odoo.addons.microsoft_calendar.utils.event_id_storage import combine_ids ATTENDEE_CONVERTER_O2M = { 'needsAction': 'notresponded', @@ -29,13 +32,19 @@ VIDEOCALL_URL_PATTERNS = ( ) MAX_RECURRENT_EVENT = 720 +_logger = logging.getLogger(__name__) + class Meeting(models.Model): _name = 'calendar.event' _inherit = ['calendar.event', 'microsoft.calendar.sync'] + # contains organizer event id and universal event id separated by a ':' microsoft_id = fields.Char('Microsoft Calendar Event Id') microsoft_recurrence_master_id = fields.Char('Microsoft Recurrence Master Id') + def _get_organizer(self): + return self.user_id + @api.model def _get_microsoft_synced_fields(self): return {'name', 'description', 'allday', 'start', 'date_end', 'stop', @@ -51,25 +60,68 @@ class Meeting(models.Model): @api.model_create_multi def create(self, vals_list): notify_context = self.env.context.get('dont_notify', False) + + # 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. return super(Meeting, self.with_context(dont_notify=notify_context)).create([ dict(vals, need_sync_m=False) if vals.get('recurrence_id') or vals.get('recurrency') else vals for vals in vals_list ]) + def _check_recurrence_overlapping(self, new_start): + """ + Outlook does not allow to modify time fields of an event if this event crosses + or overlaps the recurrence. In this case a 400 error with the Outlook code "ErrorOccurrenceCrossingBoundary" + is returned. That means that the update violates the following Outlook restriction on recurrence exceptions: + an occurrence cannot be moved to or before the day of the previous occurrence, and cannot be moved to or after + the day of the following occurrence. + For example: E1 E2 E3 E4 cannot becomes E1 E3 E2 E4 + """ + before_count = len(self.recurrence_id.calendar_event_ids.filtered( + lambda e: e.start.date() < self.start.date() and e != self + )) + after_count = len(self.recurrence_id.calendar_event_ids.filtered( + lambda e: e.start.date() < parse(new_start).date() and e != self + )) + if before_count != after_count: + raise UserError(_( + "Outlook limitation: in a recurrence, an event cannot be moved to or before the day of the " + "previous event, and cannot be moved to or after the day of the following event." + )) + + def _is_matching_timeslot(self, start, stop, allday): + """ + Check if an event matches with the provided timeslot + """ + self.ensure_one() + + event_start, event_stop = self._range() + if allday: + event_start = datetime(event_start.year, event_start.month, event_start.day, 0, 0) + event_stop = datetime(event_stop.year, event_stop.month, event_stop.day, 0, 0) + + return (event_start, event_stop) == (start, stop) + def write(self, values): recurrence_update_setting = values.get('recurrence_update') - if recurrence_update_setting in ('all_events', 'future_events') and len(self) == 1: - values = dict(values, need_sync_m=False) - elif recurrence_update_setting == 'self_only' and 'start' in values: - previous_event_before_write = self.recurrence_id.calendar_event_ids.filtered(lambda e: e.start.date() < self.start.date() and e != self) - new_start = parse(values['start']).date() - previous_event_after_write = self.recurrence_id.calendar_event_ids.filtered(lambda e: e.start.date() < new_start and e != self) - if previous_event_before_write != previous_event_after_write: - # Outlook returns a 400 error if you try to synchronize an occurrence of this type. - raise UserError(_("Modified occurrence is crossing or overlapping adjacent occurrence.")) + + # check a Outlook limitation in overlapping the actual recurrence + if recurrence_update_setting == 'self_only' and 'start' in values: + self._check_recurrence_overlapping(values['start']) + + # if a single event becomes the base event of a recurrency, it should be first + # removed from the Outlook calendar. + if 'recurrency' in values and values['recurrency']: + for e in self.filtered(lambda e: not e.recurrency and not e.recurrence_id): + 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 and values.keys() & self._get_microsoft_synced_fields(): + + if recurrence_update_setting in ('all_events',) and len(self) == 1 \ + and values.keys() & self._get_microsoft_synced_fields(): self.recurrence_id.need_sync_m = True return res @@ -89,7 +141,7 @@ class Meeting(models.Model): @api.model - def _microsoft_to_odoo_values(self, microsoft_event, default_reminders=(), default_values={}): + def _microsoft_to_odoo_values(self, microsoft_event, default_reminders=(), default_values=None, with_ids=False): if microsoft_event.is_cancelled(): return {'active': False} @@ -107,12 +159,12 @@ class Meeting(models.Model): stop = parse(microsoft_event.end.get('dateTime')).astimezone(timeZone_stop).replace(tzinfo=None) - relativedelta(days=1) else: stop = parse(microsoft_event.end.get('dateTime')).astimezone(timeZone_stop).replace(tzinfo=None) - values = { - **default_values, + values = default_values or {} + values.update({ 'name': microsoft_event.subject or _("(No title)"), 'description': microsoft_event.body and microsoft_event.body['content'], 'location': microsoft_event.location and microsoft_event.location.get('displayName') or False, - 'user_id': microsoft_event.owner(self.env).id, + 'user_id': microsoft_event.owner_id(self.env), 'privacy': sensitivity_o2m.get(microsoft_event.sensitivity, self.default_get(['privacy'])['privacy']), 'attendee_ids': commands_attendee, 'allday': microsoft_event.isAllDay, @@ -120,14 +172,14 @@ class Meeting(models.Model): 'stop': stop, 'show_as': 'free' if microsoft_event.showAs == 'free' else 'busy', 'recurrency': microsoft_event.is_recurrent() - } + }) if commands_partner: # Add partner_commands only if set from Microsoft. The write method on calendar_events will # override attendee commands if the partner_ids command is set but empty. values['partner_ids'] = commands_partner if microsoft_event.is_recurrent() and not microsoft_event.is_recurrence(): - # Propagate the follow_recurrence according to the google result + # Propagate the follow_recurrence according to the Outlook result values['follow_recurrence'] = not microsoft_event.is_recurrence_outlier() # if a videocall URL is provided with the Outlook event, use it @@ -140,7 +192,9 @@ class Meeting(models.Model): values['videocall_location'] = values['location'] values['location'] = False - values['microsoft_id'] = microsoft_event.id + if with_ids: + values['microsoft_id'] = combine_ids(microsoft_event.id, microsoft_event.iCalUId) + if microsoft_event.is_recurrent(): values['microsoft_recurrence_master_id'] = microsoft_event.seriesMasterId @@ -151,7 +205,7 @@ class Meeting(models.Model): return values @api.model - def _microsoft_to_odoo_recurrence_values(self, microsoft_event, default_reminders=(), values={}): + def _microsoft_to_odoo_recurrence_values(self, microsoft_event, default_values=None): timeZone_start = pytz.timezone(microsoft_event.start.get('timeZone')) timeZone_stop = pytz.timezone(microsoft_event.end.get('timeZone')) start = parse(microsoft_event.start.get('dateTime')).astimezone(timeZone_start).replace(tzinfo=None) @@ -159,10 +213,13 @@ class Meeting(models.Model): stop = parse(microsoft_event.end.get('dateTime')).astimezone(timeZone_stop).replace(tzinfo=None) - relativedelta(days=1) else: stop = parse(microsoft_event.end.get('dateTime')).astimezone(timeZone_stop).replace(tzinfo=None) - values['microsoft_id'] = microsoft_event.id - values['microsoft_recurrence_master_id'] = microsoft_event.seriesMasterId - values['start'] = start - values['stop'] = stop + values = default_values or {} + values.update({ + 'microsoft_id': combine_ids(microsoft_event.id, microsoft_event.iCalUId), + 'microsoft_recurrence_master_id': microsoft_event.seriesMasterId, + 'start': start, + 'stop': stop, + }) return values @api.model @@ -171,9 +228,13 @@ class Meeting(models.Model): commands_partner = [] microsoft_attendees = microsoft_event.attendees or [] - emails = [a.get('emailAddress').get('address') for a in microsoft_attendees] + emails = [ + a.get('emailAddress').get('address') + for a in microsoft_attendees + if email_normalize(a.get('emailAddress').get('address')) + ] existing_attendees = self.env['calendar.attendee'] - if microsoft_event.exists(self.env): + if microsoft_event.match_with_odoo_events(self.env): existing_attendees = self.env['calendar.attendee'].search([ ('event_id', '=', microsoft_event.odoo_id(self.env)), ('email', 'in', emails)]) @@ -182,20 +243,18 @@ class Meeting(models.Model): commands_partner += [(4, self.env.user.partner_id.id)] 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 attendee in zip(emails, partners, microsoft_attendees): - email = attendee[0] - state = ATTENDEE_CONVERTER_M2O.get(attendee[2].get('status').get('response')) + for email, partner, attendee_info in zip(emails, partners, microsoft_attendees): + state = ATTENDEE_CONVERTER_M2O.get(attendee_info.get('status').get('response'), 'needsAction') if email in attendees_by_emails: # Update existing attendees commands_attendee += [(1, attendees_by_emails[email].id, {'state': state})] - elif attendee[1]: + elif partner: # Create new attendees - partner = attendee[1] commands_attendee += [(0, 0, {'state': state, 'partner_id': partner.id})] commands_partner += [(4, partner.id)] - if attendee[2].get('emailAddress').get('name') and not partner.name: - partner.name = attendee[2].get('emailAddress').get('name') + if attendee_info.get('emailAddress').get('name') and not partner.name: + partner.name = attendee_info.get('emailAddress').get('name') for odoo_attendee in attendees_by_emails.values(): # Remove old attendees if odoo_attendee.email not in emails: @@ -269,15 +328,7 @@ class Meeting(models.Model): if not fields_to_sync: return values - values['id'] = self.microsoft_id microsoft_guid = self.env['ir.config_parameter'].sudo().get_param('microsoft_calendar.microsoft_guid', False) - values['singleValueExtendedProperties'] = [{ - 'id': 'String {%s} Name odoo_id' % microsoft_guid, - 'value': str(self.id), - }, { - 'id': 'String {%s} Name owner_odoo_id' % microsoft_guid, - 'value': str(self.user_id.id), - }] if self.microsoft_recurrence_master_id and 'type' not in values: values['seriesMasterId'] = self.microsoft_recurrence_master_id @@ -417,17 +468,7 @@ class Meeting(models.Model): "\n%s", details, invalid_events)) def _microsoft_values_occurence(self, initial_values={}): - values = dict(initial_values) - values['id'] = self.microsoft_id - microsoft_guid = self.env['ir.config_parameter'].sudo().get_param('microsoft_calendar.microsoft_guid', False) - values['singleValueExtendedProperties'] = [{ - 'id': 'String {%s} Name odoo_id' % microsoft_guid, - 'value': str(self.id), - }, { - 'id': 'String {%s} Name owner_odoo_id' % microsoft_guid, - 'value': str(self.user_id.id), - }] - + values = initial_values values['type'] = 'occurrence' if self.allday: diff --git a/addons/microsoft_calendar/models/calendar_attendee.py b/addons/microsoft_calendar/models/calendar_attendee.py index 6a28a68e0f5..61b73282702 100644 --- a/addons/microsoft_calendar/models/calendar_attendee.py +++ b/addons/microsoft_calendar/models/calendar_attendee.py @@ -40,8 +40,12 @@ class Attendee(models.Model): return res def _microsoft_sync_event(self, answer): - microsoft_service = MicrosoftCalendarService(self.env['microsoft.service']) params = {"comment": "", "sendResponse": True} # Microsoft prevent user to answer the meeting when they are the organizer - for event in self.event_id.filtered(lambda e: e.microsoft_id and e.user_id != self.env.user): - event._microsoft_attendee_answer(microsoft_service, event.microsoft_id, answer, params) + 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"]), + ) diff --git a/addons/microsoft_calendar/models/calendar_recurrence_rule.py b/addons/microsoft_calendar/models/calendar_recurrence_rule.py index a740ee0ce8d..57195abed1a 100644 --- a/addons/microsoft_calendar/models/calendar_recurrence_rule.py +++ b/addons/microsoft_calendar/models/calendar_recurrence_rule.py @@ -3,8 +3,6 @@ from odoo import api, fields, models -from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService - class RecurrenceRule(models.Model): _name = 'calendar.recurrence' @@ -36,17 +34,11 @@ class RecurrenceRule(models.Model): events = self.filtered('need_sync_m').calendar_event_ids detached_events = super()._apply_recurrence(specific_values_creation, no_send_edit, generic_values_creation) - microsoft_service = MicrosoftCalendarService(self.env['microsoft.service']) - # If a synced event becomes a recurrence, the event needs to be deleted from # Microsoft since it's now the recurrence which is synced. - # Those events are kept in the database and their microsoft_id is updated - # according to the recurrence microsoft_id, therefore we need to keep an inactive copy - # of those events with the original microsoft_id. The next sync will then correctly - # delete those events from Microsoft. vals = [] - for event in events.filtered('microsoft_id'): - if event.active and event.microsoft_id and not event.recurrence_id.microsoft_id: + for event in events._get_synced_events(): + if event.active and event.ms_universal_event_id and not event.recurrence_id.ms_universal_event_id: vals += [{ 'name': event.name, 'microsoft_id': event.microsoft_id, @@ -55,8 +47,8 @@ class RecurrenceRule(models.Model): 'active': False, 'need_sync_m': True, }] - event._microsoft_delete(microsoft_service, event.microsoft_id) - event.microsoft_id = False + event._microsoft_delete(event.user_id, event.ms_organizer_event_id) + event.ms_universal_event_id = False self.env['calendar.event'].create(vals) self.calendar_event_ids.need_sync_m = False return detached_events @@ -64,9 +56,12 @@ class RecurrenceRule(models.Model): def _write_events(self, values, dtstart=None): # If only some events are updated, sync those events. # If all events are updated, sync the recurrence instead. - values['need_sync_m'] = bool(dtstart) + values['need_sync_m'] = bool(dtstart) or values.get("need_sync_m", True) return super()._write_events(values, dtstart=dtstart) + def _get_organizer(self): + return self.base_event_id.user_id + def _get_rrule(self, dtstart=None): if not dtstart and self.dtstart: dtstart = self.dtstart @@ -81,22 +76,40 @@ class RecurrenceRule(models.Model): 'need_sync_m': True, }) + def _has_base_event_time_fields_changed(self, new): + """ + Indicates if at least one time field of the base event has changed, based + on provided `new` values. + Note: for all day event comparison, hours/minutes are ignored. + """ + def _convert(value, to_convert): + return value.date() if to_convert else value + + old = self.base_event_id and self.base_event_id.read(['start', 'stop', 'allday'])[0] + return old and ( + old['allday'] != new['allday'] + or any( + _convert(new[f], new['allday']) != _convert(old[f], old['allday']) + for f in ('start', 'stop') + ) + ) + def _write_from_microsoft(self, microsoft_event, vals): current_rrule = self.rrule # event_tz is written on event in Microsoft but on recurrence in Odoo vals['event_tz'] = microsoft_event.start.get('timeZone') super()._write_from_microsoft(microsoft_event, vals) - base_event_time_fields = ['start', 'stop', 'allday'] new_event_values = self.env["calendar.event"]._microsoft_to_odoo_values(microsoft_event) - old_event_values = self.base_event_id and self.base_event_id.read(base_event_time_fields)[0] - if old_event_values and any(new_event_values[key] != old_event_values[key] for key in base_event_time_fields): + if self._has_base_event_time_fields_changed(new_event_values): # we need to recreate the recurrence, time_fields were modified. base_event_id = self.base_event_id # We archive the old events to recompute the recurrence. These events are already deleted on Microsoft side. # We can't call _cancel because events without user_id would not be deleted (self.calendar_event_ids - base_event_id).microsoft_id = False (self.calendar_event_ids - base_event_id).unlink() - base_event_id.with_context(dont_notify=True).write(dict(new_event_values, microsoft_id=False, need_sync_m=False)) + base_event_id.with_context(dont_notify=True).write(dict( + new_event_values, microsoft_id=False, need_sync_m=False + )) if self.rrule == current_rrule: # if the rrule has changed, it will be recalculated below # There is no detached event now @@ -113,7 +126,7 @@ class RecurrenceRule(models.Model): if field not in time_fields }, need_sync_m=False) ) - # We apply the rrule check after the time_field check because the microsoft_id are generated according + # We apply the rrule check after the time_field check because the microsoft ids are generated according # to base_event start datetime. if self.rrule != current_rrule: detached_events = self._apply_recurrence() @@ -127,37 +140,45 @@ class RecurrenceRule(models.Model): # We also prevent sync of other user recurrent events. return [('calendar_event_ids.user_id', '=', self.env.user.id), ('rrule', '!=', False)] - def _cancel_microsoft(self): self.calendar_event_ids._cancel_microsoft() super()._cancel_microsoft() @api.model - def _microsoft_to_odoo_values(self, microsoft_recurrence, default_reminders=(), default_values={}): + def _microsoft_to_odoo_values(self, microsoft_recurrence, default_reminders=(), default_values=None, with_ids=False): recurrence = microsoft_recurrence.get_recurrence() - return { - **recurrence, - 'microsoft_id': microsoft_recurrence.id, - } + if with_ids: + recurrence = { + **recurrence, + 'ms_organizer_event_id': microsoft_recurrence.id, + 'ms_universal_event_id': microsoft_recurrence.iCalUId, + } + + return recurrence def _microsoft_values(self, fields_to_sync): - events_outliers = self.calendar_event_ids.filtered(lambda e: not e.follow_recurrence) - events = self.calendar_event_ids.sorted('start') - normal_event = (events - events_outliers)[:1] or events[:1] - if not normal_event: - return {} - values = normal_event._microsoft_values(fields_to_sync, initial_values={'type': 'seriesMaster'}) - - if self.microsoft_id: - values['id'] = self.microsoft_id - if events_outliers: - # We send the data as a list. If we directly send a list of values, we have issues... - values = [values] - for event in events_outliers: - event_value = event._microsoft_values(fields_to_sync) - values += [event_value] - return values + """ + Get values to update the whole Outlook event recurrence. + (done through the first event of the Outlook recurrence). + """ + return self.base_event_id._microsoft_values(fields_to_sync, initial_values={'type': 'seriesMaster'}) def _ensure_attendees_have_email(self): self.calendar_event_ids.filtered(lambda e: e.active)._ensure_attendees_have_email() + + def _split_from(self, event, recurrence_values=None): + """ + When a recurrence is splitted, the base event of the new recurrence already + exist and may be already synced with Outlook. + In this case, we need to be removed this event on Outlook side to avoid duplicates while posting + the new recurrence. + """ + new_recurrence = super()._split_from(event, recurrence_values) + if new_recurrence and new_recurrence.base_event_id.microsoft_id: + new_recurrence.base_event_id._microsoft_delete( + new_recurrence.base_event_id._get_organizer(), + new_recurrence.base_event_id.ms_organizer_event_id + ) + + return new_recurrence diff --git a/addons/microsoft_calendar/models/microsoft_sync.py b/addons/microsoft_calendar/models/microsoft_sync.py index 7ff2bdafe84..a39001ee7e2 100644 --- a/addons/microsoft_calendar/models/microsoft_sync.py +++ b/addons/microsoft_calendar/models/microsoft_sync.py @@ -4,24 +4,23 @@ import logging from contextlib import contextmanager from functools import wraps -import requests import pytz from dateutil.parser import parse -from odoo import api, fields, models, registry, _ +from odoo import api, fields, models, registry from odoo.tools import ormcache_context from odoo.exceptions import UserError from odoo.osv import expression from odoo.addons.microsoft_calendar.utils.microsoft_event import MicrosoftEvent from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService +from odoo.addons.microsoft_calendar.utils.event_id_storage import IDS_SEPARATOR, combine_ids, split_ids from odoo.addons.microsoft_account.models.microsoft_service import TIMEOUT _logger = logging.getLogger(__name__) MAX_RECURRENT_EVENT = 720 - # API requests are sent to Microsoft Calendar after the current transaction ends. # This ensures changes are sent to Microsoft only if they really happened in the Odoo database. # It is particularly important for event creation , otherwise the event might be created @@ -53,22 +52,38 @@ def after_commit(func): def microsoft_calendar_token(user): yield user._get_microsoft_calendar_token() - class MicrosoftSync(models.AbstractModel): _name = 'microsoft.calendar.sync' _description = "Synchronize a record with Microsoft Calendar" microsoft_id = fields.Char('Microsoft Calendar Id', copy=False) - # This field helps to known when a microsoft event need to be resynced + + ms_organizer_event_id = fields.Char( + 'Organizer event Id', + compute='_compute_organizer_event_id', + inverse='_set_event_id', + search='_search_organizer_event_id', + ) + ms_universal_event_id = fields.Char( + 'Universal event Id', + compute='_compute_universal_event_id', + inverse='_set_event_id', + search='_search_universal_event_id', + ) + + # This field helps to know when a microsoft event need to be resynced need_sync_m = fields.Boolean(default=True, copy=False) active = fields.Boolean(default=True) def write(self, vals): - microsoft_service = MicrosoftCalendarService(self.env['microsoft.service']) - if 'microsoft_id' in vals: - self._from_microsoft_ids.clear_cache(self) + if 'ms_universal_event_id' in vals: + self._from_uids.clear_cache(self) synced_fields = self._get_microsoft_synced_fields() - if 'need_sync_m' not in vals and vals.keys() & synced_fields and not self.env.user.microsoft_synchronization_stopped: + if ( + 'need_sync_m' not in vals and vals.keys() & synced_fields + and self.ms_organizer_event_id + and not self.env.user.microsoft_synchronization_stopped + ): fields_to_sync = [x for x in vals.keys() if x in synced_fields] if fields_to_sync: vals['need_sync_m'] = True @@ -78,36 +93,80 @@ class MicrosoftSync(models.AbstractModel): result = super().write(vals) need_delete = 'active' in vals.keys() and not vals.get('active') for record in self.filtered('need_sync_m'): - if need_delete and record.microsoft_id: - # We need to delete the event. Cancel is not sufficant. Errors may occurs - record._microsoft_delete(microsoft_service, record.microsoft_id, timeout=3) - elif record.microsoft_id and fields_to_sync: - values = record._microsoft_values(fields_to_sync) - if not values: - continue - record._microsoft_patch(microsoft_service, record.microsoft_id, values, timeout=3) + if record.ms_universal_event_id: + if need_delete: + # We need to delete the event. Cancel is not sufficant. Errors may occurs + record._microsoft_delete(record._get_organizer(), record.ms_organizer_event_id, timeout=3) + elif fields_to_sync: + values = record._microsoft_values(fields_to_sync) + if not values: + continue + record._microsoft_patch(record._get_organizer(), record.ms_organizer_event_id, values, timeout=3) + return result @api.model_create_multi def create(self, vals_list): - if any(vals.get('microsoft_id') for vals in vals_list): - self._from_microsoft_ids.clear_cache(self) if self.env.user.microsoft_synchronization_stopped: for vals in vals_list: vals.update({'need_sync_m': False}) records = super().create(vals_list) - microsoft_service = MicrosoftCalendarService(self.env['microsoft.service']) records_to_sync = records.filtered(lambda r: r.need_sync_m and r.active) for record in records_to_sync: - record._microsoft_insert(microsoft_service, record._microsoft_values(self._get_microsoft_synced_fields()), timeout=3) + record._microsoft_insert(record._microsoft_values(self._get_microsoft_synced_fields()), timeout=3) return records + @api.depends('microsoft_id') + def _compute_organizer_event_id(self): + for event in self: + event.ms_organizer_event_id = split_ids(event.microsoft_id)[0] if event.microsoft_id else False + + @api.depends('microsoft_id') + def _compute_universal_event_id(self): + for event in self: + event.ms_universal_event_id = split_ids(event.microsoft_id)[1] if event.microsoft_id else False + + def _set_event_id(self): + for event in self: + event.microsoft_id = combine_ids(event.ms_organizer_event_id, event.ms_universal_event_id) + + def _search_event_id(self, operator, value, with_uid): + def _domain(v): + return ('microsoft_id', '=like', f'%{IDS_SEPARATOR}{v}' if with_uid else f'{v}%') + + if operator == '=' and not value: + return ( + ['|', ('microsoft_id', '=', False), ('microsoft_id', '=ilike', f'%{IDS_SEPARATOR}')] + if with_uid + else [('microsoft_id', '=', False)] + ) + return ( + ['|'] * (len(value) - 1) + [_domain(v) for v in value] + if operator.lower() == 'in' + else [_domain(value)] + ) + + def _search_organizer_event_id(self, operator, value): + return self._search_event_id(operator, value, with_uid=False) + + def _search_universal_event_id(self, operator, value): + return self._search_event_id(operator, value, with_uid=True) + + @api.model + def _get_microsoft_service(self): + return MicrosoftCalendarService(self.env['microsoft.service']) + + def _get_synced_events(self): + """ + Get events already synced with Microsoft Outlook. + """ + return self.filtered(lambda e: e.ms_universal_event_id) + def unlink(self): - synced = self.filtered('microsoft_id') - microsoft_service = MicrosoftCalendarService(self.env['microsoft.service']) + synced = self._get_synced_events() for ev in synced: - ev._microsoft_delete(microsoft_service, ev.microsoft_id) + ev._microsoft_delete(ev._get_organizer(), ev.ms_organizer_event_id) return super().unlink() def _write_from_microsoft(self, microsoft_event, vals): @@ -118,13 +177,13 @@ class MicrosoftSync(models.AbstractModel): return self.create(vals_list) @api.model - @ormcache_context('microsoft_ids', keys=('active_test',)) - def _from_microsoft_ids(self, microsoft_ids): - if not microsoft_ids: + @ormcache_context('uids', keys=('active_test',)) + def _from_uids(self, uids): + if not uids: return self.browse() - return self.search([('microsoft_id', 'in', microsoft_ids)]) + return self.search([('ms_universal_event_id', 'in', uids)]) - def _sync_odoo2microsoft(self, microsoft_service: MicrosoftCalendarService): + def _sync_odoo2microsoft(self): if not self: return if self._active_name: @@ -134,47 +193,58 @@ class MicrosoftSync(models.AbstractModel): cancelled_records = self - records_to_sync records_to_sync._ensure_attendees_have_email() - updated_records = records_to_sync.filtered('microsoft_id') + updated_records = records_to_sync._get_synced_events() new_records = records_to_sync - updated_records - for record in cancelled_records.filtered('microsoft_id'): - record._microsoft_delete(microsoft_service, record.microsoft_id) + + for record in cancelled_records._get_synced_events(): + record._microsoft_delete(record._get_organizer(), record.ms_organizer_event_id) for record in new_records: values = record._microsoft_values(self._get_microsoft_synced_fields()) if isinstance(values, dict): - record._microsoft_insert(microsoft_service, values) + record._microsoft_insert(values) else: for value in values: - record._microsoft_insert(microsoft_service, value) + record._microsoft_insert(value) for record in updated_records.filtered('need_sync_m'): values = record._microsoft_values(self._get_microsoft_synced_fields()) if not values: continue - record._microsoft_patch(microsoft_service, record.microsoft_id, values) + record._microsoft_patch(record._get_organizer(), record.ms_organizer_event_id, values) def _cancel_microsoft(self): self.microsoft_id = False self.unlink() - def _sync_recurrence_microsoft2odoo(self, microsoft_events: MicrosoftEvent): - recurrent_masters = microsoft_events.filter(lambda e: e.is_recurrence()) - recurrents = microsoft_events.filter(lambda e: e.is_recurrent_not_master()) + def _sync_recurrence_microsoft2odoo(self, microsoft_events, new_events=None): + recurrent_masters = new_events.filter(lambda e: e.is_recurrence()) if new_events else [] + recurrents = new_events.filter(lambda e: e.is_recurrent_not_master()) if new_events else [] default_values = {'need_sync_m': False} new_recurrence = self.env['calendar.recurrence'] + updated_events = self.env['calendar.event'] + # --- create new recurrences and associated events --- for recurrent_master in recurrent_masters: - new_calendar_recurrence = dict(self.env['calendar.recurrence']._microsoft_to_odoo_values(recurrent_master, (), default_values), need_sync_m=False) - to_create = recurrents.filter(lambda e: e.seriesMasterId == new_calendar_recurrence['microsoft_id']) + new_calendar_recurrence = dict( + self.env['calendar.recurrence']._microsoft_to_odoo_values(recurrent_master, default_values, with_ids=True), + need_sync_m=False + ) + to_create = recurrents.filter( + lambda e: e.seriesMasterId == new_calendar_recurrence['ms_organizer_event_id'] + ) recurrents -= to_create - base_values = dict(self.env['calendar.event']._microsoft_to_odoo_values(recurrent_master, (), default_values), need_sync_m=False) + base_values = dict( + self.env['calendar.event']._microsoft_to_odoo_values(recurrent_master, default_values, with_ids=True), + need_sync_m=False + ) to_create_values = [] if new_calendar_recurrence.get('end_type', False) in ['count', 'forever']: to_create = list(to_create)[:MAX_RECURRENT_EVENT] for recurrent_event in to_create: if recurrent_event.type == 'occurrence': - value = self.env['calendar.event']._microsoft_to_odoo_recurrence_values(recurrent_event, (), base_values) + value = self.env['calendar.event']._microsoft_to_odoo_recurrence_values(recurrent_event, base_values) else: - value = self.env['calendar.event']._microsoft_to_odoo_values(recurrent_event, (), default_values) + value = self.env['calendar.event']._microsoft_to_odoo_values(recurrent_event, default_values) to_create_values += [dict(value, need_sync_m=False)] @@ -183,161 +253,219 @@ class MicrosoftSync(models.AbstractModel): 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 - microsoft_ids = [x.seriesMasterId for x in recurrents] - recurrences = self.env['calendar.recurrence'].search([('microsoft_id', 'in', microsoft_ids)]) - for recurrent_master_id in set([x.seriesMasterId for x in recurrents]): - recurrence_id = recurrences.filtered(lambda ev: ev.microsoft_id == recurrent_master_id) + # --- update events in existing recurrences --- + # Important note: + # To map existing recurrences with events to update, we must use the universal id + # (also known as ICalUId in the Microsoft API), as 'seriesMasterId' attribute of events + # is specific to the Microsoft user calendar. + ms_recurrence_ids = list({x.seriesMasterId for x in recurrents}) + ms_recurrence_uids = {r.id: r.iCalUId for r in microsoft_events if r.id in ms_recurrence_ids} + + recurrences = self.env['calendar.recurrence'].search([ + ('ms_universal_event_id', 'in', ms_recurrence_uids.values()) + ]) + for recurrent_master_id in ms_recurrence_ids: + recurrence_id = recurrences.filtered( + lambda ev: ev.ms_universal_event_id == ms_recurrence_uids[recurrent_master_id] + ) to_update = recurrents.filter(lambda e: e.seriesMasterId == recurrent_master_id) for recurrent_event in to_update: if recurrent_event.type == 'occurrence': - value = self.env['calendar.event']._microsoft_to_odoo_recurrence_values(recurrent_event, (), {'need_sync_m': False}) + value = self.env['calendar.event']._microsoft_to_odoo_recurrence_values( + recurrent_event, {'need_sync_m': False} + ) else: - value = self.env['calendar.event']._microsoft_to_odoo_values(recurrent_event, (), default_values) - existing_event = recurrence_id.calendar_event_ids.filtered(lambda e: e._range() == (value['start'], value['stop'])) + value = self.env['calendar.event']._microsoft_to_odoo_values(recurrent_event, default_values) + existing_event = recurrence_id.calendar_event_ids.filtered( + lambda e: e._is_matching_timeslot(value['start'], value['stop'], recurrent_event.isAllDay) + ) if not existing_event: continue value.pop('start') value.pop('stop') existing_event._write_from_microsoft(recurrent_event, value) + updated_events |= existing_event new_recurrence |= recurrence_id - return new_recurrence + return new_recurrence, updated_events - def _update_microsoft_recurrence(self, recurrence_event, events): - vals = dict(self.base_event_id._microsoft_to_odoo_values(recurrence_event, ()), need_sync_m=False) - vals['microsoft_recurrence_master_id'] = vals.pop('microsoft_id') - self.base_event_id.write(vals) - values = {} - default_values = {} - - normal_events = [] - events_to_update = events.filter(lambda e: e.seriesMasterId == self.microsoft_id) + def _update_microsoft_recurrence(self, recurrence, events): + """ + Update Odoo events from Outlook recurrence and events. + """ + # get the list of events to update ... + events_to_update = events.filter(lambda e: e.seriesMasterId == self.ms_organizer_event_id) if self.end_type in ['count', 'forever']: events_to_update = list(events_to_update)[:MAX_RECURRENT_EVENT] - for recurrent_event in events_to_update: - if recurrent_event.type == 'occurrence': - value = self.env['calendar.event']._microsoft_to_odoo_recurrence_values(recurrent_event, (), default_values) - normal_events += [recurrent_event.odoo_id(self.env)] + # ... and update them + rec_values = {} + update_events = self.env['calendar.event'] + for e in events_to_update: + if e.type == "exception": + event_values = self.env['calendar.event']._microsoft_to_odoo_values(e) + elif e.type == "occurrence": + event_values = self.env['calendar.event']._microsoft_to_odoo_recurrence_values(e) else: - value = self.env['calendar.event']._microsoft_to_odoo_values(recurrent_event, (), default_values) - event = self.env['calendar.event'].browse(recurrent_event.odoo_id(self.env)).exists() - if event: - event.with_context(no_mail_to_attendees=True, mail_create_nolog=True).write(dict(value, need_sync_m=False)) - if value.get('start') and value.get('stop'): - values[(self.id, value.get('start'), value.get('stop'))] = dict(value, need_sync_m=False) + event_values = None - if (self.id, vals.get('start'), vals.get('stop')) in values: - base_event_vals = dict(vals) - base_event_vals.update(values[(self.id, vals.get('start'), vals.get('stop'))]) - self.base_event_id.write(base_event_vals) + if event_values: + # keep event values to update the recurrence later + if any(f for f in ('start', 'stop') if f in event_values): + rec_values[(self.id, event_values.get('start'), event_values.get('stop'))] = dict( + event_values, need_sync_m=False + ) - old_record = self._apply_recurrence(specific_values_creation=values, no_send_edit=True) + 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)) + update_events |= odoo_event - vals.pop('microsoft_id', None) - vals.pop('start', None) - vals.pop('stop', None) - normal_events = [e for e in normal_events if e in self.calendar_event_ids.ids] - normal_event_ids = self.env['calendar.event'].browse(normal_events) - old_record - if normal_event_ids: - vals['follow_recurrence'] = True - (self.env['calendar.event'].browse(normal_events) - old_record).write(vals) + # update the recurrence + detached_events = self._apply_recurrence(rec_values) + detached_events._cancel_microsoft() - old_record._cancel_microsoft() - if not self.base_event_id: - self.base_event_id = self._get_first_event(include_outliers=False) + return update_events @api.model - def _sync_microsoft2odoo(self, microsoft_events: MicrosoftEvent, default_reminders=()): - """Synchronize Microsoft recurrences in Odoo. Creates new recurrences, updates - existing ones. - + def _sync_microsoft2odoo(self, microsoft_events: MicrosoftEvent): + """ + Synchronize Microsoft recurrences in Odoo. + Creates new recurrences, updates existing ones. :return: synchronized odoo """ - existing = microsoft_events.exists(self.env) - new = microsoft_events - existing - microsoft_events.cancelled() - new_recurrent = new.filter(lambda e: e.is_recurrent()) - - default_values = {} + existing = microsoft_events.match_with_odoo_events(self.env) + cancelled = microsoft_events.cancelled() + new = microsoft_events - existing - cancelled + new_recurrence = new.filter(lambda e: e.is_recurrent()) + # create new events and reccurrences odoo_values = [ - dict(self._microsoft_to_odoo_values(e, default_reminders, default_values), need_sync_m=False) - for e in (new - new_recurrent) + dict(self._microsoft_to_odoo_values(e, with_ids=True), need_sync_m=False) + for e in (new - new_recurrence) ] - new_odoo = self.with_context(dont_notify=True)._create_from_microsoft(new, odoo_values) + synced_events = self.with_context(dont_notify=True)._create_from_microsoft(new, odoo_values) + synced_recurrences, updated_events = self._sync_recurrence_microsoft2odoo(existing, new_recurrence) + synced_events |= updated_events - synced_recurrent_records = self._sync_recurrence_microsoft2odoo(new_recurrent) + # remove cancelled events and recurrences + cancelled_recurrences = self.env['calendar.recurrence'].search([ + '|', + ('ms_universal_event_id', 'in', cancelled.uids), + ('ms_organizer_event_id', 'in', cancelled.ids), + ]) + cancelled_events = self.browse([ + e.odoo_id(self.env) + for e in cancelled + if e.id not in [r.ms_organizer_event_id for r in cancelled_recurrences] + ]) + cancelled_recurrences._cancel_microsoft() + cancelled_events = cancelled_events.exists() + cancelled_events._cancel_microsoft() - cancelled = existing.cancelled() - cancelled_odoo = self.browse(cancelled.odoo_ids(self.env)) - cancelled_odoo._cancel_microsoft() + synced_recurrences |= cancelled_recurrences + synced_events |= cancelled_events | cancelled_recurrences.calendar_event_ids - recurrent_cancelled = self.env['calendar.recurrence'].search([ - ('microsoft_id', 'in', (microsoft_events.cancelled() - cancelled).microsoft_ids())]) - recurrent_cancelled._cancel_microsoft() - - synced_records = new_odoo + cancelled_odoo + synced_recurrent_records.calendar_event_ids - - for mevent in (existing - cancelled).filter(lambda e: e.lastModifiedDateTime and not e.seriesMasterId): + # update other events + for mevent in (existing - cancelled).filter(lambda e: e.lastModifiedDateTime): # Last updated wins. # This could be dangerous if microsoft server time and odoo server time are different if mevent.is_recurrence(): - odoo_record = self.env['calendar.recurrence'].browse(mevent.odoo_id(self.env)) + odoo_event = self.env['calendar.recurrence'].browse(mevent.odoo_id(self.env)).exists() else: - odoo_record = self.browse(mevent.odoo_id(self.env)) - odoo_record_updated = pytz.utc.localize(odoo_record.write_date) - updated = parse(mevent.lastModifiedDateTime or str(odoo_record_updated)) - if updated >= odoo_record_updated: - vals = dict(odoo_record._microsoft_to_odoo_values(mevent, default_reminders), need_sync_m=False) - odoo_record._write_from_microsoft(mevent, vals) - if odoo_record._name == 'calendar.recurrence': - odoo_record._update_microsoft_recurrence(mevent, microsoft_events) - synced_recurrent_records |= odoo_record - else: - synced_records |= odoo_record + odoo_event = self.browse(mevent.odoo_id(self.env)).exists() - return synced_records, synced_recurrent_records + if odoo_event: + odoo_event_updated_time = pytz.utc.localize(odoo_event.write_date) + ms_event_updated_time = parse(mevent.lastModifiedDateTime) + + 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) + + if odoo_event._name == 'calendar.recurrence': + update_events = odoo_event._update_microsoft_recurrence(mevent, microsoft_events) + synced_recurrences |= odoo_event + synced_events |= update_events + else: + synced_events |= odoo_event + + return synced_events, synced_recurrences + + def _impersonate_user(self, user_id): + """ Impersonate a user (mainly the event organizer) to be able to call the Outlook API with its token """ + return user_id.with_user(user_id) @after_commit - def _microsoft_delete(self, microsoft_service: MicrosoftCalendarService, microsoft_id, timeout=TIMEOUT): - with microsoft_calendar_token(self.env.user.sudo()) as token: + def _microsoft_delete(self, user_id, event_id, timeout=TIMEOUT): + """ + Once the event has been really removed from the Odoo database, remove it from the Outlook calendar. + + Note that all self attributes to use in this method must be provided as method parameters because + 'self' won't exist when this method will be really called due to @after_commit decorator. + """ + microsoft_service = self._get_microsoft_service() + with microsoft_calendar_token(self._impersonate_user(user_id).sudo()) as token: if token: - microsoft_service.delete(microsoft_id, token=token, timeout=timeout) + microsoft_service.delete(event_id, token=token, timeout=timeout) @after_commit - def _microsoft_patch(self, microsoft_service: MicrosoftCalendarService, microsoft_id, values, timeout=TIMEOUT): - with microsoft_calendar_token(self.env.user.sudo()) as token: + def _microsoft_patch(self, user_id, event_id, values, timeout=TIMEOUT): + """ + Once the event has been really modified in the Odoo database, modify it in the Outlook calendar. + + Note that all self attributes to use in this method must be provided as method parameters because + 'self' may have been modified between the call of '_microsoft_patch' and its execution, + due to @after_commit decorator. + """ + microsoft_service = self._get_microsoft_service() + with microsoft_calendar_token(self._impersonate_user(user_id).sudo()) as token: if token: self._ensure_attendees_have_email() - microsoft_service.patch(microsoft_id, values, token=token, timeout=timeout) - self.need_sync_m = False + res = microsoft_service.patch(event_id, values, token=token, timeout=timeout) + self.write({ + 'need_sync_m': not res, + }) @after_commit - def _microsoft_insert(self, microsoft_service: MicrosoftCalendarService, values, timeout=TIMEOUT): + def _microsoft_insert(self, values, timeout=TIMEOUT): + """ + Once the event has been really added in the Odoo database, add it in the Outlook calendar. + + Note that all self attributes to use in this method must be provided as method parameters because + 'self' may have been modified between the call of '_microsoft_insert' and its execution, + due to @after_commit decorator. + """ if not values: return + microsoft_service = self._get_microsoft_service() with microsoft_calendar_token(self.env.user.sudo()) as token: if token: self._ensure_attendees_have_email() - microsoft_id = microsoft_service.insert(values, token=token, timeout=timeout) + event_id, uid = microsoft_service.insert(values, token=token, timeout=timeout) self.write({ - 'microsoft_id': microsoft_id, + 'microsoft_id': combine_ids(event_id, uid), 'need_sync_m': False, }) - def _microsoft_attendee_answer(self, microsoft_service: MicrosoftCalendarService, microsoft_id, answer, params, timeout=TIMEOUT): + def _microsoft_attendee_answer(self, answer, params, timeout=TIMEOUT): if not answer: return + microsoft_service = self._get_microsoft_service() with microsoft_calendar_token(self.env.user.sudo()) as token: if token: self._ensure_attendees_have_email() - microsoft_service.answer(microsoft_id, answer, params, token=token, timeout=timeout) + microsoft_service.answer( + self.ms_organizer_event_id, + answer, params, token=token, timeout=timeout + ) self.write({ 'need_sync_m': False, }) def _get_microsoft_records_to_sync(self, full_sync=False): - """Return records that should be synced from Odoo to Microsoft - + """ + Return records that should be synced from Odoo to Microsoft :param full_sync: If True, all events attended by the user are returned :return: events """ @@ -346,21 +474,25 @@ class MicrosoftSync(models.AbstractModel): is_active_clause = (self._active_name, '=', True) if self._active_name else expression.TRUE_LEAF domain = expression.AND([domain, [ '|', - '&', ('microsoft_id', '=', False), is_active_clause, - ('need_sync_m', '=', True), + '&', ('ms_universal_event_id', '=', False), is_active_clause, + ('need_sync_m', '=', True), ]]) return self.with_context(active_test=False).search(domain) @api.model - def _microsoft_to_odoo_values(self, microsoft_event: MicrosoftEvent, default_reminders=()): - """Implements this method to return a dict of Odoo values corresponding + def _microsoft_to_odoo_values( + self, microsoft_event: MicrosoftEvent, default_reminders=(), default_values=None, with_ids=False + ): + """ + Implements this method to return a dict of Odoo values corresponding to the Microsoft event given as parameter :return: dict of Odoo formatted values """ raise NotImplementedError() def _microsoft_values(self, fields_to_sync): - """Implements this method to return a dict with values formatted + """ + Implements this method to return a dict with values formatted according to the Microsoft Calendar API :return: dict of Microsoft formatted values """ @@ -370,13 +502,15 @@ class MicrosoftSync(models.AbstractModel): raise NotImplementedError() def _get_microsoft_sync_domain(self): - """Return a domain used to search records to synchronize. + """ + Return a domain used to search records to synchronize. e.g. return a domain to synchronize records owned by the current user. """ raise NotImplementedError() def _get_microsoft_synced_fields(self): - """Return a set of field names. Changing one of these fields + """ + Return a set of field names. Changing one of these fields marks the record to be re-synchronized. """ raise NotImplementedError() diff --git a/addons/microsoft_calendar/models/res_users.py b/addons/microsoft_calendar/models/res_users.py index 50a0473a45a..32233d2ac8d 100644 --- a/addons/microsoft_calendar/models/res_users.py +++ b/addons/microsoft_calendar/models/res_users.py @@ -9,7 +9,8 @@ from datetime import timedelta from odoo import api, fields, models, _ from odoo.exceptions import UserError from odoo.loglevels import exception_to_unicode -from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService, InvalidSyncToken +from odoo.addons.microsoft_account.models.microsoft_service import DEFAULT_MICROSOFT_TOKEN_ENDPOINT +from odoo.addons.microsoft_calendar.utils.microsoft_calendar import InvalidSyncToken _logger = logging.getLogger(__name__) @@ -58,8 +59,9 @@ class User(models.Model): } try: - endpoint = self.env['microsoft.service']._get_token_endpoint() - dummy, response, dummy = self.env['microsoft.service']._do_request(endpoint, params=data, headers=headers, method='POST', preuri='') + dummy, response, dummy = self.env['microsoft.service']._do_request( + DEFAULT_MICROSOFT_TOKEN_ENDPOINT, params=data, headers=headers, method='POST', preuri='' + ) ttl = response.get('expires_in') self.write({ 'microsoft_calendar_token': response.get('access_token'), @@ -83,31 +85,31 @@ class User(models.Model): error_key) raise UserError(error_msg) - def _sync_microsoft_calendar(self, calendar_service: MicrosoftCalendarService): + def _sync_microsoft_calendar(self): self.ensure_one() if self.microsoft_synchronization_stopped: return False + calendar_service = self.env["calendar.event"]._get_microsoft_service() full_sync = not bool(self.microsoft_calendar_sync_token) with microsoft_calendar_token(self) as token: try: - events, next_sync_token, default_reminders = calendar_service.get_events(self.microsoft_calendar_sync_token, token=token) + events, next_sync_token = calendar_service.get_events(self.microsoft_calendar_sync_token, token=token) except InvalidSyncToken: - events, next_sync_token, default_reminders = calendar_service.get_events(token=token) + events, next_sync_token = calendar_service.get_events(token=token) full_sync = True self.microsoft_calendar_sync_token = next_sync_token # Microsoft -> Odoo - recurrences = events.filter(lambda e: e.is_recurrent()) - synced_events, synced_recurrences = self.env['calendar.event']._sync_microsoft2odoo(events, default_reminders=default_reminders) if events else (self.env['calendar.event'], self.env['calendar.recurrence']) + synced_events, synced_recurrences = self.env['calendar.event']._sync_microsoft2odoo(events) if events else (self.env['calendar.event'], self.env['calendar.recurrence']) # Odoo -> Microsoft recurrences = self.env['calendar.recurrence']._get_microsoft_records_to_sync(full_sync=full_sync) recurrences -= synced_recurrences - recurrences._sync_odoo2microsoft(calendar_service) + recurrences._sync_odoo2microsoft() synced_events |= recurrences.calendar_event_ids events = self.env['calendar.event']._get_microsoft_records_to_sync(full_sync=full_sync) - (events - synced_events)._sync_odoo2microsoft(calendar_service) + (events - synced_events)._sync_odoo2microsoft() return bool(events | synced_events) or bool(recurrences | synced_recurrences) @@ -115,11 +117,10 @@ class User(models.Model): def _sync_all_microsoft_calendar(self): """ Cron job """ users = self.env['res.users'].search([('microsoft_calendar_rtoken', '!=', False), ('microsoft_synchronization_stopped', '=', False)]) - microsoft = MicrosoftCalendarService(self.env['microsoft.service']) for user in users: _logger.info("Calendar Synchro - Starting synchronization for %s", user) try: - user.with_user(user).sudo()._sync_microsoft_calendar(microsoft) + user.with_user(user).sudo()._sync_microsoft_calendar() self.env.cr.commit() except Exception as e: _logger.exception("[%s] Calendar Synchro - Exception : %s !", user, exception_to_unicode(e)) diff --git a/addons/microsoft_calendar/tests/__init__.py b/addons/microsoft_calendar/tests/__init__.py index 0a906aa7123..c0e00052229 100644 --- a/addons/microsoft_calendar/tests/__init__.py +++ b/addons/microsoft_calendar/tests/__init__.py @@ -1,5 +1,9 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import test_sync_microsoft2odoo -from . import test_sync_odoo2microsoft +from . import test_microsoft_event +from . import test_microsoft_service +from . import test_create_events +from . import test_update_events +from . import test_delete_events +from . import test_answer_events diff --git a/addons/microsoft_calendar/tests/common.py b/addons/microsoft_calendar/tests/common.py new file mode 100644 index 00000000000..09108d3f8d4 --- /dev/null +++ b/addons/microsoft_calendar/tests/common.py @@ -0,0 +1,503 @@ +import pytz +from datetime import datetime, timedelta +from markupsafe import Markup +from unittest.mock import patch, MagicMock + +from odoo.tests.common import HttpCase + +from odoo.addons.microsoft_calendar.models.microsoft_sync import MicrosoftSync +from odoo.addons.microsoft_calendar.utils.event_id_storage import combine_ids + +def mock_get_token(user): + return f"TOKEN_FOR_USER_{user.id}" + +def _modified_date_in_the_future(event): + """ + Add some seconds to the event write date to be sure to have a last modified date + in the future + """ + return (event.write_date + timedelta(seconds=5)).strftime("%Y-%m-%dT%H:%M:%SZ") + +def patch_api(func): + @patch.object(MicrosoftSync, '_microsoft_insert', MagicMock()) + @patch.object(MicrosoftSync, '_microsoft_delete', MagicMock()) + @patch.object(MicrosoftSync, '_microsoft_patch', MagicMock()) + def patched(self, *args, **kwargs): + return func(self, *args, **kwargs) + return patched + +# By inheriting from TransactionCase, postcommit hooks (so methods tagged with `@after_commit` in MicrosoftSync), +# are not called because no commit is done. +# To be able to manually call these postcommit hooks, we need to inherit from HttpCase. +# Note: as postcommit hooks are called separately, do not forget to invalidate cache for records read during the test. +class TestCommon(HttpCase): + + @patch_api + def setUp(self): + super(TestCommon, self).setUp() + + # prepare users + self.organizer_user = self.env["res.users"].search([("name", "=", "Mike Organizer")]) + if not self.organizer_user: + partner = self.env['res.partner'].create({'name': 'Mike Organizer', 'email': 'mike@organizer.com'}) + self.organizer_user = self.env['res.users'].create({ + 'name': 'Mike Organizer', + 'login': 'mike@organizer.com', + 'partner_id': partner.id, + }) + + self.attendee_user = self.env["res.users"].search([("name", "=", "John Attendee")]) + if not self.attendee_user: + partner = self.env['res.partner'].create({'name': 'John Attendee', 'email': 'john@attendee.com'}) + self.attendee_user = self.env['res.users'].create({ + 'name': 'John Attendee', + 'login': 'john@attendee.com', + 'partner_id': partner.id, + }) + + # ----------------------------------------------------------------------------------------- + # To create Odoo events + # ----------------------------------------------------------------------------------------- + self.start_date = datetime(2021, 9, 22, 10, 0, 0, 0) + self.end_date = datetime(2021, 9, 22, 11, 0, 0, 0) + self.recurrent_event_interval = 2 + self.recurrent_events_count = 7 + self.recurrence_end_date = self.end_date + timedelta( + days=self.recurrent_event_interval * self.recurrent_events_count + ) + + # simple event values to create a Odoo event + self.simple_event_values = { + "name": "simple_event", + "description": "my simple event", + "active": True, + "start": self.start_date, + "stop": self.end_date, + "partner_ids": [(4, self.organizer_user.partner_id.id), (4, self.attendee_user.partner_id.id)], + } + self.recurrent_event_values = { + 'name': 'recurring_event', + 'description': 'a recurring event', + "partner_ids": [(4, self.attendee_user.partner_id.id)], + 'recurrency': True, + 'follow_recurrence': True, + 'start': self.start_date.strftime("%Y-%m-%d %H:%M:%S"), + 'stop': self.end_date.strftime("%Y-%m-%d %H:%M:%S"), + 'event_tz': 'Europe/London', + 'recurrence_update': 'self_only', + 'rrule_type': 'daily', + 'interval': self.recurrent_event_interval, + 'count': self.recurrent_events_count, + 'end_type': 'count', + 'duration': 1, + 'byday': '-1', + 'day': 22, + 'wed': True, + 'weekday': 'WED' + } + + # ----------------------------------------------------------------------------------------- + # Expected values for Odoo events converted to Outlook events (to be posted through API) + # ----------------------------------------------------------------------------------------- + + # simple event values converted in the Outlook format to be posted through the API + self.simple_event_ms_values = { + "subject": self.simple_event_values["name"], + "body": { + 'content': self.simple_event_values["description"], + 'contentType': "text", + }, + "start": { + 'dateTime': pytz.utc.localize(self.simple_event_values["start"]).isoformat(), + 'timeZone': 'Europe/London' + }, + "end": { + 'dateTime': pytz.utc.localize(self.simple_event_values["stop"]).isoformat(), + 'timeZone': 'Europe/London' + }, + "isAllDay": False, + "organizer": { + 'emailAddress': { + 'address': self.organizer_user.email, + 'name': self.organizer_user.display_name, + } + }, + "isOrganizer": True, + "sensitivity": "normal", + "showAs": "busy", + "attendees": [ + { + 'emailAddress': { + 'address': self.attendee_user.email, + 'name': self.attendee_user.display_name + }, + 'status': {'response': "notresponded"} + } + ], + "isReminderOn": False, + "location": {'displayName': ''}, + "reminderMinutesBeforeStart": 0, + } + + self.recurrent_event_ms_values = { + 'subject': self.recurrent_event_values["name"], + "body": { + 'content': Markup('

%s

' % self.recurrent_event_values["description"]), + 'contentType': "html", + }, + 'start': { + 'dateTime': self.start_date.strftime("%Y-%m-%dT%H:%M:%S+00:00"), + 'timeZone': 'Europe/London' + }, + 'end': { + 'dateTime': self.end_date.strftime("%Y-%m-%dT%H:%M:%S+00:00"), + 'timeZone': 'Europe/London' + }, + 'isAllDay': False, + 'isOrganizer': True, + 'isReminderOn': False, + 'reminderMinutesBeforeStart': 0, + 'sensitivity': 'normal', + 'showAs': 'busy', + 'type': 'seriesMaster', + "attendees": [ + { + 'emailAddress': { + 'address': self.attendee_user.email, + 'name': self.attendee_user.display_name + }, + 'status': {'response': "notresponded"} + } + ], + 'location': {'displayName': ''}, + 'organizer': { + 'emailAddress': { + 'address': self.organizer_user.email, + 'name': self.organizer_user.display_name, + }, + }, + 'recurrence': { + 'pattern': {'dayOfMonth': 22, 'interval': self.recurrent_event_interval, 'type': 'daily'}, + 'range': { + 'numberOfOccurrences': self.recurrent_events_count, + 'startDate': self.start_date.strftime("%Y-%m-%d"), + 'type': 'numbered' + }, + }, + } + + # ----------------------------------------------------------------------------------------- + # Events coming from Outlook (so from the API) + # ----------------------------------------------------------------------------------------- + + self.simple_event_from_outlook_organizer = { + 'type': 'singleInstance', + 'seriesMasterId': None, + 'id': '123', + 'iCalUId': '456', + 'subject': 'simple_event', + 'body': { + 'content': "my simple event", + 'contentType': "text", + }, + 'start': {'dateTime': self.start_date.strftime("%Y-%m-%dT%H:%M:%S.0000000"), 'timeZone': 'UTC'}, + 'end': {'dateTime': self.end_date.strftime("%Y-%m-%dT%H:%M:%S.0000000"), 'timeZone': 'UTC'}, + 'attendees': [{ + 'type': 'required', + 'status': {'response': 'none', 'time': '0001-01-01T00:00:00Z'}, + 'emailAddress': {'name': self.attendee_user.display_name, 'address': self.attendee_user.email} + }], + 'isAllDay': False, + 'isCancelled': False, + 'sensitivity': 'normal', + 'showAs': 'busy', + 'isOnlineMeeting': False, + 'onlineMeetingUrl': None, + 'isOrganizer': True, + 'isReminderOn': True, + 'location': {'displayName': ''}, + 'organizer': { + 'emailAddress': {'address': self.organizer_user.email, 'name': self.organizer_user.display_name}, + }, + 'reminderMinutesBeforeStart': 15, + 'responseRequested': True, + 'responseStatus': { + 'response': 'organizer', + 'time': '0001-01-01T00:00:00Z', + }, + } + + self.simple_event_from_outlook_attendee = self.simple_event_from_outlook_organizer + self.simple_event_from_outlook_attendee.update(isOrganizer=False) + + # ----------------------------------------------------------------------------------------- + # Expected values for Outlook events converted to Odoo events + # ----------------------------------------------------------------------------------------- + + self.expected_odoo_event_from_outlook = { + "name": "simple_event", + "description": Markup('

my simple event

'), + "active": True, + "start": self.start_date, + "stop": self.end_date, + "user_id": self.organizer_user, + "microsoft_id": combine_ids("123", "456"), + "partner_ids": [self.organizer_user.partner_id.id, self.attendee_user.partner_id.id], + } + self.expected_odoo_recurrency_from_outlook = { + 'active': True, + 'byday': '1', + 'count': 0, + 'day': 0, + 'display_name': "Every %s Days until %s" % ( + self.recurrent_event_interval, self.recurrence_end_date.strftime("%Y-%m-%d") + ), + 'dtstart': self.start_date, + 'end_type': 'end_date', + 'event_tz': False, + 'fri': False, + 'interval': self.recurrent_event_interval, + 'month_by': 'date', + 'microsoft_id': combine_ids('REC123', 'REC456'), + 'name': "Every %s Days until %s" % ( + self.recurrent_event_interval, self.recurrence_end_date.strftime("%Y-%m-%d") + ), + 'need_sync_m': False, + 'rrule': 'DTSTART:%s\nRRULE:FREQ=DAILY;INTERVAL=%s;UNTIL=%s' % ( + self.start_date.strftime("%Y%m%dT%H%M%S"), + self.recurrent_event_interval, + self.recurrence_end_date.strftime("%Y%m%dT235959"), + ), + 'rrule_type': 'daily', + 'until': self.recurrence_end_date.date(), + 'weekday': False, + } + + self.recurrent_event_from_outlook_organizer = [{ + 'attendees': [{ + 'emailAddress': {'address': self.attendee_user.email, 'name': self.attendee_user.display_name}, + 'status': {'response': 'none', 'time': '0001-01-01T00:00:00Z'}, + 'type': 'required' + }], + 'body': { + 'content': "my recurrent event", + 'contentType': "text", + }, + 'start': {'dateTime': self.start_date.strftime("%Y-%m-%dT%H:%M:%S.0000000"), 'timeZone': 'UTC'}, + 'end': {'dateTime': self.end_date.strftime("%Y-%m-%dT%H:%M:%S.0000000"), 'timeZone': 'UTC'}, + 'id': 'REC123', + 'iCalUId': 'REC456', + 'isAllDay': False, + 'isCancelled': False, + 'isOnlineMeeting': False, + 'isOrganizer': True, + 'isReminderOn': True, + 'location': {'displayName': ''}, + 'organizer': {'emailAddress': { + 'address': self.organizer_user.email, 'name': self.organizer_user.display_name} + }, + 'recurrence': { + 'pattern': { + 'dayOfMonth': 0, + 'firstDayOfWeek': 'sunday', + 'index': 'first', + 'interval': self.recurrent_event_interval, + 'month': 0, + 'type': 'daily' + }, + 'range': { + 'startDate': self.start_date.strftime("%Y-%m-%d"), + 'endDate': self.recurrence_end_date.strftime("%Y-%m-%d"), + 'numberOfOccurrences': 0, + 'recurrenceTimeZone': 'Romance Standard Time', + 'type': 'endDate' + } + }, + 'reminderMinutesBeforeStart': 15, + 'responseRequested': True, + 'responseStatus': {'response': 'organizer', 'time': '0001-01-01T00:00:00Z'}, + 'sensitivity': 'normal', + 'seriesMasterId': None, + 'showAs': 'busy', + 'subject': "recurrent event", + 'type': 'seriesMaster', + }] + self.recurrent_event_from_outlook_organizer += [ + { + 'attendees': [{ + 'emailAddress': {'address': self.attendee_user.email, 'name': self.attendee_user.display_name}, + 'status': {'response': 'none', 'time': '0001-01-01T00:00:00Z'}, + 'type': 'required' + }], + 'body': { + 'content': "my recurrent event", + 'contentType': "text", + }, + 'start': { + 'dateTime': ( + self.start_date + timedelta(days=i * self.recurrent_event_interval) + ).strftime("%Y-%m-%dT%H:%M:%S.0000000"), + 'timeZone': 'UTC' + }, + 'end': { + 'dateTime': ( + self.end_date + timedelta(days=i * self.recurrent_event_interval) + ).strftime("%Y-%m-%dT%H:%M:%S.0000000"), + 'timeZone': 'UTC' + }, + 'id': f'REC123_EVENT_{i+1}', + 'iCalUId': f'REC456_EVENT_{i+1}', + 'seriesMasterId': 'REC123', + 'isAllDay': False, + 'isCancelled': False, + 'isOnlineMeeting': False, + 'isOrganizer': True, + 'isReminderOn': True, + 'location': {'displayName': ''}, + 'organizer': { + 'emailAddress': {'address': self.organizer_user.email, 'name': self.organizer_user.display_name} + }, + 'recurrence': None, + 'reminderMinutesBeforeStart': 15, + 'responseRequested': True, + 'responseStatus': {'response': 'organizer', 'time': '0001-01-01T00:00:00Z'}, + 'sensitivity': 'normal', + 'showAs': 'busy', + 'subject': "recurrent event", + 'type': 'occurrence', + } + for i in range(self.recurrent_events_count) + ] + self.recurrent_event_from_outlook_attendee = [ + dict( + d, + isOrganizer=False, + attendees=[ + { + 'emailAddress': {'address': self.organizer_user.email, 'name': self.organizer_user.display_name}, + 'status': {'response': 'none', 'time': '0001-01-01T00:00:00Z'}, + 'type': 'required' + }, + { + 'emailAddress': {'address': self.attendee_user.email, 'name': self.attendee_user.display_name}, + 'status': {'response': 'none', 'time': '0001-01-01T00:00:00Z'}, + 'type': 'required' + }, + ] + ) + for d in self.recurrent_event_from_outlook_organizer + ] + + self.expected_odoo_recurrency_events_from_outlook = [ + { + "name": "recurrent event", + "user_id": self.organizer_user, + "partner_ids": [self.organizer_user.partner_id.id, self.attendee_user.partner_id.id], + "start": self.start_date + timedelta(days=i * self.recurrent_event_interval), + "stop": self.end_date + timedelta(days=i * self.recurrent_event_interval), + "until": self.recurrence_end_date.date(), + "microsoft_recurrence_master_id": "REC123", + 'microsoft_id': combine_ids(f"REC123_EVENT_{i+1}", f"REC456_EVENT_{i+1}"), + "recurrency": True, + "follow_recurrence": True, + "active": True, + } + for i in range(self.recurrent_events_count) + ] + self.env.cr.postcommit.clear() + + def create_events_for_tests(self): + """ + Create some events for test purpose + """ + + # ---- create some events that will be updated during tests ----- + + # a simple event + self.simple_event = self.env["calendar.event"].search([("name", "=", "simple_event")]) + if not self.simple_event: + self.simple_event = self.env["calendar.event"].with_user(self.organizer_user).create( + dict( + self.simple_event_values, + microsoft_id=combine_ids("123", "456"), + ) + ) + + # a recurrent event with 7 occurrences + self.recurrent_base_event = self.env["calendar.event"].search( + [("name", "=", "recurrent_event")], + order="id", + limit=1, + ) + already_created = self.recurrent_base_event + + if not already_created: + self.recurrent_base_event = self.env["calendar.event"].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({ + "microsoft_id": combine_ids("REC123", "REC456"), + }) + for i, e in enumerate(self.recurrence.calendar_event_ids.sorted(key=lambda r: r.start)): + e.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) + + def assert_odoo_event(self, odoo_event, expected_values): + """ + Assert that an Odoo event has the same values than in the expected_values dictionary, + for the keys present in expected_values. + """ + self.assertTrue(expected_values) + + odoo_event_values = odoo_event.read(list(expected_values.keys()))[0] + for k, v in expected_values.items(): + if k in ("user_id", "recurrence_id"): + v = (v.id, v.name) if v else False + + if isinstance(v, list): + self.assertListEqual(sorted(v), sorted(odoo_event_values.get(k)), msg=f"'{k}' mismatch") + else: + self.assertEqual(v, odoo_event_values.get(k), msg=f"'{k}' mismatch") + + def assert_odoo_recurrence(self, odoo_recurrence, expected_values): + """ + Assert that an Odoo recurrence has the same values than in the expected_values dictionary, + for the keys present in expected_values. + """ + odoo_recurrence_values = odoo_recurrence.read(list(expected_values.keys()))[0] + + for k, v in expected_values.items(): + self.assertEqual(v, odoo_recurrence_values.get(k), msg=f"'{k}' mismatch") + + def assert_dict_equal(self, dict1, dict2): + + # check missing keys + keys = set(dict1.keys()) ^ set(dict2.keys()) + self.assertFalse(keys, msg="Following keys are not in both dicts: %s" % ", ".join(keys)) + + # compare key by key + for k, v in dict1.items(): + self.assertEqual(v, dict2.get(k), f"'{k}' mismatch") + + def call_post_commit_hooks(self): + """ + manually calls postcommit hooks defined with the decorator @after_commit + """ + + # need to manually handle post-commit hooks calls as `self.env.cr.postcommit.run()` clean + # the queue at the end of the first post-commit hook call ... + funcs = self.env.cr.postcommit._funcs.copy() + while funcs: + func = funcs.popleft() + func() diff --git a/addons/microsoft_calendar/tests/test_answer_events.py b/addons/microsoft_calendar/tests/test_answer_events.py new file mode 100644 index 00000000000..ea67652c54a --- /dev/null +++ b/addons/microsoft_calendar/tests/test_answer_events.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +from unittest.mock import patch, ANY + +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.utils.event_id_storage import combine_ids +from odoo.addons.microsoft_calendar.tests.common import TestCommon, mock_get_token, _modified_date_in_the_future, patch_api + + +@patch.object(User, '_get_microsoft_calendar_token', mock_get_token) +class TestAnswerEvents(TestCommon): + + @patch_api + def setUp(self): + super().setUp() + + # a simple event + self.simple_event = self.env["calendar.event"].search([("name", "=", "simple_event")]) + if not self.simple_event: + self.simple_event = self.env["calendar.event"].with_user(self.organizer_user).create( + dict( + self.simple_event_values, + microsoft_id=combine_ids("123", "456"), + ) + ) + + @patch.object(MicrosoftCalendarService, 'patch') + def test_attendee_accepts_event_from_odoo_calendar(self, mock_patch): + attendee = self.env["calendar.attendee"].search([ + ('event_id', '=', self.simple_event.id), + ('partner_id', '=', self.attendee_user.partner_id.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, + ) + + @patch.object(MicrosoftCalendarService, 'patch') + def test_attendee_declines_event_from_odoo_calendar(self, mock_patch): + attendee = self.env["calendar.attendee"].search([ + ('event_id', '=', self.simple_event.id), + ('partner_id', '=', self.attendee_user.partner_id.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, + ) + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_attendee_accepts_event_from_outlook_calendar(self, mock_get_events): + """ + In his Outlook calendar, the attendee accepts the event and sync with his odoo calendar. + """ + mock_get_events.return_value = ( + MicrosoftEvent([dict( + self.simple_event_from_outlook_organizer, + attendees=[{ + 'type': 'required', + 'status': {'response': 'accepted', 'time': '0001-01-01T00:00:00Z'}, + 'emailAddress': {'name': self.attendee_user.display_name, 'address': self.attendee_user.email} + }], + lastModifiedDateTime=_modified_date_in_the_future(self.simple_event) + )]), None + ) + self.attendee_user.with_user(self.attendee_user).sudo()._sync_microsoft_calendar() + + attendee = self.env["calendar.attendee"].search([ + ('event_id', '=', self.simple_event.id), + ('partner_id', '=', self.attendee_user.partner_id.id) + ]) + self.assertEqual(attendee.state, "accepted") + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_attendee_accepts_event_from_outlook_calendar_synced_by_organizer(self, mock_get_events): + """ + In his Outlook calendar, the attendee accepts the event and the organizer syncs his odoo calendar. + """ + mock_get_events.return_value = ( + MicrosoftEvent([dict( + self.simple_event_from_outlook_organizer, + attendees=[{ + 'type': 'required', + 'status': {'response': 'accepted', 'time': '0001-01-01T00:00:00Z'}, + 'emailAddress': {'name': self.attendee_user.display_name, 'address': self.attendee_user.email} + }], + lastModifiedDateTime=_modified_date_in_the_future(self.simple_event) + )]), None + ) + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + + attendee = self.env["calendar.attendee"].search([ + ('event_id', '=', self.simple_event.id), + ('partner_id', '=', self.attendee_user.partner_id.id) + ]) + self.assertEqual(attendee.state, "accepted") + + def test_attendee_declines_event_from_outlook_calendar(self): + """ + In his Outlook calendar, the attendee declines the event leading to automatically + delete this event (that's the way Outlook handles it ...) + + LIMITATION: + + But, as there is no way to get the iCalUId to identify the corresponding Odoo event, + there is no way to update the attendee status to "declined". + """ + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_attendee_declines_event_from_outlook_calendar_synced_by_organizer(self, mock_get_events): + """ + In his Outlook calendar, the attendee declines the event leading to automatically + delete this event (that's the way Outlook handles it ...) + """ + mock_get_events.return_value = ( + MicrosoftEvent([dict( + self.simple_event_from_outlook_organizer, + attendees=[{ + 'type': 'required', + 'status': {'response': 'declined', 'time': '0001-01-01T00:00:00Z'}, + 'emailAddress': {'name': self.attendee_user.display_name, 'address': self.attendee_user.email} + }], + lastModifiedDateTime=_modified_date_in_the_future(self.simple_event) + )]), None + ) + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + + attendee = self.env["calendar.attendee"].search([ + ('event_id', '=', self.simple_event.id), + ('partner_id', '=', self.attendee_user.partner_id.id) + ]) + self.assertEqual(attendee.state, "declined") diff --git a/addons/microsoft_calendar/tests/test_create_events.py b/addons/microsoft_calendar/tests/test_create_events.py new file mode 100644 index 00000000000..605335cd690 --- /dev/null +++ b/addons/microsoft_calendar/tests/test_create_events.py @@ -0,0 +1,259 @@ +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 + +@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): + """ + A Odoo event is created when Outlook sync is not enabled. + """ + + # arrange + self.organizer_user.microsoft_synchronization_stopped = True + + # act + record = self.env["calendar.event"].with_user(self.organizer_user).create(self.simple_event_values) + self.call_post_commit_hooks() + record.invalidate_recordset() + + # assert + mock_insert.assert_not_called() + self.assertEqual(record.need_sync_m, False) + + def test_create_simple_event_without_email(self): + """ + Outlook does not accept attendees without email. + """ + # arrange + self.attendee_user.partner_id.email = False + + # act & assert + record = self.env["calendar.event"].with_user(self.organizer_user).create(self.simple_event_values) + + with self.assertRaises(ValidationError): + record._sync_odoo2microsoft() + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_create_simple_event_from_outlook_organizer_calendar(self, mock_get_events): + """ + An event has been created in Outlook and synced in the Odoo organizer calendar. + """ + + # arrange + mock_get_events.return_value = (MicrosoftEvent([self.simple_event_from_outlook_organizer]), None) + existing_records = self.env["calendar.event"].search([]) + + # act + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + + # assert + records = self.env["calendar.event"].search([]) + new_records = (records - existing_records) + self.assertEqual(len(new_records), 1) + self.assert_odoo_event(new_records, self.expected_odoo_event_from_outlook) + self.assertEqual(new_records.user_id, self.organizer_user) + self.assertEqual(new_records.need_sync_m, False) + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_create_simple_event_from_outlook_attendee_calendar_and_organizer_exists_in_odoo(self, mock_get_events): + """ + An event has been created in Outlook and synced in the Odoo attendee calendar. + There is a Odoo user that matches with the organizer email address. + """ + + # arrange + mock_get_events.return_value = (MicrosoftEvent([self.simple_event_from_outlook_attendee]), None) + existing_records = self.env["calendar.event"].search([]) + + # act + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + + # assert + records = self.env["calendar.event"].search([]) + new_records = (records - existing_records) + self.assertEqual(len(new_records), 1) + self.assert_odoo_event(new_records, self.expected_odoo_event_from_outlook) + 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 + ): + """ + An event has been created in Outlook and synced in the Odoo attendee calendar. + no Odoo user that matches with the organizer email address. + """ + + # arrange + outlook_event = self.simple_event_from_outlook_attendee + outlook_event = dict(self.simple_event_from_outlook_attendee, organizer={ + 'emailAddress': {'address': "john.doe@odoo.com", 'name': "John Doe"}, + }) + expected_event = dict(self.expected_odoo_event_from_outlook, user_id=False) + + mock_get_events.return_value = (MicrosoftEvent([outlook_event]), None) + existing_records = self.env["calendar.event"].search([]) + + # act + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + + # assert + records = self.env["calendar.event"].search([]) + new_records = (records - existing_records) + self.assertEqual(len(new_records), 1) + self.assert_odoo_event(new_records, expected_event) + + @patch.object(MicrosoftCalendarService, 'insert') + def test_create_recurrent_event_without_sync(self, mock_insert): + """ + A Odoo recurrent event is created when Outlook sync is not enabled. + """ + + # arrange + self.organizer_user.microsoft_synchronization_stopped = True + + # act + record = self.env["calendar.event"].with_user(self.organizer_user).create(self.recurrent_event_values) + self.call_post_commit_hooks() + record.invalidate_recordset() + + # assert + mock_insert.assert_not_called() + self.assertEqual(record.need_sync_m, False) + + @patch.object(MicrosoftCalendarService, 'get_events') + @patch.object(MicrosoftCalendarService, 'insert') + def test_create_recurrent_event_with_sync(self, mock_insert, mock_get_events): + """ + A Odoo recurrent event is created when Outlook sync is enabled. + """ + + # >>> first phase: create the recurrence + + # act + record = self.env["calendar.event"].with_user(self.organizer_user).create(self.recurrent_event_values) + + # assert + recurrence = self.env["calendar.recurrence"].search([("base_event_id", "=", record.id)]) + + mock_insert.assert_not_called() + self.assertEqual(record.name, "recurring_event") + self.assertEqual(recurrence.name, "Every 2 Days for 7 events") + self.assertEqual(len(recurrence.calendar_event_ids), 7) + + # >>> second phase: sync with organizer outlook calendar + + # arrange + event_id = "123" + event_iCalUId = "456" + mock_insert.return_value = (event_id, event_iCalUId) + mock_get_events.return_value = ([], None) + + # act + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + self.call_post_commit_hooks() + recurrence.invalidate_recordset() + + # assert + self.assertEqual(recurrence.ms_organizer_event_id, event_id) + self.assertEqual(recurrence.ms_universal_event_id, event_iCalUId) + self.assertEqual(recurrence.need_sync_m, False) + + mock_insert.assert_called_once() + self.assert_dict_equal(mock_insert.call_args[0][0], self.recurrent_event_ms_values) + + @patch.object(MicrosoftCalendarService, 'get_events') + @patch.object(MicrosoftCalendarService, 'insert') + def test_create_recurrent_event_with_sync_by_another_user(self, mock_insert, mock_get_events): + """ + A Odoo recurrent event has been created and synced with Outlook by another user, but nothing + should happen as it we prevent sync of recurrences from other users + ( see microsoft_calendar/models/calendar_recurrence_rule.py::_get_microsoft_sync_domain() ) + """ + + # >>> first phase: create the recurrence + + # act + record = self.env["calendar.event"].with_user(self.organizer_user).create(self.recurrent_event_values) + + # assert + recurrence = self.env["calendar.recurrence"].search([("base_event_id", "=", record.id)]) + + mock_insert.assert_not_called() + self.assertEqual(record.name, "recurring_event") + self.assertEqual(recurrence.name, f"Every 2 Days for {self.recurrent_events_count} events") + self.assertEqual(len(recurrence.calendar_event_ids), self.recurrent_events_count) + + # >>> second phase: sync with attendee Outlook calendar + + # arrange + event_id = "123" + event_iCalUId = "456" + mock_insert.return_value = (event_id, event_iCalUId) + mock_get_events.return_value = ([], None) + + # act + self.attendee_user.with_user(self.attendee_user).sudo()._sync_microsoft_calendar() + self.call_post_commit_hooks() + recurrence.invalidate_recordset() + + # assert + mock_insert.assert_not_called() + + self.assertEqual(recurrence.ms_organizer_event_id, False) + self.assertEqual(recurrence.ms_universal_event_id, False) + self.assertEqual(recurrence.need_sync_m, False) + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_create_recurrent_event_from_outlook_organizer_calendar(self, mock_get_events): + """ + A recurrent event has been created in Outlook and synced in the Odoo organizer calendar. + """ + + # arrange + mock_get_events.return_value = (MicrosoftEvent(self.recurrent_event_from_outlook_organizer), None) + existing_events = self.env["calendar.event"].search([]) + existing_recurrences = self.env["calendar.recurrence"].search([]) + + # act + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + + # assert + new_events = (self.env["calendar.event"].search([]) - existing_events) + new_recurrences = (self.env["calendar.recurrence"].search([]) - existing_recurrences) + self.assertEqual(len(new_recurrences), 1) + self.assertEqual(len(new_events), self.recurrent_events_count) + 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, 'get_events') + def test_create_recurrent_event_from_outlook_attendee_calendar(self, mock_get_events): + """ + A recurrent event has been created in Outlook and synced in the Odoo attendee calendar. + """ + + # arrange + mock_get_events.return_value = (MicrosoftEvent(self.recurrent_event_from_outlook_attendee), None) + existing_events = self.env["calendar.event"].search([]) + existing_recurrences = self.env["calendar.recurrence"].search([]) + + # act + self.attendee_user.with_user(self.attendee_user).sudo()._sync_microsoft_calendar() + + # assert + new_events = (self.env["calendar.event"].search([]) - existing_events) + new_recurrences = (self.env["calendar.recurrence"].search([]) - existing_recurrences) + self.assertEqual(len(new_recurrences), 1) + self.assertEqual(len(new_events), self.recurrent_events_count) + 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]) diff --git a/addons/microsoft_calendar/tests/test_delete_events.py b/addons/microsoft_calendar/tests/test_delete_events.py new file mode 100644 index 00000000000..7e3d45a056f --- /dev/null +++ b/addons/microsoft_calendar/tests/test_delete_events.py @@ -0,0 +1,254 @@ +# -*- coding: utf-8 -*- +from unittest.mock import patch, ANY + +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, + _modified_date_in_the_future, + patch_api +) + +@patch.object(User, '_get_microsoft_calendar_token', mock_get_token) +class TestDeleteEvents(TestCommon): + + @patch_api + def setUp(self): + super(TestDeleteEvents, self).setUp() + self.create_events_for_tests() + + @patch.object(MicrosoftCalendarService, 'delete') + def test_delete_simple_event_from_odoo_organizer_calendar(self, mock_delete): + event_id = self.simple_event.ms_organizer_event_id + + self.simple_event.with_user(self.organizer_user).unlink() + self.call_post_commit_hooks() + self.simple_event.invalidate_recordset() + + self.assertFalse(self.simple_event.exists()) + mock_delete.assert_called_once_with( + event_id, + token=mock_get_token(self.organizer_user), + timeout=ANY + ) + + @patch.object(MicrosoftCalendarService, 'delete') + def test_delete_simple_event_from_odoo_attendee_calendar(self, mock_delete): + event_id = self.simple_event.ms_organizer_event_id + + self.simple_event.with_user(self.attendee_user).unlink() + self.call_post_commit_hooks() + self.simple_event.invalidate_recordset() + + self.assertFalse(self.simple_event.exists()) + mock_delete.assert_called_once_with( + event_id, + token=mock_get_token(self.organizer_user), + timeout=ANY + ) + + @patch.object(MicrosoftCalendarService, 'delete') + def test_archive_simple_event_from_odoo_organizer_calendar(self, mock_delete): + event_id = self.simple_event.ms_organizer_event_id + + self.simple_event.with_user(self.organizer_user).write({'active': False}) + self.call_post_commit_hooks() + self.simple_event.invalidate_recordset() + + self.assertTrue(self.simple_event.exists()) + self.assertFalse(self.simple_event.active) + mock_delete.assert_called_once_with( + event_id, + token=mock_get_token(self.organizer_user), + timeout=ANY + ) + + @patch.object(MicrosoftCalendarService, 'delete') + def test_archive_simple_event_from_odoo_attendee_calendar(self, mock_delete): + event_id = self.simple_event.ms_organizer_event_id + + self.simple_event.with_user(self.attendee_user).write({'active': False}) + self.call_post_commit_hooks() + self.simple_event.invalidate_recordset() + + self.assertTrue(self.simple_event.exists()) + self.assertFalse(self.simple_event.active) + mock_delete.assert_called_once_with( + event_id, + token=mock_get_token(self.organizer_user), + timeout=ANY + ) + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_cancel_simple_event_from_outlook_organizer_calendar(self, mock_get_events): + """ + In his Outlook calendar, the organizer cannot delete the event, he can only cancel it. + """ + event_id = self.simple_event.ms_organizer_event_id + mock_get_events.return_value = ( + MicrosoftEvent([{ + "id": event_id, + "@removed": {"reason": "deleted"} + }]), + None + ) + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + self.assertFalse(self.simple_event.exists()) + + def test_delete_simple_event_from_outlook_attendee_calendar(self): + """ + If an attendee deletes an event from its Outlook calendar, during the sync, Odoo will be notified that + this event has been deleted BUT only with the attendees's calendar event id and not with the global one + (called iCalUId). That means, it's not possible to match this deleted event with an Odoo event. + + LIMITATION: + + Unfortunately, there is no magic solution: + 1) keep the list of calendar events ids linked to a unique iCalUId but all Odoo users may not have synced + their Odoo calendar, leading to missing ids in the list => bad solution. + 2) call the microsoft API to get the iCalUId matching the received event id => as the event has already + been deleted, this call may return an error. + """ + + @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 + + # act + self.recurrent_events[idx].with_user(self.organizer_user).unlink() + self.call_post_commit_hooks() + + # assert + self.assertFalse(self.recurrent_events[idx].exists()) + self.assertEqual(len(self.recurrence.calendar_event_ids), self.recurrent_events_count - 1) + mock_delete.assert_called_once_with( + event_id, + token=mock_get_token(self.organizer_user), + timeout=ANY + ) + + @patch.object(MicrosoftCalendarService, 'delete') + def test_delete_first_event_from_recurrence_from_odoo_calendar(self, mock_delete): + + # arrange + idx = 0 + event_id = self.recurrent_events[idx].ms_organizer_event_id + + # act + self.recurrent_events[idx].with_user(self.organizer_user).unlink() + self.call_post_commit_hooks() + + # assert + self.assertFalse(self.recurrent_events[idx].exists()) + self.assertEqual(len(self.recurrence.calendar_event_ids), self.recurrent_events_count - 1) + self.assertEqual(self.recurrence.base_event_id, self.recurrent_events[1]) + mock_delete.assert_called_once_with( + event_id, + token=mock_get_token(self.organizer_user), + timeout=ANY + ) + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_delete_one_event_from_recurrence_from_outlook_calendar(self, mock_get_events): + """ + When a single event is removed from a recurrence, Outlook returns the recurrence and + events which still exist. + """ + # arrange + idx = 3 + rec_values = [ + dict( + event, + lastModifiedDateTime=_modified_date_in_the_future(self.recurrence) + ) + for i, event in enumerate(self.recurrent_event_from_outlook_organizer) + if i != (idx + 1) # + 1 because recurrent_event_from_outlook_organizer contains the recurrence itself as first item + ] + event_to_remove = self.recurrent_events[idx] + mock_get_events.return_value = (MicrosoftEvent(rec_values), None) + + # act + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + + # assert + self.assertFalse(event_to_remove.exists()) + self.assertEqual(len(self.recurrence.calendar_event_ids), self.recurrent_events_count - 1) + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_delete_first_event_from_recurrence_from_outlook_calendar(self, mock_get_events): + + # arrange + rec_values = [ + dict( + event, + lastModifiedDateTime=_modified_date_in_the_future(self.recurrence) + ) + for i, event in enumerate(self.recurrent_event_from_outlook_organizer) + if i != 1 + ] + event_to_remove = self.recurrent_events[0] + next_base_event = self.recurrent_events[1] + mock_get_events.return_value = (MicrosoftEvent(rec_values), None) + + # act + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + + # assert + self.assertFalse(event_to_remove.exists()) + self.assertEqual(len(self.recurrence.calendar_event_ids), self.recurrent_events_count - 1) + self.assertEqual(self.recurrence.base_event_id, next_base_event) + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_delete_one_event_and_future_from_recurrence_from_outlook_calendar(self, mock_get_events): + # arrange + idx = range(4, self.recurrent_events_count) + rec_values = [ + dict( + event, + lastModifiedDateTime=_modified_date_in_the_future(self.recurrence) + ) + for i, event in enumerate(self.recurrent_event_from_outlook_organizer) + if i not in [x + 1 for x in idx] + ] + event_to_remove = [e for i, e in enumerate(self.recurrent_events) if i in idx] + mock_get_events.return_value = (MicrosoftEvent(rec_values), None) + + # act + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + + # assert + for e in event_to_remove: + self.assertFalse(e.exists()) + self.assertEqual(len(self.recurrence.calendar_event_ids), self.recurrent_events_count - len(idx)) + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_delete_first_event_and_future_from_recurrence_from_outlook_calendar(self, mock_get_events): + """ + In Outlook, deleting the first event and future ones is the same than removing all the recurrence. + """ + # arrange + mock_get_events.return_value = ( + MicrosoftEvent([{ + "id": self.recurrence.ms_organizer_event_id, + "@removed": {"reason": "deleted"} + }]), + None + ) + + # act + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + + # assert + self.assertFalse(self.recurrence.exists()) + self.assertFalse(self.recurrence.calendar_event_ids.exists()) + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_delete_all_events_from_recurrence_from_outlook_calendar(self, mock_get_events): + """ + Same than test_delete_first_event_and_future_from_recurrence_from_outlook_calendar. + """ diff --git a/addons/microsoft_calendar/tests/test_microsoft_event.py b/addons/microsoft_calendar/tests/test_microsoft_event.py new file mode 100644 index 00000000000..85707688496 --- /dev/null +++ b/addons/microsoft_calendar/tests/test_microsoft_event.py @@ -0,0 +1,290 @@ +from odoo.addons.microsoft_calendar.utils.microsoft_event import MicrosoftEvent +from odoo.addons.microsoft_calendar.tests.common import TestCommon, patch_api + +class TestMicrosoftEvent(TestCommon): + + @patch_api + def setUp(self): + super().setUp() + self.create_events_for_tests() + + def test_already_mapped_events(self): + + # arrange + event_id = self.simple_event.ms_organizer_event_id + event_uid = self.simple_event.ms_universal_event_id + events = MicrosoftEvent([{ + "type": "singleInstance", + "_odoo_id": self.simple_event.id, + "iCalUId": event_uid, + "id": event_id, + }]) + + # act + mapped = events._load_odoo_ids_from_db(self.env) + + # assert + self.assertEqual(len(mapped._events), 1) + self.assertEqual(mapped._events[event_id]["_odoo_id"], self.simple_event.id) + + def test_map_an_event_using_global_id(self): + + # arrange + event_id = self.simple_event.ms_organizer_event_id + event_uid = self.simple_event.ms_universal_event_id + events = MicrosoftEvent([{ + "type": "singleInstance", + "_odoo_id": False, + "iCalUId": event_uid, + "id": event_id, + }]) + + # act + mapped = events._load_odoo_ids_from_db(self.env) + + # assert + self.assertEqual(len(mapped._events), 1) + self.assertEqual(mapped._events[event_id]["_odoo_id"], self.simple_event.id) + + def test_map_an_event_using_instance_id(self): + """ + Here, the Odoo event has an uid but the Outlook event has not. + """ + # arrange + event_id = self.simple_event.ms_organizer_event_id + events = MicrosoftEvent([{ + "type": "singleInstance", + "_odoo_id": False, + "iCalUId": False, + "id": event_id, + }]) + + # act + mapped = events._load_odoo_ids_from_db(self.env) + + # assert + self.assertEqual(len(mapped._events), 1) + self.assertEqual(mapped._events[event_id]["_odoo_id"], self.simple_event.id) + + def test_map_an_event_without_uid_using_instance_id(self): + """ + Here, the Odoo event has no uid but the Outlook event has one. + """ + + # arrange + event_id = self.simple_event.ms_organizer_event_id + event_uid = self.simple_event.ms_universal_event_id + self.simple_event.ms_universal_event_id = False + events = MicrosoftEvent([{ + "type": "singleInstance", + "_odoo_id": False, + "iCalUId": event_uid, + "id": event_id, + }]) + + # act + mapped = events._load_odoo_ids_from_db(self.env) + + # assert + self.assertEqual(len(mapped._events), 1) + self.assertEqual(mapped._events[event_id]["_odoo_id"], self.simple_event.id) + self.assertEqual(self.simple_event.ms_universal_event_id, event_uid) + + def test_map_an_event_without_uid_using_instance_id_2(self): + """ + Here, both Odoo event and Outlook event have no uid. + """ + + # arrange + event_id = self.simple_event.ms_organizer_event_id + self.simple_event.ms_universal_event_id = False + events = MicrosoftEvent([{ + "type": "singleInstance", + "_odoo_id": False, + "iCalUId": False, + "id": event_id, + }]) + + # act + mapped = events._load_odoo_ids_from_db(self.env) + + # assert + self.assertEqual(len(mapped._events), 1) + self.assertEqual(mapped._events[event_id]["_odoo_id"], self.simple_event.id) + self.assertEqual(self.simple_event.ms_universal_event_id, False) + + def test_map_a_recurrence_using_global_id(self): + + # arrange + rec_id = self.recurrence.ms_organizer_event_id + rec_uid = self.recurrence.ms_universal_event_id + events = MicrosoftEvent([{ + "type": "seriesMaster", + "_odoo_id": False, + "iCalUId": rec_uid, + "id": rec_id, + }]) + + # act + mapped = events._load_odoo_ids_from_db(self.env) + + # assert + self.assertEqual(len(mapped._events), 1) + self.assertEqual(mapped._events[rec_id]["_odoo_id"], self.recurrence.id) + + def test_map_a_recurrence_using_instance_id(self): + + # arrange + rec_id = self.recurrence.ms_organizer_event_id + events = MicrosoftEvent([{ + "type": "seriesMaster", + "_odoo_id": False, + "iCalUId": False, + "id": rec_id, + }]) + + # act + mapped = events._load_odoo_ids_from_db(self.env) + + # assert + self.assertEqual(len(mapped._events), 1) + self.assertEqual(mapped._events[rec_id]["_odoo_id"], self.recurrence.id) + + def test_try_to_map_mixed_of_single_events_and_recurrences(self): + + # arrange + event_id = self.simple_event.ms_organizer_event_id + event_uid = self.simple_event.ms_universal_event_id + rec_id = self.recurrence.ms_organizer_event_id + rec_uid = self.recurrence.ms_universal_event_id + + events = MicrosoftEvent([ + { + "type": "seriesMaster", + "_odoo_id": False, + "iCalUId": rec_uid, + "id": rec_id, + }, + { + "type": "singleInstance", + "_odoo_id": False, + "iCalUId": event_uid, + "id": event_id, + }, + ]) + + # act & assert + with self.assertRaises(TypeError): + events._load_odoo_ids_from_db(self.env) + + def test_match_event_only(self): + + # arrange + event_id = self.simple_event.ms_organizer_event_id + event_uid = self.simple_event.ms_universal_event_id + events = MicrosoftEvent([{ + "type": "singleInstance", + "_odoo_id": False, + "iCalUId": event_uid, + "id": event_id, + }]) + + # act + matched = events.match_with_odoo_events(self.env) + + # assert + self.assertEqual(len(matched._events), 1) + self.assertEqual(matched._events[event_id]["_odoo_id"], self.simple_event.id) + + def test_match_recurrence_only(self): + + # arrange + rec_id = self.recurrence.ms_organizer_event_id + rec_uid = self.recurrence.ms_universal_event_id + events = MicrosoftEvent([{ + "type": "seriesMaster", + "_odoo_id": False, + "iCalUId": rec_uid, + "id": rec_id, + }]) + + # act + matched = events.match_with_odoo_events(self.env) + + # assert + self.assertEqual(len(matched._events), 1) + self.assertEqual(matched._events[rec_id]["_odoo_id"], self.recurrence.id) + + def test_match_not_typed_recurrence(self): + """ + When a recurrence is deleted, Outlook returns the id of the deleted recurrence + without the type of event, so it's not directly possible to know that it's a + recurrence. + """ + # arrange + rec_id = self.recurrence.ms_organizer_event_id + rec_uid = self.recurrence.ms_universal_event_id + events = MicrosoftEvent([{ + "@removed": { + "reason": "deleted", + }, + "_odoo_id": False, + "iCalUId": rec_uid, + "id": rec_id, + }]) + + # act + matched = events.match_with_odoo_events(self.env) + + # assert + self.assertEqual(len(matched._events), 1) + self.assertEqual(matched._events[rec_id]["_odoo_id"], self.recurrence.id) + + def test_match_mix_of_events_and_recurrences(self): + + # arrange + event_id = self.simple_event.ms_organizer_event_id + event_uid = self.simple_event.ms_universal_event_id + rec_id = self.recurrence.ms_organizer_event_id + rec_uid = self.recurrence.ms_universal_event_id + + events = MicrosoftEvent([ + { + "type": "singleInstance", + "_odoo_id": False, + "iCalUId": event_uid, + "id": event_id, + }, + { + "@removed": { + "reason": "deleted", + }, + "_odoo_id": False, + "iCalUId": rec_uid, + "id": rec_id, + } + ]) + + # act + matched = events.match_with_odoo_events(self.env) + + # assert + self.assertEqual(len(matched._events), 2) + self.assertEqual(matched._events[event_id]["_odoo_id"], self.simple_event.id) + self.assertEqual(matched._events[rec_id]["_odoo_id"], self.recurrence.id) + + def test_ignore_not_found_items(self): + + # arrange + events = MicrosoftEvent([{ + "type": "singleInstance", + "_odoo_id": False, + "iCalUId": "UNKNOWN_EVENT", + "id": "UNKNOWN_EVENT", + }]) + + # act + matched = events.match_with_odoo_events(self.env) + + # assert + self.assertEqual(len(matched._events), 0) diff --git a/addons/microsoft_calendar/tests/test_microsoft_service.py b/addons/microsoft_calendar/tests/test_microsoft_service.py new file mode 100644 index 00000000000..136e979ae95 --- /dev/null +++ b/addons/microsoft_calendar/tests/test_microsoft_service.py @@ -0,0 +1,439 @@ +import json +import requests +from unittest.mock import patch, call, MagicMock + +from odoo import fields +from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService +from odoo.addons.microsoft_calendar.utils.microsoft_event import MicrosoftEvent +from odoo.addons.microsoft_account.models.microsoft_service import MicrosoftService +from odoo.tests import TransactionCase + + +DEFAULT_TIMEOUT = 20 + + +class TestMicrosoftService(TransactionCase): + + def _do_request_result(self, data): + """ _do_request returns a tuple (status, data, time) but only the data part is used """ + return (None, data, None) + + def setUp(self): + super(TestMicrosoftService, self).setUp() + + self.service = MicrosoftCalendarService(self.env["microsoft.service"]) + self.fake_token = "MY_TOKEN" + self.fake_sync_token = "MY_SYNC_TOKEN" + self.fake_next_sync_token = "MY_NEXT_SYNC_TOKEN" + self.fake_next_sync_token_url = f"https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken={self.fake_next_sync_token}" + + self.header_prefer = 'outlook.body-content-type="html", odata.maxpagesize=50' + self.header = {'Content-type': 'application/json', 'Authorization': 'Bearer %s' % self.fake_token} + self.call_with_sync_token = call( + "/v1.0/me/calendarView/delta", + {"$deltatoken": self.fake_sync_token}, + {**self.header, 'Prefer': self.header_prefer}, + method="GET", timeout=DEFAULT_TIMEOUT, + ) + self.call_without_sync_token = call( + "/v1.0/me/calendarView/delta", + { + 'startDateTime': fields.Datetime.subtract(fields.Datetime.now(), years=2).strftime("%Y-%m-%dT00:00:00Z"), + 'endDateTime': fields.Datetime.add(fields.Datetime.now(), years=2).strftime("%Y-%m-%dT00:00:00Z"), + }, + {**self.header, 'Prefer': self.header_prefer}, + method="GET", timeout=DEFAULT_TIMEOUT, + ) + + def test_get_events_delta_without_token(self): + """ + if no token is provided, an exception is raised + """ + with self.assertRaises(AttributeError): + self.service._get_events_delta() + + @patch.object(MicrosoftService, "_do_request") + def test_get_events_unexpected_exception(self, mock_do_request): + """ + When an unexpected exception is raised, just propagate it. + """ + mock_do_request.side_effect = Exception() + + with self.assertRaises(Exception): + self.service._get_events_delta(token=self.fake_token, timeout=DEFAULT_TIMEOUT) + + @patch.object(MicrosoftService, "_do_request") + def test_get_events_delta_token_error(self, mock_do_request): + """ + When the provided sync token is invalid, an exception should be raised and then + a full sync should be done. + """ + mock_do_request.side_effect = [ + requests.HTTPError(response=MagicMock(status_code=410, content="fullSyncRequired")), + self._do_request_result({"value": []}), + ] + + events, next_token = self.service._get_events_delta( + token=self.fake_token, sync_token=self.fake_sync_token, timeout=DEFAULT_TIMEOUT + ) + + self.assertEqual(next_token, None) + self.assertFalse(events) + mock_do_request.assert_has_calls([self.call_with_sync_token, self.call_without_sync_token]) + + @patch.object(MicrosoftService, "_do_request") + def test_get_events_delta_without_sync_token(self, mock_do_request): + """ + when no sync token is provided, a full sync should be done + """ + # returns empty data without any next sync token + mock_do_request.return_value = self._do_request_result({"value": []}) + + events, next_token = self.service._get_events_delta(token=self.fake_token, timeout=DEFAULT_TIMEOUT) + + self.assertEqual(next_token, None) + self.assertFalse(events) + mock_do_request.assert_has_calls([self.call_without_sync_token]) + + @patch.object(MicrosoftService, "_do_request") + def test_get_events_delta_with_sync_token(self, mock_do_request): + """ + when a sync token is provided, we should retrieve the sync token to use for the next sync. + """ + # returns empty data with a next sync token + mock_do_request.return_value = self._do_request_result({ + "value": [], + "@odata.deltaLink": self.fake_next_sync_token_url + }) + + events, next_token = self.service._get_events_delta( + token=self.fake_token, sync_token=self.fake_sync_token, timeout=DEFAULT_TIMEOUT + ) + + self.assertEqual(next_token, "MY_NEXT_SYNC_TOKEN") + self.assertFalse(events) + mock_do_request.assert_has_calls([self.call_with_sync_token]) + + @patch.object(MicrosoftService, "_do_request") + def test_get_events_one_page(self, mock_do_request): + """ + When all events are on one page, just get them. + """ + mock_do_request.return_value = self._do_request_result({ + "value": [ + {"id": 1, "type": "singleInstance", "subject": "ev1"}, + {"id": 2, "type": "singleInstance", "subject": "ev2"}, + {"id": 3, "type": "singleInstance", "subject": "ev3"}, + ], + }) + events, _ = self.service._get_events_delta(token=self.fake_token, timeout=DEFAULT_TIMEOUT) + + self.assertEqual(events, MicrosoftEvent([ + {"id": 1, "type": "singleInstance", "subject": "ev1"}, + {"id": 2, "type": "singleInstance", "subject": "ev2"}, + {"id": 3, "type": "singleInstance", "subject": "ev3"}, + ])) + mock_do_request.assert_has_calls([self.call_without_sync_token]) + + @patch.object(MicrosoftService, "_do_request") + def test_get_events_loop_over_pages(self, mock_do_request): + """ + Loop over pages to retrieve all the events. + """ + mock_do_request.side_effect = [ + self._do_request_result({ + "value": [{"id": 1, "type": "singleInstance", "subject": "ev1"}], + "@odata.nextLink": "link_1" + }), + self._do_request_result({ + "value": [{"id": 2, "type": "singleInstance", "subject": "ev2"}], + "@odata.nextLink": "link_2" + }), + self._do_request_result({ + "value": [{"id": 3, "type": "singleInstance", "subject": "ev3"}], + }), + ] + + events, _ = self.service._get_events_delta(token=self.fake_token, timeout=DEFAULT_TIMEOUT) + + self.assertEqual(events, MicrosoftEvent([ + {"id": 1, "type": "singleInstance", "subject": "ev1"}, + {"id": 2, "type": "singleInstance", "subject": "ev2"}, + {"id": 3, "type": "singleInstance", "subject": "ev3"}, + ])) + mock_do_request.assert_has_calls([ + self.call_without_sync_token, + call( + "link_1", + {}, + {**self.header, 'Prefer': self.header_prefer}, + preuri='', method="GET", timeout=DEFAULT_TIMEOUT + ), + call( + "link_2", + {}, + {**self.header, 'Prefer': self.header_prefer}, + preuri='', method="GET", timeout=DEFAULT_TIMEOUT + ), + ]) + + @patch.object(MicrosoftService, "_do_request") + def test_get_events_filter_out_occurrences(self, mock_do_request): + """ + When all events are on one page, just get them. + """ + mock_do_request.return_value = self._do_request_result({ + "value": [ + {"id": 1, "type": "singleInstance", "subject": "ev1"}, + {"id": 2, "type": "occurrence", "subject": "ev2"}, + {"id": 3, "type": "seriesMaster", "subject": "ev3"}, + ], + }) + events, _ = self.service._get_events_delta(token=self.fake_token, timeout=DEFAULT_TIMEOUT) + + self.assertEqual(events, MicrosoftEvent([ + {"id": 1, "type": "singleInstance", "subject": "ev1"}, + {"id": 3, "type": "seriesMaster", "subject": "ev3"}, + ])) + mock_do_request.assert_has_calls([self.call_without_sync_token]) + + def test_get_occurrence_details_token_error(self): + """ + if no token is provided, an exception is raised + """ + with self.assertRaises(AttributeError): + self.service._get_occurrence_details(1) + + @patch.object(MicrosoftService, "_do_request") + def test_get_occurrence_details(self, mock_do_request): + mock_do_request.return_value = self._do_request_result({ + "value": [ + {"id": 1, "type": "singleInstance", "subject": "ev1"}, + {"id": 2, "type": "occurrence", "subject": "ev2"}, + {"id": 3, "type": "seriesMaster", "subject": "ev3"}, + ], + }) + events = self.service._get_occurrence_details(123, token=self.fake_token, timeout=DEFAULT_TIMEOUT) + + self.assertEqual(events, MicrosoftEvent([ + {"id": 1, "type": "singleInstance", "subject": "ev1"}, + {"id": 2, "type": "occurrence", "subject": "ev2"}, + {"id": 3, "type": "seriesMaster", "subject": "ev3"}, + ])) + + mock_do_request.assert_called_with( + "/v1.0/me/events/123/instances", + { + 'startDateTime': fields.Datetime.subtract(fields.Datetime.now(), years=2).strftime("%Y-%m-%dT00:00:00Z"), + 'endDateTime': fields.Datetime.add(fields.Datetime.now(), years=2).strftime("%Y-%m-%dT00:00:00Z"), + }, + {**self.header, 'Prefer': self.header_prefer}, + method='GET', timeout=DEFAULT_TIMEOUT, + ) + + def test_get_events_token_error(self): + """ + if no token is provided, an exception is raised + """ + with self.assertRaises(AttributeError): + self.service.get_events() + + @patch.object(MicrosoftService, "_do_request") + def test_get_events_no_serie_master(self, mock_do_request): + """ + When there is no serie master, just retrieve the list of events. + """ + mock_do_request.return_value = self._do_request_result({ + "value": [ + {"id": 1, "type": "singleInstance", "subject": "ev1"}, + {"id": 2, "type": "singleInstance", "subject": "ev2"}, + {"id": 3, "type": "singleInstance", "subject": "ev3"}, + ], + }) + + events, _ = self.service.get_events(token=self.fake_token, timeout=DEFAULT_TIMEOUT) + + self.assertEqual(events, MicrosoftEvent([ + {"id": 1, "type": "singleInstance", "subject": "ev1"}, + {"id": 2, "type": "singleInstance", "subject": "ev2"}, + {"id": 3, "type": "singleInstance", "subject": "ev3"}, + ])) + + @patch.object(MicrosoftService, "_do_request") + def test_get_events_with_one_serie_master(self, mock_do_request): + """ + When there is a serie master, retrieve the list of events and event occurrences linked to the serie master + """ + mock_do_request.side_effect = [ + self._do_request_result({ + "value": [ + {"id": 1, "type": "singleInstance", "subject": "ev1"}, + {"id": 2, "type": "seriesMaster", "subject": "ev2"}, + ], + }), + self._do_request_result({ + "value": [ + {"id": 3, "type": "occurrence", "subject": "ev3"}, + ], + }), + ] + + events, _ = self.service.get_events(token=self.fake_token, timeout=DEFAULT_TIMEOUT) + + self.assertEqual(events, MicrosoftEvent([ + {"id": 1, "type": "singleInstance", "subject": "ev1"}, + {"id": 2, "type": "seriesMaster", "subject": "ev2"}, + {"id": 3, "type": "occurrence", "subject": "ev3"}, + ])) + + def test_insert_token_error(self): + """ + if no token is provided, an exception is raised + """ + with self.assertRaises(AttributeError): + self.service.insert({}) + + + @patch.object(MicrosoftService, "_do_request") + def test_insert(self, mock_do_request): + + mock_do_request.return_value = self._do_request_result({'id': 1, 'iCalUId': 2}) + + instance_id, event_id = self.service.insert({"subject": "ev1"}, token=self.fake_token, timeout=DEFAULT_TIMEOUT) + + self.assertEqual(instance_id, 1) + self.assertEqual(event_id, 2) + mock_do_request.assert_called_with( + "/v1.0/me/calendar/events", + json.dumps({"subject": "ev1"}), + self.header, method="POST", timeout=DEFAULT_TIMEOUT + ) + + def test_patch_token_error(self): + """ + if no token is provided, an exception is raised + """ + with self.assertRaises(AttributeError): + self.service.patch(123, {}) + + @patch.object(MicrosoftService, "_do_request") + def test_patch_returns_false_if_event_does_not_exist(self, mock_do_request): + event_id = 123 + values = {"subject": "ev2"} + mock_do_request.return_value = (404, "", None) + + res = self.service.patch(event_id, values, token=self.fake_token, timeout=DEFAULT_TIMEOUT) + + self.assertFalse(res) + mock_do_request.assert_called_with( + f"/v1.0/me/calendar/events/{event_id}", + json.dumps(values), + self.header, method="PATCH", timeout=DEFAULT_TIMEOUT + ) + + @patch.object(MicrosoftService, "_do_request") + def test_patch_an_existing_event(self, mock_do_request): + event_id = 123 + values = {"subject": "ev2"} + mock_do_request.return_value = (200, "", None) + + res = self.service.patch(event_id, values, token=self.fake_token, timeout=DEFAULT_TIMEOUT) + + self.assertTrue(res) + mock_do_request.assert_called_with( + f"/v1.0/me/calendar/events/{event_id}", + json.dumps(values), + self.header, method="PATCH", timeout=DEFAULT_TIMEOUT + ) + + def test_delete_token_error(self): + """ + if no token is provided, an exception is raised + """ + with self.assertRaises(AttributeError): + self.service.delete(123) + + @patch.object(MicrosoftService, "_do_request") + def test_delete_returns_false_if_event_does_not_exist(self, mock_do_request): + event_id = 123 + mock_do_request.return_value = (404, "", None) + + res = self.service.delete(event_id, token=self.fake_token, timeout=DEFAULT_TIMEOUT) + + self.assertFalse(res) + mock_do_request.assert_called_with( + f"/v1.0/me/calendar/events/{event_id}", + {}, headers={'Authorization': 'Bearer %s' % self.fake_token}, method="DELETE", timeout=DEFAULT_TIMEOUT + ) + + @patch.object(MicrosoftService, "_do_request") + def test_delete_an_already_cancelled_event(self, mock_do_request): + """ + When an event has already been cancelled, Outlook may return a status code equals to 403 or 410. + In this case, the delete method should return True. + """ + event_id = 123 + + for status in (403, 410): + mock_do_request.return_value = (status, "", None) + + res = self.service.delete(event_id, token=self.fake_token, timeout=DEFAULT_TIMEOUT) + + self.assertTrue(res) + mock_do_request.assert_called_with( + f"/v1.0/me/calendar/events/{event_id}", + {}, headers={'Authorization': 'Bearer %s' % self.fake_token}, method="DELETE", timeout=DEFAULT_TIMEOUT + ) + + + @patch.object(MicrosoftService, "_do_request") + def test_delete_an_existing_event(self, mock_do_request): + event_id = 123 + mock_do_request.return_value = (200, "", None) + + res = self.service.delete(event_id, token=self.fake_token, timeout=DEFAULT_TIMEOUT) + + self.assertTrue(res) + mock_do_request.assert_called_with( + f"/v1.0/me/calendar/events/{event_id}", + {}, headers={'Authorization': 'Bearer %s' % self.fake_token}, method="DELETE", timeout=DEFAULT_TIMEOUT + ) + + def test_answer_token_error(self): + """ + if no token is provided, an exception is raised + """ + with self.assertRaises(AttributeError): + self.service.answer(123, 'ok', {}) + + @patch.object(MicrosoftService, "_do_request") + def test_answer_returns_false_if_event_does_not_exist(self, mock_do_request): + event_id = 123 + answer = "accept" + values = {"a": 1, "b": 2} + mock_do_request.return_value = (404, "", None) + + res = self.service.answer(event_id, answer, values, token=self.fake_token, timeout=DEFAULT_TIMEOUT) + + self.assertFalse(res) + mock_do_request.assert_called_with( + f"/v1.0/me/calendar/events/{event_id}/{answer}", + json.dumps(values), + self.header, method="POST", timeout=DEFAULT_TIMEOUT + ) + + @patch.object(MicrosoftService, "_do_request") + def test_answer_to_an_existing_event(self, mock_do_request): + event_id = 123 + answer = "decline" + values = {"a": 1, "b": 2} + mock_do_request.return_value = (200, "", None) + + res = self.service.answer(event_id, answer, values, token=self.fake_token, timeout=DEFAULT_TIMEOUT) + + self.assertTrue(res) + mock_do_request.assert_called_with( + f"/v1.0/me/calendar/events/{event_id}/{answer}", + json.dumps(values), + self.header, method="POST", timeout=DEFAULT_TIMEOUT + ) diff --git a/addons/microsoft_calendar/tests/test_update_events.py b/addons/microsoft_calendar/tests/test_update_events.py new file mode 100644 index 00000000000..86a9bf7337b --- /dev/null +++ b/addons/microsoft_calendar/tests/test_update_events.py @@ -0,0 +1,1273 @@ +# -*- coding: utf-8 -*- +from datetime import datetime, timedelta +from dateutil.parser import parse +import logging +import pytz +from unittest.mock import patch, ANY + +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.utils.event_id_storage import combine_ids +from odoo.addons.microsoft_calendar.tests.common import TestCommon, mock_get_token, _modified_date_in_the_future, patch_api +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + +@patch.object(User, '_get_microsoft_calendar_token', mock_get_token) +class TestUpdateEvents(TestCommon): + + @patch_api + def setUp(self): + super(TestUpdateEvents, self).setUp() + self.create_events_for_tests() + + # ------------------------------------------------------------------------------- + # Update from Odoo to Outlook + # ------------------------------------------------------------------------------- + + # ------ Simple event ------ + + @patch.object(MicrosoftCalendarService, 'patch') + def test_update_odoo_simple_event_without_sync(self, mock_patch): + """ + Update an Odoo event without Outlook sync enabled + """ + + # arrange + self.organizer_user.microsoft_synchronization_stopped = True + self.simple_event.need_sync_m = False + + # act + self.simple_event.write({"name": "my new simple event"}) + self.call_post_commit_hooks() + self.simple_event.invalidate_recordset() + + # assert + mock_patch.assert_not_called() + self.assertEqual(self.simple_event.need_sync_m, False) + + @patch.object(MicrosoftCalendarService, 'patch') + def test_update_simple_event_from_odoo(self, mock_patch): + """ + Update an Odoo event with Outlook sync enabled + """ + + # arrange + mock_patch.return_value = True + + # act + res = self.simple_event.with_user(self.organizer_user).write({"name": "my new simple event"}) + self.call_post_commit_hooks() + self.simple_event.invalidate_recordset() + + # assert + self.assertTrue(res) + mock_patch.assert_called_once_with( + self.simple_event.ms_organizer_event_id, + {"subject": "my new simple event"}, + token=mock_get_token(self.organizer_user), + timeout=ANY, + ) + self.assertEqual(self.simple_event.name, "my new simple event") + + @patch.object(MicrosoftCalendarService, 'patch') + def test_update_simple_event_from_odoo_attendee_calendar(self, mock_patch): + """ + Update an Odoo event from the attendee calendar. + """ + + # arrange + mock_patch.return_value = True + + # act + res = self.simple_event.with_user(self.attendee_user).write({"name": "my new simple event"}) + self.call_post_commit_hooks() + self.simple_event.invalidate_recordset() + + # assert + self.assertTrue(res) + mock_patch.assert_called_once_with( + self.simple_event.ms_organizer_event_id, + {"subject": "my new simple event"}, + token=mock_get_token(self.organizer_user), + timeout=ANY, + ) + self.assertEqual(self.simple_event.name, "my new simple event") + + # ------ One event in a recurrence ------ + + @patch.object(MicrosoftCalendarService, 'patch') + def test_update_name_of_one_event_of_recurrence_from_odoo(self, mock_patch): + """ + Update one Odoo event name from a recurrence from the organizer calendar. + """ + + # arrange + new_name = "my specific event in recurrence" + modified_event_id = 4 + + # act + res = self.recurrent_events[modified_event_id].with_user(self.organizer_user).write({ + "recurrence_update": "self_only", + "name": new_name, + }) + self.call_post_commit_hooks() + self.recurrent_events[modified_event_id].invalidate_recordset() + + # assert + self.assertTrue(res) + mock_patch.assert_called_once_with( + self.recurrent_events[modified_event_id].ms_organizer_event_id, + {'seriesMasterId': 'REC123', 'type': 'exception', "subject": new_name}, + token=mock_get_token(self.organizer_user), + timeout=ANY, + ) + self.assertEqual(self.recurrent_events[modified_event_id].name, new_name) + self.assertEqual(self.recurrent_events[modified_event_id].follow_recurrence, True) + + for i in range(self.recurrent_events_count): + if i != modified_event_id: + self.assertNotEqual(self.recurrent_events[i].name, new_name) + + @patch.object(MicrosoftCalendarService, 'patch') + def test_update_start_of_one_event_of_recurrence_from_odoo(self, mock_patch): + """ + Update one Odoo event start date from a recurrence from the organizer calendar. + """ + + # arrange + new_date = datetime(2021, 9, 29, 10, 0, 0) + modified_event_id = 4 + + # act + res = self.recurrent_events[modified_event_id].with_user(self.organizer_user).write({ + "recurrence_update": "self_only", + "start": new_date.strftime("%Y-%m-%d %H:%M:%S"), + }) + self.call_post_commit_hooks() + self.recurrent_events[modified_event_id].invalidate_recordset() + + # assert + self.assertTrue(res) + mock_patch.assert_called_once_with( + self.recurrent_events[modified_event_id].ms_organizer_event_id, + { + 'seriesMasterId': 'REC123', + 'type': 'exception', + 'start': { + 'dateTime': pytz.utc.localize(new_date).isoformat(), + 'timeZone': 'Europe/London' + }, + 'end': { + 'dateTime': pytz.utc.localize(new_date + timedelta(hours=1)).isoformat(), + 'timeZone': 'Europe/London' + }, + 'isAllDay': False + }, + token=mock_get_token(self.organizer_user), + timeout=ANY, + ) + self.assertEqual(self.recurrent_events[modified_event_id].start, new_date) + self.assertEqual(self.recurrent_events[modified_event_id].follow_recurrence, False) + + for i in range(self.recurrent_events_count): + if i != modified_event_id: + self.assertNotEqual(self.recurrent_events[i].start, new_date) + self.assertEqual(self.recurrent_events[i].follow_recurrence, True) + + @patch.object(MicrosoftCalendarService, 'patch') + def test_update_start_of_one_event_of_recurrence_from_odoo_with_overlap(self, mock_patch): + """ + Update one Odoo event start date from a recurrence from the organizer calendar, in order to + overlap another existing event. + """ + # arrange + new_date = datetime(2021, 9, 27, 10, 0, 0) + modified_event_id = 4 + + # act + with self.assertRaises(UserError): + self.recurrent_events[modified_event_id].with_user(self.organizer_user).write({ + "recurrence_update": "self_only", + "start": new_date.strftime("%Y-%m-%d %H:%M:%S"), + }) + self.call_post_commit_hooks() + self.recurrent_events.invalidate_recordset() + + # assert + mock_patch.assert_not_called() + + @patch.object(MicrosoftCalendarService, 'patch') + def test_update_name_of_one_event_of_recurrence_from_odoo_attendee_calendar(self, mock_patch): + """ + Update one Odoo event name from a recurrence from the atendee calendar. + """ + + # arrange + new_name = "my specific event in recurrence" + modified_event_id = 4 + + # act + res = self.recurrent_events[modified_event_id].with_user(self.attendee_user).write({ + "recurrence_update": "self_only", + "name": new_name + }) + self.call_post_commit_hooks() + self.recurrent_events[modified_event_id].invalidate_recordset() + + # assert + self.assertTrue(res) + mock_patch.assert_called_once_with( + self.recurrent_events[modified_event_id].ms_organizer_event_id, + {'seriesMasterId': 'REC123', 'type': 'exception', "subject": new_name}, + token=mock_get_token(self.organizer_user), + timeout=ANY, + ) + self.assertEqual(self.recurrent_events[modified_event_id].name, new_name) + self.assertEqual(self.recurrent_events[modified_event_id].follow_recurrence, True) + + # ------ One and future events in a recurrence ------ + + @patch.object(MicrosoftCalendarService, 'delete') + @patch.object(MicrosoftCalendarService, 'insert') + @patch.object(MicrosoftCalendarService, 'patch') + def test_update_name_of_one_and_future_events_of_recurrence_from_odoo( + self, mock_patch, mock_insert, mock_delete + ): + """ + Update a Odoo event name and future events from a recurrence from the organizer calendar. + """ + + # arrange + new_name = "my specific event in recurrence" + modified_event_id = 4 + + # act + res = self.recurrent_events[modified_event_id].with_user(self.organizer_user).write({ + "recurrence_update": "future_events", + "name": new_name, + }) + self.call_post_commit_hooks() + self.recurrent_events.invalidate_recordset() + + # assert + self.assertTrue(res) + self.assertEqual(mock_patch.call_count, self.recurrent_events_count - modified_event_id) + for i in range(modified_event_id, self.recurrent_events_count): + mock_patch.assert_any_call( + self.recurrent_events[i].ms_organizer_event_id, + {'seriesMasterId': 'REC123', 'type': 'exception', "subject": new_name}, + token=mock_get_token(self.organizer_user), + timeout=ANY, + ) + for i in range(modified_event_id, self.recurrent_events_count): + self.assertEqual(self.recurrent_events[i].name, new_name) + self.assertEqual(self.recurrent_events[i].follow_recurrence, True) + + for i in range(modified_event_id): + self.assertNotEqual(self.recurrent_events[i].name, new_name) + + @patch.object(MicrosoftCalendarService, 'delete') + @patch.object(MicrosoftCalendarService, 'insert') + @patch.object(MicrosoftCalendarService, 'patch') + def test_update_start_of_one_and_future_events_of_recurrence_from_odoo( + self, mock_patch, mock_insert, mock_delete + ): + """ + Update a Odoo event start date and future events from a recurrence from the organizer calendar. + """ + + # 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 + # _check_recurrence_overlapping() for more explanation) + # + # In this case, as we also update future events, the recurrence should be splitted into 2 parts: + # - the original recurrence should end just before the first updated event + # - a second recurrence should start at the first updated event + + # arrange + new_date = datetime(2021, 9, 29, 10, 0, 0) + modified_event_id = 4 + existing_recurrences = self.env["calendar.recurrence"].search([]) + + expected_deleted_event_ids = [ + r.ms_organizer_event_id + for i, r in enumerate(self.recurrent_events) + if i in range(modified_event_id + 1, self.recurrent_events_count) + ] + + # act + res = self.recurrent_events[modified_event_id].with_user(self.organizer_user).write({ + "recurrence_update": "future_events", + "start": new_date.strftime("%Y-%m-%d %H:%M:%S"), + }) + self.call_post_commit_hooks() + self.recurrent_events.invalidate_recordset() + + # assert + new_recurrences = self.env["calendar.recurrence"].search([]) - existing_recurrences + + self.assertTrue(res) + + # a new recurrence should be created from the modified event to the end + self.assertEqual(len(new_recurrences), 1) + self.assertEqual(new_recurrences.base_event_id.start, new_date) + self.assertEqual(len(new_recurrences.calendar_event_ids), self.recurrent_events_count - modified_event_id) + + # future events of the old recurrence should have been removed + for e_id in expected_deleted_event_ids: + mock_delete.assert_any_call( + e_id, + token=mock_get_token(self.organizer_user), + timeout=ANY, + ) + + # the base event should have been modified + mock_patch.assert_called_once_with( + self.recurrent_events[modified_event_id].ms_organizer_event_id, + { + 'seriesMasterId': 'REC123', + 'type': 'exception', + 'start': { + 'dateTime': pytz.utc.localize(new_date).isoformat(), + 'timeZone': 'Europe/London' + }, + 'end': { + 'dateTime': pytz.utc.localize(new_date + timedelta(hours=1)).isoformat(), + 'timeZone': 'Europe/London' + }, + 'isAllDay': False + }, + token=mock_get_token(self.organizer_user), + timeout=ANY, + ) + + @patch.object(MicrosoftCalendarService, 'delete') + @patch.object(MicrosoftCalendarService, 'insert') + @patch.object(MicrosoftCalendarService, 'patch') + def test_update_start_of_one_and_future_events_of_recurrence_from_odoo_with_overlap( + self, mock_patch, mock_insert, mock_delete + ): + """ + Update a Odoo event start date and future events from a recurrence from the organizer calendar, + overlapping an existing event. + """ + + # arrange + new_date = datetime(2021, 9, 27, 10, 0, 0) + modified_event_id = 4 + existing_recurrences = self.env["calendar.recurrence"].search([]) + + expected_deleted_event_ids = [ + r.ms_organizer_event_id + for i, r in enumerate(self.recurrent_events) + if i in range(modified_event_id + 1, self.recurrent_events_count) + ] + + # as the test overlap the previous event of the updated event, this previous event + # should be removed too + expected_deleted_event_ids += [self.recurrent_events[modified_event_id - 1].ms_organizer_event_id] + + # act + res = self.recurrent_events[modified_event_id].with_user(self.organizer_user).write({ + "recurrence_update": "future_events", + "start": new_date.strftime("%Y-%m-%d %H:%M:%S"), + }) + self.call_post_commit_hooks() + self.recurrent_events.invalidate_recordset() + + # assert + new_recurrences = self.env["calendar.recurrence"].search([]) - existing_recurrences + + self.assertTrue(res) + + # a new recurrence should be created from the modified event to the end + self.assertEqual(len(new_recurrences), 1) + self.assertEqual(new_recurrences.base_event_id.start, new_date) + self.assertEqual(len(new_recurrences.calendar_event_ids), self.recurrent_events_count - modified_event_id + 1) + + # future events of the old recurrence should have been removed + the overlapped event + for e_id in expected_deleted_event_ids: + mock_delete.assert_any_call( + e_id, + token=mock_get_token(self.organizer_user), + timeout=ANY, + ) + + # the base event should have been modified + mock_patch.assert_called_once_with( + self.recurrent_events[modified_event_id].ms_organizer_event_id, + { + 'seriesMasterId': 'REC123', + 'type': 'exception', + 'start': { + 'dateTime': pytz.utc.localize(new_date).isoformat(), + 'timeZone': 'Europe/London' + }, + 'end': { + 'dateTime': pytz.utc.localize(new_date + timedelta(hours=1)).isoformat(), + 'timeZone': 'Europe/London' + }, + 'isAllDay': False + }, + token=mock_get_token(self.organizer_user), + timeout=ANY, + ) + + @patch.object(MicrosoftCalendarService, 'delete') + @patch.object(MicrosoftCalendarService, 'insert') + @patch.object(MicrosoftCalendarService, 'patch') + def test_update_one_and_future_events_of_recurrence_from_odoo_attendee_calendar( + self, mock_patch, mock_insert, mock_delete + ): + """ + Update a Odoo event name and future events from a recurrence from the attendee calendar. + """ + + # arrange + new_date = datetime(2021, 9, 29, 10, 0, 0) + modified_event_id = 4 + existing_recurrences = self.env["calendar.recurrence"].search([]) + + expected_deleted_event_ids = [ + r.ms_organizer_event_id + for i, r in enumerate(self.recurrent_events) + if i in range(modified_event_id + 1, self.recurrent_events_count) + ] + + # act + res = self.recurrent_events[modified_event_id].with_user(self.attendee_user).write({ + "recurrence_update": "future_events", + "start": new_date.strftime("%Y-%m-%d %H:%M:%S"), + }) + self.call_post_commit_hooks() + self.recurrent_events.invalidate_recordset() + + # assert + new_recurrences = self.env["calendar.recurrence"].search([]) - existing_recurrences + + self.assertTrue(res) + + # a new recurrence should be created from the modified event to the end + self.assertEqual(len(new_recurrences), 1) + self.assertEqual(new_recurrences.base_event_id.start, new_date) + self.assertEqual(len(new_recurrences.calendar_event_ids), self.recurrent_events_count - modified_event_id) + + # future events of the old recurrence should have been removed + for e_id in expected_deleted_event_ids: + mock_delete.assert_any_call( + e_id, + token=mock_get_token(self.organizer_user), + timeout=ANY, + ) + + # the base event should have been modified + mock_patch.assert_called_once_with( + self.recurrent_events[modified_event_id].ms_organizer_event_id, + { + 'seriesMasterId': 'REC123', + 'type': 'exception', + 'start': { + 'dateTime': pytz.utc.localize(new_date).isoformat(), + 'timeZone': 'Europe/London' + }, + 'end': { + 'dateTime': pytz.utc.localize(new_date + timedelta(hours=1)).isoformat(), + 'timeZone': 'Europe/London' + }, + 'isAllDay': False + }, + token=mock_get_token(self.organizer_user), + timeout=ANY, + ) + + # ------ All events in a recurrence ------ + + @patch.object(MicrosoftCalendarService, 'delete') + @patch.object(MicrosoftCalendarService, 'insert') + @patch.object(MicrosoftCalendarService, 'patch') + def test_update_name_of_all_events_of_recurrence_from_odoo( + self, mock_patch, mock_insert, mock_delete + ): + """ + Update all events name from a recurrence from the organizer calendar. + """ + + # arrange + new_name = "my specific event in recurrence" + + # act + res = self.recurrent_events[0].with_user(self.organizer_user).write({ + "recurrence_update": "all_events", + "name": new_name, + }) + self.call_post_commit_hooks() + self.recurrent_events.invalidate_recordset() + + # assert + self.assertTrue(res) + self.assertEqual(mock_patch.call_count, self.recurrent_events_count) + for i in range(self.recurrent_events_count): + mock_patch.assert_any_call( + self.recurrent_events[i].ms_organizer_event_id, + {'seriesMasterId': 'REC123', 'type': 'exception', "subject": new_name}, + token=mock_get_token(self.organizer_user), + timeout=ANY, + ) + self.assertEqual(self.recurrent_events[i].name, new_name) + self.assertEqual(self.recurrent_events[i].follow_recurrence, True) + + @patch.object(MicrosoftCalendarService, 'delete') + @patch.object(MicrosoftCalendarService, 'insert') + @patch.object(MicrosoftCalendarService, 'patch') + def test_update_start_of_all_events_of_recurrence_from_odoo( + self, mock_patch, mock_insert, mock_delete + ): + """ + Update all events start date from a recurrence from the organizer calendar. + """ + + # arrange + new_date = datetime(2021, 9, 25, 10, 0, 0) + existing_recurrences = self.env["calendar.recurrence"].search([]) + expected_deleted_event_ids = [ + r.ms_organizer_event_id + for i, r in enumerate(self.recurrent_events) + if i in range(1, self.recurrent_events_count) + ] + + # act + res = self.recurrent_events[0].with_user(self.organizer_user).write({ + "recurrence_update": "all_events", + "start": new_date.strftime("%Y-%m-%d %H:%M:%S"), + }) + self.call_post_commit_hooks() + self.recurrent_events.invalidate_recordset() + + # assert + new_recurrences = self.env["calendar.recurrence"].search([]) - existing_recurrences + + self.assertTrue(res) + + self.assertEqual(len(new_recurrences), 1) + self.assertEqual(new_recurrences.base_event_id.start, new_date) + self.assertEqual(len(new_recurrences.calendar_event_ids), self.recurrent_events_count) + + mock_patch.assert_called_once_with( + self.recurrent_events[0].ms_organizer_event_id, + { + 'seriesMasterId': 'REC123', + 'type': 'exception', + 'start': { + 'dateTime': pytz.utc.localize(new_date).isoformat(), + 'timeZone': 'Europe/London' + }, + 'end': { + 'dateTime': pytz.utc.localize(new_date + timedelta(hours=1)).isoformat(), + 'timeZone': 'Europe/London' + }, + 'isAllDay': False + }, + token=mock_get_token(self.organizer_user), + timeout=ANY, + ) + + # events (except the base one) of the old recurrence should have been removed + for e_id in expected_deleted_event_ids: + mock_delete.assert_any_call( + e_id, + token=mock_get_token(self.organizer_user), + timeout=ANY, + ) + + @patch.object(MicrosoftCalendarService, 'delete') + @patch.object(MicrosoftCalendarService, 'insert') + @patch.object(MicrosoftCalendarService, 'patch') + def test_update_all_events_of_recurrence_from_odoo_attendee_calendar( + self, mock_patch, mock_insert, mock_delete + ): + """ + Update all events start date from a recurrence from the attendee calendar. + """ + + # arrange + new_date = datetime(2021, 9, 25, 10, 0, 0) + existing_recurrences = self.env["calendar.recurrence"].search([]) + expected_deleted_event_ids = [ + r.ms_organizer_event_id + for i, r in enumerate(self.recurrent_events) + if i in range(1, self.recurrent_events_count) + ] + + # act + res = self.recurrent_events[0].with_user(self.attendee_user).write({ + "recurrence_update": "all_events", + "start": new_date.strftime("%Y-%m-%d %H:%M:%S"), + }) + self.call_post_commit_hooks() + self.recurrent_events.invalidate_recordset() + + # assert + new_recurrences = self.env["calendar.recurrence"].search([]) - existing_recurrences + + self.assertTrue(res) + + self.assertEqual(len(new_recurrences), 1) + self.assertEqual(new_recurrences.base_event_id.start, new_date) + self.assertEqual(len(new_recurrences.calendar_event_ids), self.recurrent_events_count) + + mock_patch.assert_called_once_with( + self.recurrent_events[0].ms_organizer_event_id, + { + 'seriesMasterId': 'REC123', + 'type': 'exception', + 'start': { + 'dateTime': pytz.utc.localize(new_date).isoformat(), + 'timeZone': 'Europe/London' + }, + 'end': { + 'dateTime': pytz.utc.localize(new_date + timedelta(hours=1)).isoformat(), + 'timeZone': 'Europe/London' + }, + 'isAllDay': False + }, + token=mock_get_token(self.organizer_user), + timeout=ANY, + ) + + # events (except the base one) of the old recurrence should have been removed + for e_id in expected_deleted_event_ids: + mock_delete.assert_any_call( + e_id, + token=mock_get_token(self.organizer_user), + timeout=ANY, + ) + + # ------------------------------------------------------------------------------- + # Update from Outlook to Odoo + # ------------------------------------------------------------------------------- + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_update_simple_event_from_outlook_organizer_calendar(self, mock_get_events): + """ + Update a simple event from Outlook organizer calendar. + """ + + # arrange + new_name = "update simple event" + mock_get_events.return_value = ( + MicrosoftEvent([dict( + self.simple_event_from_outlook_organizer, + subject=new_name, + type="exception", + lastModifiedDateTime=_modified_date_in_the_future(self.simple_event) + )]), None + ) + + # act + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + + # assert + self.assertEqual(self.simple_event.name, new_name) + self.assertEqual(self.simple_event.follow_recurrence, False) + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_update_simple_event_from_outlook_attendee_calendar(self, mock_get_events): + """ + Update a simple event from Outlook attendee calendar. + """ + + # arrange + 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 + subject=new_name, + type="exception", + lastModifiedDateTime=_modified_date_in_the_future(self.simple_event) + )]), None + ) + + # act + self.attendee_user.with_user(self.attendee_user).sudo()._sync_microsoft_calendar() + + # assert + self.assertEqual(self.simple_event.name, new_name) + self.assertEqual(self.simple_event.follow_recurrence, False) + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_update_name_of_one_event_of_recurrence_from_outlook_organizer_calendar(self, mock_get_events): + """ + Update one event name from a recurrence from Outlook organizer calendar. + """ + + # arrange + new_name = "another event name" + from_event_index = 2 + events = self.recurrent_event_from_outlook_organizer + events[from_event_index] = dict( + events[from_event_index], + subject=new_name, + type="exception", + lastModifiedDateTime=_modified_date_in_the_future(self.simple_event) + ) + ms_event_id = events[from_event_index]['id'] + mock_get_events.return_value = (MicrosoftEvent(events), None) + + # act + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + + # assert + updated_event = self.env["calendar.event"].search([('ms_organizer_event_id', '=', ms_event_id)]) + self.assertEqual(updated_event.name, new_name) + self.assertEqual(updated_event.follow_recurrence, False) + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_update_start_of_one_event_of_recurrence_from_outlook_organizer_calendar(self, mock_get_events): + """ + Update one event start date from a recurrence from Outlook organizer calendar. + """ + + # arrange + new_date = datetime(2021, 9, 25, 10, 0, 0) + from_event_index = 3 + events = self.recurrent_event_from_outlook_organizer + events[from_event_index] = dict( + events[from_event_index], + start={'dateTime': new_date.strftime("%Y-%m-%dT%H:%M:%S.0000000"), 'timeZone': 'UTC'}, + type="exception", + lastModifiedDateTime=_modified_date_in_the_future(self.recurrent_base_event) + ) + ms_event_id = events[from_event_index]['id'] + mock_get_events.return_value = (MicrosoftEvent(events), None) + + # act + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + + # assert + updated_event = self.env["calendar.event"].search([('ms_organizer_event_id', '=', ms_event_id)]) + self.assertEqual(updated_event.start, new_date) + self.assertEqual(updated_event.follow_recurrence, False) + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_update_start_of_one_event_of_recurrence_from_outlook_organizer_calendar_with_overlap( + self, mock_get_events + ): + """ + Update one event start date from a recurrence from Outlook organizer calendar, with event overlap. + """ + + # arrange + new_date = datetime(2021, 9, 23, 10, 0, 0) + from_event_index = 3 + events = self.recurrent_event_from_outlook_organizer + events[from_event_index] = dict( + events[from_event_index], + start={'dateTime': new_date.strftime("%Y-%m-%dT%H:%M:%S.0000000"), 'timeZone': 'UTC'}, + type="exception", + lastModifiedDateTime=_modified_date_in_the_future(self.recurrent_base_event) + ) + ms_event_id = events[from_event_index]['id'] + mock_get_events.return_value = (MicrosoftEvent(events), None) + + # act + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + + # assert + updated_event = self.env["calendar.event"].search([('ms_organizer_event_id', '=', ms_event_id)]) + self.assertEqual(updated_event.start, new_date) + self.assertEqual(updated_event.follow_recurrence, False) + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_update_name_of_one_event_and_future_of_recurrence_from_outlook_organizer_calendar(self, mock_get_events): + """ + Update one event name and future events from a recurrence from Outlook organizer calendar. + """ + + # arrange + new_name = "another event name" + from_event_index = 3 + events = self.recurrent_event_from_outlook_organizer + for i in range(from_event_index, len(events)): + events[i] = dict( + events[i], + subject=f"{new_name}_{i}", + type="exception", + lastModifiedDateTime=_modified_date_in_the_future(self.recurrent_base_event) + ) + ms_event_ids = { + events[i]['id']: events[i]['subject'] for i in range(from_event_index, len(events)) + } + mock_get_events.return_value = (MicrosoftEvent(events), None) + + # act + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + + # assert + updated_events = self.env["calendar.event"].search([ + ('ms_organizer_event_id', 'in', tuple(ms_event_ids.keys())) + ]) + for e in updated_events: + self.assertEqual(e.name, ms_event_ids[e.ms_organizer_event_id]) + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_update_start_of_one_event_and_future_of_recurrence_from_outlook_organizer_calendar(self, mock_get_events): + """ + Update one event start date and future events from a recurrence from Outlook organizer calendar. + + When a time field is modified on an event and the future events of the recurrence, the recurrence is splitted: + - the first one is still the same than the existing one, but stops at the first modified event, + - the second one containing newly created events but based on the old events which have been deleted. + """ + + # ----------- ARRANGE -------------- + + existing_events = self.env["calendar.event"].search([]) + existing_recurrences = self.env["calendar.recurrence"].search([]) + + # event index from where the current recurrence will be splitted/modified + from_event_index = 3 + + # number of events in both recurrences + old_recurrence_event_count = from_event_index - 1 + new_recurrence_event_count = len(self.recurrent_event_from_outlook_organizer) - from_event_index + + # dates for the new recurrences (shift event dates of 1 day in the past) + new_rec_first_event_start_date = self.start_date + timedelta( + days=self.recurrent_event_interval * old_recurrence_event_count - 1 + ) + new_rec_first_event_end_date = new_rec_first_event_start_date + timedelta(hours=1) + new_rec_end_date = new_rec_first_event_end_date + timedelta( + days=self.recurrent_event_interval * new_recurrence_event_count - 1 + ) + + # prepare first recurrence data in received Outlook events + events = self.recurrent_event_from_outlook_organizer[0:from_event_index] + events[0]['lastModifiedDateTime'] = _modified_date_in_the_future(self.recurrent_base_event) + events[0]['recurrence']['range']['endDate'] = ( + self.recurrence_end_date - timedelta(days=self.recurrent_event_interval * new_recurrence_event_count) + ).strftime("%Y-%m-%d") + + # prepare second recurrence data in received Outlook events + events += [ + dict( + self.recurrent_event_from_outlook_organizer[0], + start={ + 'dateTime': new_rec_first_event_start_date.strftime("%Y-%m-%dT%H:%M:%S.0000000"), + 'timeZone': 'UTC' + }, + end={ + 'dateTime': new_rec_first_event_end_date.strftime("%Y-%m-%dT%H:%M:%S.0000000"), + 'timeZone': 'UTC' + }, + id='REC123_new', + iCalUId='REC456_new', + recurrence=dict( + self.recurrent_event_from_outlook_organizer[0]['recurrence'], + range={ + 'startDate': new_rec_first_event_start_date.strftime("%Y-%m-%d"), + 'endDate': new_rec_end_date.strftime("%Y-%m-%d"), + 'numberOfOccurrences': 0, + 'recurrenceTimeZone': 'Romance Standard Time', + 'type': 'endDate' + } + ) + ) + ] + # ... and the recurrent events + events += [ + dict( + self.recurrent_event_from_outlook_organizer[1], + start={ + 'dateTime': ( + new_rec_first_event_start_date + timedelta(days=i * self.recurrent_event_interval) + ).strftime("%Y-%m-%dT%H:%M:%S.0000000"), + 'timeZone': 'UTC' + }, + end={ + 'dateTime': ( + new_rec_first_event_end_date + timedelta(days=i * self.recurrent_event_interval) + ).strftime("%Y-%m-%dT%H:%M:%S.0000000"), + 'timeZone': 'UTC' + }, + id=f'REC123_new_{i+1}', + iCalUId=f'REC456_new_{i+1}', + seriesMasterId='REC123_new', + ) + for i in range(0, new_recurrence_event_count) + ] + mock_get_events.return_value = (MicrosoftEvent(events), None) + + # ----------- ACT -------------- + + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + + # ----------- ASSERT -------------- + + new_events = self.env["calendar.event"].search([]) - existing_events + new_recurrences = self.env["calendar.recurrence"].search([]) - existing_recurrences + + # old recurrence + self.assertEqual(len(self.recurrence.calendar_event_ids), 2) + self.assertEqual( + self.recurrence.until, + self.recurrence_end_date.date() - timedelta(days=self.recurrent_event_interval * new_recurrence_event_count) + ) + + # new recurrence + self.assertEqual(len(new_recurrences), 1) + self.assertEqual(len(new_events), new_recurrence_event_count) + self.assertEqual(new_recurrences.ms_organizer_event_id, "REC123_new") + self.assertEqual(new_recurrences.ms_universal_event_id, "REC456_new") + + for i, e in enumerate(sorted(new_events, key=lambda e: e.id)): + self.assert_odoo_event(e, { + "start": new_rec_first_event_start_date + timedelta(days=i * self.recurrent_event_interval), + "stop": new_rec_first_event_end_date + timedelta(days=i * self.recurrent_event_interval), + "microsoft_id": combine_ids(f'REC123_new_{i+1}', f'REC456_new_{i+1}'), + "recurrence_id": new_recurrences, + "follow_recurrence": True, + }) + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_update_start_of_one_event_and_future_of_recurrence_from_outlook_organizer_calendar_with_overlap( + self, mock_get_events + ): + """ + Update one event start date and future events from a recurrence from Outlook organizer calendar, + overlapping an existing event. + """ + + # ----------- ARRANGE -------------- + + existing_events = self.env["calendar.event"].search([]) + existing_recurrences = self.env["calendar.recurrence"].search([]) + + # event index from where the current recurrence will be splitted/modified + from_event_index = 3 + + # number of events in both recurrences + old_recurrence_event_count = from_event_index - 1 + new_recurrence_event_count = len(self.recurrent_event_from_outlook_organizer) - from_event_index + + # dates for the new recurrences (shift event dates of (recurrent_event_interval + 1) days in the past + # to overlap an event. + new_rec_first_event_start_date = self.start_date + timedelta( + days=self.recurrent_event_interval * (old_recurrence_event_count - 1) - 1 + ) + new_rec_first_event_end_date = new_rec_first_event_start_date + timedelta(hours=1) + new_rec_end_date = new_rec_first_event_end_date + timedelta( + days=self.recurrent_event_interval * (new_recurrence_event_count - 1) - 1 + ) + + # prepare first recurrence data in received Outlook events + events = self.recurrent_event_from_outlook_organizer[0:from_event_index] + events[0]['lastModifiedDateTime'] = _modified_date_in_the_future(self.recurrent_base_event) + events[0]['recurrence']['range']['endDate'] = ( + self.recurrence_end_date - timedelta(days=self.recurrent_event_interval * new_recurrence_event_count) + ).strftime("%Y-%m-%d") + + # prepare second recurrence data in received Outlook events + events += [ + dict( + self.recurrent_event_from_outlook_organizer[0], + start={ + 'dateTime': new_rec_first_event_start_date.strftime("%Y-%m-%dT%H:%M:%S.0000000"), + 'timeZone': 'UTC' + }, + end={ + 'dateTime': new_rec_first_event_end_date.strftime("%Y-%m-%dT%H:%M:%S.0000000"), + 'timeZone': 'UTC' + }, + id='REC123_new', + iCalUId='REC456_new', + recurrence=dict( + self.recurrent_event_from_outlook_organizer[0]['recurrence'], + range={ + 'startDate': new_rec_first_event_start_date.strftime("%Y-%m-%d"), + 'endDate': new_rec_end_date.strftime("%Y-%m-%d"), + 'numberOfOccurrences': 0, + 'recurrenceTimeZone': 'Romance Standard Time', + 'type': 'endDate' + } + ) + ) + ] + # ... and the recurrent events + events += [ + dict( + self.recurrent_event_from_outlook_organizer[1], + start={ + 'dateTime': ( + new_rec_first_event_start_date + timedelta(days=i * self.recurrent_event_interval) + ).strftime("%Y-%m-%dT%H:%M:%S.0000000"), + 'timeZone': 'UTC' + }, + end={ + 'dateTime': ( + new_rec_first_event_end_date + timedelta(days=i * self.recurrent_event_interval) + ).strftime("%Y-%m-%dT%H:%M:%S.0000000"), + 'timeZone': 'UTC' + }, + id=f'REC123_new_{i+1}', + iCalUId=f'REC456_new_{i+1}', + seriesMasterId='REC123_new', + ) + for i in range(0, new_recurrence_event_count) + ] + mock_get_events.return_value = (MicrosoftEvent(events), None) + + # ----------- ACT -------------- + + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + + # ----------- ASSERT -------------- + + new_events = self.env["calendar.event"].search([]) - existing_events + new_recurrences = self.env["calendar.recurrence"].search([]) - existing_recurrences + + # old recurrence + self.assertEqual(len(self.recurrence.calendar_event_ids), 2) + self.assertEqual( + self.recurrence.until, + self.recurrence_end_date.date() - timedelta(days=self.recurrent_event_interval * new_recurrence_event_count) + ) + + # new recurrence + self.assertEqual(len(new_recurrences), 1) + self.assertEqual(len(new_events), new_recurrence_event_count) + self.assertEqual(new_recurrences.ms_organizer_event_id, "REC123_new") + self.assertEqual(new_recurrences.ms_universal_event_id, "REC456_new") + + for i, e in enumerate(sorted(new_events, key=lambda e: e.id)): + self.assert_odoo_event(e, { + "start": new_rec_first_event_start_date + timedelta(days=i * self.recurrent_event_interval), + "stop": new_rec_first_event_end_date + timedelta(days=i * self.recurrent_event_interval), + "microsoft_id": combine_ids(f'REC123_new_{i+1}', f'REC456_new_{i+1}'), + "recurrence_id": new_recurrences, + "follow_recurrence": True, + }) + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_update_name_of_all_events_of_recurrence_from_outlook_organizer_calendar(self, mock_get_events): + """ + Update all event names of a recurrence from Outlook organizer calendar. + """ + + # arrange + new_name = "another event name" + events = self.recurrent_event_from_outlook_organizer + for i, e in enumerate(events): + events[i] = dict( + e, + subject=f"{new_name}_{i}", + lastModifiedDateTime=_modified_date_in_the_future(self.recurrent_base_event) + ) + ms_events_to_update = { + events[i]['id']: events[i]['subject'] for i in range(1, len(events)) + } + mock_get_events.return_value = (MicrosoftEvent(events), None) + + # act + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + + # assert + updated_events = self.env["calendar.event"].search([ + ('ms_organizer_event_id', 'in', tuple(ms_events_to_update.keys())) + ]) + for e in updated_events: + self.assertEqual(e.name, ms_events_to_update[e.ms_organizer_event_id]) + self.assertEqual(e.follow_recurrence, True) + + def _prepare_outlook_events_for_all_events_start_date_update(self, nb_of_events): + """ + Utility method to avoid repeating data preparation for all tests + about updating the start date of all events of a recurrence + """ + new_start_date = datetime(2021, 9, 21, 10, 0, 0) + new_end_date = new_start_date + timedelta(hours=1) + + # prepare recurrence based on self.recurrent_event_from_outlook_organizer[0] which is the Outlook recurrence + events = [dict( + self.recurrent_event_from_outlook_organizer[0], + start={ + 'dateTime': new_start_date.strftime("%Y-%m-%dT%H:%M:%S.0000000"), + 'timeZone': 'UTC' + }, + end={ + 'dateTime': new_end_date.strftime("%Y-%m-%dT%H:%M:%S.0000000"), + 'timeZone': 'UTC', + }, + recurrence=dict( + self.recurrent_event_from_outlook_organizer[0]['recurrence'], + range={ + 'startDate': new_start_date.strftime("%Y-%m-%d"), + 'endDate': ( + new_end_date + timedelta(days=self.recurrent_event_interval * nb_of_events) + ).strftime("%Y-%m-%d"), + 'numberOfOccurrences': 0, + 'recurrenceTimeZone': 'Romance Standard Time', + 'type': 'endDate' + } + ), + lastModifiedDateTime=_modified_date_in_the_future(self.recurrent_base_event) + )] + + # prepare all events based on self.recurrent_event_from_outlook_organizer[1] which is the first Outlook event + events += nb_of_events * [self.recurrent_event_from_outlook_organizer[1]] + for i in range(1, nb_of_events + 1): + events[i] = dict( + events[i], + id=f'REC123_EVENT_{i}', + iCalUId=f'REC456_EVENT_{i}', + start={ + 'dateTime': ( + new_start_date + timedelta(days=(i - 1) * self.recurrent_event_interval) + ).strftime("%Y-%m-%dT%H:%M:%S.0000000"), + 'timeZone': 'UTC', + }, + end={ + 'dateTime': ( + new_end_date + timedelta(days=(i - 1) * self.recurrent_event_interval) + ).strftime("%Y-%m-%dT%H:%M:%S.0000000"), + 'timeZone': 'UTC', + }, + lastModifiedDateTime=_modified_date_in_the_future(self.recurrent_base_event) + ) + + return events + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_update_start_of_all_events_of_recurrence_from_outlook_organizer_calendar(self, mock_get_events): + """ + Update all event start date of a recurrence from Outlook organizer calendar. + """ + + # ----------- ARRANGE ----------- + events = self._prepare_outlook_events_for_all_events_start_date_update(self.recurrent_events_count) + ms_events_to_update = { + events[i]['id']: events[i]['start'] for i in range(1, self.recurrent_events_count + 1) + } + mock_get_events.return_value = (MicrosoftEvent(events), None) + + # ----------- ACT ----------- + + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + + # ----------- ASSERT ----------- + + updated_events = self.env["calendar.event"].search([ + ('ms_organizer_event_id', 'in', tuple(ms_events_to_update.keys())) + ]) + for e in updated_events: + self.assertEqual( + e.start.strftime("%Y-%m-%dT%H:%M:%S.0000000"), + ms_events_to_update[e.ms_organizer_event_id]["dateTime"] + ) + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_update_start_of_all_events_of_recurrence_with_more_events(self, mock_get_events): + """ + Update all event start date of a recurrence from Outlook organizer calendar, where + more events have been added (the end date is later in the year) + """ + # ----------- ARRANGE ----------- + + nb_of_events = self.recurrent_events_count + 2 + events = self._prepare_outlook_events_for_all_events_start_date_update(nb_of_events) + ms_events_to_update = { + events[i]['id']: events[i]['start'] for i in range(1, nb_of_events + 1) + } + mock_get_events.return_value = (MicrosoftEvent(events), None) + + # ----------- ACT ----------- + + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + + # ----------- ASSERT ----------- + + updated_events = self.env["calendar.event"].search([ + ('ms_organizer_event_id', 'in', tuple(ms_events_to_update.keys())) + ]) + for e in updated_events: + self.assertEqual( + e.start.strftime("%Y-%m-%dT%H:%M:%S.0000000"), + ms_events_to_update[e.ms_organizer_event_id]["dateTime"] + ) + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_update_start_of_all_events_of_recurrence_with_less_events(self, mock_get_events): + """ + Update all event start date of a recurrence from Outlook organizer calendar, where + some events have been removed (the end date is earlier in the year) + """ + # ----------- ARRANGE ----------- + + nb_of_events = self.recurrent_events_count - 2 + events = self._prepare_outlook_events_for_all_events_start_date_update(nb_of_events) + ms_events_to_update = { + events[i]['id']: events[i]['start'] for i in range(1, nb_of_events + 1) + } + mock_get_events.return_value = (MicrosoftEvent(events), None) + + # ----------- ACT ----------- + + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + + # ----------- ASSERT ----------- + + updated_events = self.env["calendar.event"].search([ + ('ms_organizer_event_id', 'in', tuple(ms_events_to_update.keys())) + ]) + for e in updated_events: + self.assertEqual( + e.start.strftime("%Y-%m-%dT%H:%M:%S.0000000"), + ms_events_to_update[e.ms_organizer_event_id]["dateTime"] + ) + + @patch.object(MicrosoftCalendarService, 'get_events') + def test_update_start_of_all_events_of_recurrence_with_exceptions(self, mock_get_events): + """ + Update all event start date of a recurrence from Outlook organizer calendar, where + an event does not follow the recurrence anymore (it became an exception) + """ + # ----------- ARRANGE ----------- + + nb_of_events = self.recurrent_events_count - 2 + events = self._prepare_outlook_events_for_all_events_start_date_update(nb_of_events) + + new_start_date = parse(events[2]['start']['dateTime']) + timedelta(days=1) + new_end_date = parse(events[2]['end']['dateTime']) + timedelta(days=1) + events[2] = dict( + events[2], + start={ + 'dateTime': new_start_date.strftime("%Y-%m-%dT%H:%M:%S.0000000"), + 'timeZone': 'UTC', + }, + end={ + 'dateTime': new_end_date.strftime("%Y-%m-%dT%H:%M:%S.0000000"), + 'timeZone': 'UTC', + }, + type="exception", + ) + ms_events_to_update = { + events[i]['id']: events[i]['start'] for i in range(1, nb_of_events + 1) + } + mock_get_events.return_value = (MicrosoftEvent(events), None) + + # ----------- ACT ----------- + + self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar() + + # ----------- ASSERT ----------- + + updated_events = self.env["calendar.event"].search([ + ('ms_organizer_event_id', 'in', tuple(ms_events_to_update.keys())) + ]) + for e in updated_events: + self.assertEqual( + e.start.strftime("%Y-%m-%dT%H:%M:%S.0000000"), + ms_events_to_update[e.ms_organizer_event_id]["dateTime"] + ) diff --git a/addons/microsoft_calendar/utils/__init__.py b/addons/microsoft_calendar/utils/__init__.py index 15a47c3f516..d94eefd57bc 100644 --- a/addons/microsoft_calendar/utils/__init__.py +++ b/addons/microsoft_calendar/utils/__init__.py @@ -2,3 +2,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import microsoft_calendar from . import microsoft_event +from . import event_id_storage diff --git a/addons/microsoft_calendar/utils/event_id_storage.py b/addons/microsoft_calendar/utils/event_id_storage.py new file mode 100644 index 00000000000..2d9d3b611d9 --- /dev/null +++ b/addons/microsoft_calendar/utils/event_id_storage.py @@ -0,0 +1,10 @@ +IDS_SEPARATOR = ':' + +def combine_ids(ms_id, ms_uid): + if not ms_id: + return False + return ms_id + IDS_SEPARATOR + (ms_uid if ms_uid else '') + +def split_ids(value): + ids = value.split(IDS_SEPARATOR) + return tuple(ids) if len(ids) > 1 and ids[1] else (ids[0], False) diff --git a/addons/microsoft_calendar/utils/microsoft_calendar.py b/addons/microsoft_calendar/utils/microsoft_calendar.py index beca7cf38b9..2e4cb30e1cf 100644 --- a/addons/microsoft_calendar/utils/microsoft_calendar.py +++ b/addons/microsoft_calendar/utils/microsoft_calendar.py @@ -9,8 +9,7 @@ from werkzeug import urls from odoo import fields from odoo.addons.microsoft_calendar.utils.microsoft_event import MicrosoftEvent -from odoo.addons.microsoft_account.models.microsoft_service import TIMEOUT - +from odoo.addons.microsoft_account.models.microsoft_service import TIMEOUT, RESOURCE_NOT_FOUND_STATUSES _logger = logging.getLogger(__name__) @@ -24,56 +23,134 @@ def requires_auth_token(func): class InvalidSyncToken(Exception): pass +# In Outlook, an event can be: +# - a 'singleInstance' event, +# - a 'serie master' which contains all the information about an event reccurrence such as +# - an 'occurrence' which is an event from a reccurrence (serie) that follows this reccurrence +# - an 'exception' which is an event from a reccurrence (serie) but some differences with the reccurrence template (could be +# the name, the day of occurrence, ...) +# +# All these kinds of events are identified by: +# - a event ID (id) which is specific to an Outlook calendar. +# - a global event ID (iCalUId) which is common to all Outlook calendars containing this event. +# +# - 'singleInstance' and 'serie master' events are retrieved through the end-point `/v1.0/me/calendarView/delta` which provides +# the last modified/deleted items since the last sync (or all of these items at the first time). +# - 'occurrence' and 'exception' events are retrieved through the end-point `/v1.0/me/events/{serieMaster.id}/instances`, +# using the corresponding serie master ID. + class MicrosoftCalendarService(): def __init__(self, microsoft_service): self.microsoft_service = microsoft_service @requires_auth_token - def get_events(self, sync_token=None, token=None, timeout=TIMEOUT): - url = "/v1.0/me/calendarView/delta" - headers = {'Content-type': 'application/json', 'Authorization': 'Bearer %s' % token, 'Prefer': 'outlook.body-content-type="html"'} - params = {} - if sync_token: - params['$deltatoken'] = sync_token - else: - params['startDateTime'] = fields.Datetime.subtract(fields.Datetime.now(), years=2).strftime("%Y-%m-%dT00:00:00Z") - params['endDateTime'] = fields.Datetime.add(fields.Datetime.now(), years=2).strftime("%Y-%m-%dT00:00:00Z") - try: - status, data, time = self.microsoft_service._do_request(url, params, headers, method='GET', timeout=timeout) - except requests.HTTPError as e: - if e.response.status_code == 410 and 'fullSyncRequired' in str(e.response.content): - raise InvalidSyncToken("Invalid sync token. Full sync required") - raise e + def _get_events_from_paginated_url(self, url, token=None, params=None, timeout=TIMEOUT): + """ + Get a list of events from a paginated URL. + Each page contains a link to the next page, so loop over all the pages to get all the events. + """ + headers = { + 'Content-type': 'application/json', + 'Authorization': 'Bearer %s' % token, + 'Prefer': 'outlook.body-content-type="html", odata.maxpagesize=50' + } + if not params: + params = { + 'startDateTime': fields.Datetime.subtract(fields.Datetime.now(), years=2).strftime("%Y-%m-%dT00:00:00Z"), + 'endDateTime': fields.Datetime.add(fields.Datetime.now(), years=2).strftime("%Y-%m-%dT00:00:00Z"), + } + # get the first page of events + _, data, _ = self.microsoft_service._do_request( + url, params, headers, method='GET', timeout=timeout + ) + + # and then, loop on other pages to get all the events events = data.get('value', []) next_page_token = data.get('@odata.nextLink') while next_page_token: - status, data, time = self.microsoft_service._do_request(next_page_token, {}, headers, preuri='', method='GET', timeout=timeout) + _, data, _ = self.microsoft_service._do_request( + next_page_token, {}, headers, preuri='', method='GET', timeout=timeout + ) next_page_token = data.get('@odata.nextLink') events += data.get('value', []) - next_sync_token_url = data.get('@odata.deltaLink') - next_sync_token = urls.url_parse(next_sync_token_url).decode_query().get('$deltatoken', False) + token_url = data.get('@odata.deltaLink') + next_sync_token = urls.url_parse(token_url).decode_query().get('$deltatoken', False) if token_url else None - default_reminders = data.get('defaultReminders') + return events, next_sync_token - return MicrosoftEvent(events), next_sync_token, default_reminders + @requires_auth_token + def _get_events_delta(self, sync_token=None, token=None, timeout=TIMEOUT): + """ + Get a set of events that have been added, deleted or updated in a time range. + See: https://docs.microsoft.com/en-us/graph/api/event-delta?view=graph-rest-1.0&tabs=http + """ + url = "/v1.0/me/calendarView/delta" + params = {'$deltatoken': sync_token} if sync_token else None + + try: + events, next_sync_token = self._get_events_from_paginated_url( + url, params=params, token=token, timeout=timeout) + except requests.HTTPError as e: + if e.response.status_code == 410 and 'fullSyncRequired' in str(e.response.content) and sync_token: + # retry with a full sync + return self._get_events_delta(token=token, timeout=timeout) + raise e + + # event occurrences (from a recurrence) are retrieved separately to get all their info, + # # and mainly the iCalUId attribute which is not provided by the 'get_delta' api end point + events = [e for e in events if e.get('type') != 'occurrence'] + + return MicrosoftEvent(events), next_sync_token + + @requires_auth_token + def _get_occurrence_details(self, serieMasterId, token=None, timeout=TIMEOUT): + """ + Get all occurrences details from a serie master. + See: https://docs.microsoft.com/en-us/graph/api/event-list-instances?view=graph-rest-1.0&tabs=http + """ + url = f"/v1.0/me/events/{serieMasterId}/instances" + + events, _ = self._get_events_from_paginated_url(url, token=token, timeout=timeout) + return MicrosoftEvent(events) + + @requires_auth_token + def get_events(self, sync_token=None, token=None, timeout=TIMEOUT): + """ + Retrieve all the events that have changed (added/updated/removed) from Microsoft Outlook. + This is done in 2 steps: + 1) get main changed events (so single events and serie masters) + 2) get occurrences linked to a serie masters (to retrieve all needed details such as iCalUId) + """ + events, next_sync_token = self._get_events_delta(sync_token=sync_token, token=token, timeout=timeout) + + # get occurences details for all serie masters + for master in filter(lambda e: e.type == 'seriesMaster', events): + events |= self._get_occurrence_details(master.id, token=token, timeout=timeout) + + return events, next_sync_token @requires_auth_token def insert(self, values, token=None, timeout=TIMEOUT): url = "/v1.0/me/calendar/events" headers = {'Content-type': 'application/json', 'Authorization': 'Bearer %s' % token} - if not values.get('id'): - values.pop('id', None) - dummy, data, dummy = self.microsoft_service._do_request(url, json.dumps(values, separators=(',', ':')), headers, method='POST', timeout=timeout) - return data['id'] + _dummy, data, _dummy = self.microsoft_service._do_request(url, json.dumps(values), headers, method='POST', timeout=timeout) + + return data['id'], data['iCalUId'] @requires_auth_token def patch(self, event_id, values, token=None, timeout=TIMEOUT): url = "/v1.0/me/calendar/events/%s" % event_id headers = {'Content-type': 'application/json', 'Authorization': 'Bearer %s' % token} - self.microsoft_service._do_request(url, json.dumps(values, separators=(',', ':')), headers, method='PATCH', timeout=timeout) + try: + status, _dummy, _dummy = self.microsoft_service._do_request(url, json.dumps(values), headers, method='PATCH', timeout=timeout) + except requests.HTTPError: + _logger.info("Microsoft event %s has not been updated", event_id) + return False + + return status not in RESOURCE_NOT_FOUND_STATUSES @requires_auth_token def delete(self, event_id, token=None, timeout=TIMEOUT): @@ -81,18 +158,24 @@ class MicrosoftCalendarService(): headers = {'Authorization': 'Bearer %s' % token} params = {} try: - self.microsoft_service._do_request(url, params, headers=headers, method='DELETE', timeout=timeout) + status, _dummy, _dummy = self.microsoft_service._do_request(url, params, headers=headers, method='DELETE', timeout=timeout) except requests.HTTPError as e: # For some unknown reason Microsoft can also return a 403 response when the event is already cancelled. - if e.response.status_code not in (410, 403): + status = e.response.status_code + if status in (410, 403): + _logger.info("Microsoft event %s was already deleted", event_id) + else: raise e - _logger.info("Microsoft event %s was already deleted" % event_id) + + return status not in RESOURCE_NOT_FOUND_STATUSES @requires_auth_token def answer(self, event_id, answer, values, token=None, timeout=TIMEOUT): url = "/v1.0/me/calendar/events/%s/%s" % (event_id, answer) headers = {'Content-type': 'application/json', 'Authorization': 'Bearer %s' % token} - self.microsoft_service._do_request(url, json.dumps(values), headers, method='POST', timeout=timeout) + status, _dummy, _dummy = self.microsoft_service._do_request(url, json.dumps(values), headers, method='POST', timeout=timeout) + + return status not in RESOURCE_NOT_FOUND_STATUSES ##################################### diff --git a/addons/microsoft_calendar/utils/microsoft_event.py b/addons/microsoft_calendar/utils/microsoft_event.py index 0d50c3ae8bb..a040e8d23c1 100644 --- a/addons/microsoft_calendar/utils/microsoft_event.py +++ b/addons/microsoft_calendar/utils/microsoft_event.py @@ -3,16 +3,17 @@ from odoo.api import model from typing import Iterator, Mapping from collections import abc +from odoo.addons.microsoft_calendar.utils.event_id_storage import combine_ids class MicrosoftEvent(abc.Set): - """This helper class holds the values of a Microsoft event. + """ + This helper class holds the values of a Microsoft event. Inspired by Odoo recordset, one instance can be a single Microsoft event or a (immutable) set of Microsoft events. All usual set operations are supported (union, intersection, etc). :param iterable: iterable of MicrosoftCalendar instances or iterable of dictionnaries - """ def __init__(self, iterable=()): @@ -51,86 +52,108 @@ class MicrosoftEvent(abc.Set): @property def ids(self): + """ + Use 'id' to return an event identifier which is specific to a calendar + """ return tuple(e.id for e in self) def microsoft_ids(self): return tuple(e.id for e in self) + @property + def uids(self): + """ + Use 'iCalUid' to return an identifier which is unique accross all calendars + """ + return tuple(e.iCalUId for e in self) + def odoo_id(self, env): - self.odoo_ids(env) # load ids return self._odoo_id def _meta_odoo_id(self, microsoft_guid): """Returns the Odoo id stored in the Microsoft Event metadata. This id might not actually exists in the database. """ - if self.singleValueExtendedProperties: - o_id = [prop['value'] for prop in self.singleValueExtendedProperties if prop['id'] == 'String {%s} Name odoo_id' % microsoft_guid][0] - return int(o_id) + return None - def odoo_ids(self, env): - ids = tuple(e._odoo_id for e in self if e._odoo_id) - if len(ids) == len(self): - return ids - found = self._load_odoo_ids_from_db(env) - unsure = self - found - if unsure: - unsure._load_odoo_ids_from_metadata(env) + @property + def odoo_ids(self): + """ + Get the list of Odoo event ids already mapped with Outlook events (self) + """ + return tuple(e._odoo_id for e in self if e._odoo_id) - return tuple(e._odoo_id for e in self) + def _load_odoo_ids_from_db(self, env, force_model=None): + """ + Map Microsoft events to existing Odoo events: + 1) extract unmapped events only, + 2) match Odoo events and Outlook events which have both a ICalUId set, + 3) match remaining events, + Returns the list of mapped events + """ + mapped_events = [e.id for e in self if e._odoo_id] - def _load_odoo_ids_from_metadata(self, env): - model_env = self._get_model(env) - microsoft_guid = env['ir.config_parameter'].sudo().get_param('microsoft_calendar.microsoft_guid', False) - unsure_odoo_ids = tuple(e._meta_odoo_id(microsoft_guid) for e in self) - odoo_events = model_env.browse(_id for _id in unsure_odoo_ids if _id) + # avoid mapping events if they are already all mapped + if len(self) == len(mapped_events): + return self - # Extended properties are copied when splitting a recurrence Microsoft side. - # Hence, we may have two Microsoft recurrences linked to the same Odoo id. - # Therefore, we only consider Odoo records without microsoft id when trying - # to match events. - o_ids = odoo_events.exists().filtered(lambda e: not e.microsoft_id).ids - for e in self: - odoo_id = e._meta_odoo_id(microsoft_guid) - if odoo_id in o_ids: - e._events[e.id]['_odoo_id'] = odoo_id + unmapped_events = self.filter(lambda e: e.id not in mapped_events) - def _load_odoo_ids_from_db(self, env): - model_env = self._get_model(env) - odoo_events = model_env.with_context(active_test=False)._from_microsoft_ids(self.ids).with_env(env) - mapping = {e.microsoft_id: e.id for e in odoo_events} - existing_microsoft_ids = odoo_events.mapped('microsoft_id') - for e in self: - odoo_id = mapping.get(e.id) + model_env = force_model if force_model is not None else self._get_model(env) + odoo_events = model_env.with_context(active_test=False).search([ + '|', + ('ms_universal_event_id', "in", unmapped_events.uids), + ('ms_organizer_event_id', "in", unmapped_events.ids) + ]).with_env(env) + + # 1. try to match unmapped events with Odoo events using their iCalUId + unmapped_events_with_uids = unmapped_events.filter(lambda e: e.iCalUId) + odoo_events_with_uids = odoo_events.filtered(lambda e: e.ms_universal_event_id) + mapping = {e.ms_universal_event_id: e.id for e in odoo_events_with_uids} + + for ms_event in unmapped_events_with_uids: + odoo_id = mapping.get(ms_event.iCalUId) if odoo_id: - e._events[e.id]['_odoo_id'] = odoo_id - return self.filter(lambda e: e.id in existing_microsoft_ids) + ms_event._events[ms_event.id]['_odoo_id'] = odoo_id + mapped_events.append(ms_event.id) - def owner(self, env): - # Owner/organizer could be desynchronised between Microsoft and Odoo. - # Let userA, userB be two new users (never synced to Microsoft before). - # UserA creates an event in Odoo (they are the owner) but userB syncs first. - # There is no way to insert the event into userA's calendar since we don't have - # any authentication access. The event is therefore inserted into userB's calendar - # (they are the organizer in Microsoft). The "real" owner (in Odoo) is stored as an - # extended property. There is currently no support to "transfert" ownership when - # userA syncs their calendar the first time. - if self.singleValueExtendedProperties: - microsoft_guid = env['ir.config_parameter'].sudo().get_param('microsoft_calendar.microsoft_guid', False) - real_owner_id = [prop['value'] for prop in self.singleValueExtendedProperties if prop['id'] == 'String {%s} Name owner_odoo_id' % microsoft_guid][0] - real_owner = real_owner_id and env['res.users'].browse(int(real_owner_id)) - else: - real_owner_id = False + # 2. try to match unmapped events with Odoo events using their id + unmapped_events = self.filter(lambda e: e.id not in mapped_events) + mapping = {e.ms_organizer_event_id: e for e in odoo_events} - if real_owner_id and real_owner.exists(): - return real_owner - elif self.isOrganizer: - return env.user - elif self.organizer and self.organizer.get('emailAddress') and self.organizer.get('emailAddress').get('address'): - # In Microsoft: 1 email = 1 user; but in Odoo several users might have the same email - return env['res.users'].search([('email', '=', self.organizer.get('emailAddress').get('address'))], limit=1) - else: - return env['res.users'] + for ms_event in unmapped_events: + odoo_event = mapping.get(ms_event.id) + if odoo_event: + ms_event._events[ms_event.id]['_odoo_id'] = odoo_event.id + mapped_events.append(ms_event.id) + + # don't forget to also set the global event ID on the Odoo event to ease + # and improve reliability of future mappings + odoo_event.write({ + 'microsoft_id': combine_ids(ms_event.id, ms_event.iCalUId), + 'need_sync_m': False, + }) + + return self.filter(lambda e: e.id in mapped_events) + + def owner_id(self, env): + """ + Indicates who is the owner of an event (i.e the organizer of the event). + + There are several possible cases: + 1) the current Odoo user is the organizer of the event according to Outlook event, so return his id. + 2) the current Odoo user is NOT the organizer and: + 2.1) we are able to find a Odoo user using the Outlook event organizer email address and we use his id, + 2.2) we are NOT able to find a Odoo user matching the organizer email address and we return False, meaning + that no Odoo user will be able to modify this event. All modifications will be done from Outlook. + """ + if self.isOrganizer: + return env.user.id + if self.organizer.get('emailAddress') and self.organizer.get('emailAddress').get('address'): + # Warning: In Microsoft: 1 email = 1 user; but in Odoo several users might have the same email + user = env['res.users'].search([('email', '=', self.organizer.get('emailAddress').get('address'))], limit=1) + return user.id if user else False + return False def filter(self, func) -> 'MicrosoftEvent': return MicrosoftEvent(e for e in self if func(e)) @@ -200,21 +223,32 @@ class MicrosoftEvent(abc.Set): return result def is_cancelled(self): - return bool(self.isCancelled or (self.__getattr__('@removed') and self.__getattr__('@removed').get('reason') == 'deleted')) + return bool(self.isCancelled) or self.is_removed() + + def is_removed(self): + return self.__getattr__('@removed') and self.__getattr__('@removed').get('reason') == 'deleted' def is_recurrence_outlier(self): - return bool(self.originalStartTime) + return self.type == "exception" def cancelled(self): return self.filter(lambda e: e.is_cancelled()) - def exists(self, env) -> 'MicrosoftEvent': - recurrences = self.filter(MicrosoftEvent.is_recurrence) - events = self - recurrences - recurrences.odoo_ids(env) - events.odoo_ids(env) + def match_with_odoo_events(self, env) -> 'MicrosoftEvent': + """ + Match Outlook events (self) with existing Odoo events, and return the list of matched events + """ + # first, try to match recurrences + # Note that when a recurrence is removed, there is no field in Outlook data to identify + # the item as a recurrence, so select all deleted items by default. + recurrence_candidates = self.filter(lambda x: x.is_recurrence() or x.is_removed()) + mapped_recurrences = recurrence_candidates._load_odoo_ids_from_db(env, force_model=env["calendar.recurrence"]) - return self.filter(lambda e: e._odoo_id) + # then, try to match events + events_candidates = (self - mapped_recurrences).filter(lambda x: not x.is_recurrence()) + mapped_events = events_candidates._load_odoo_ids_from_db(env) + + return mapped_recurrences | mapped_events def _get_model(self, env): if all(e.is_recurrence() for e in self): diff --git a/addons/microsoft_calendar/wizard/reset_account.py b/addons/microsoft_calendar/wizard/reset_account.py index 1a21a29250d..a3e3d639917 100644 --- a/addons/microsoft_calendar/wizard/reset_account.py +++ b/addons/microsoft_calendar/wizard/reset_account.py @@ -4,7 +4,6 @@ from odoo import fields, models from odoo.addons.microsoft_calendar.models.microsoft_sync import microsoft_calendar_token -from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService class ResetMicrosoftAccount(models.TransientModel): @@ -25,15 +24,15 @@ class ResetMicrosoftAccount(models.TransientModel): ], string="Next Synchronization", required=True, default='new') def reset_account(self): - microsoft = MicrosoftCalendarService(self.env['microsoft.service']) + microsoft = self.env["calendar.event"]._get_microsoft_service() events = self.env['calendar.event'].search([ ('user_id', '=', self.user_id.id), - ('microsoft_id', '!=', False)]) + ('ms_universal_event_id', '!=', False)]) if self.delete_policy in ('delete_microsoft', 'delete_both'): with microsoft_calendar_token(self.user_id) as token: for event in events: - microsoft.delete(event.microsoft_id, token=token) + microsoft.delete(event.ms_universal_event_id, token=token) if self.delete_policy in ('delete_odoo', 'delete_both'): events.microsoft_id = False