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