[FIX] calendar, microsoft_calendar: backport bugfixes from master

As several customers complained about lot of bugs in microsoft_calendar in 14.0,
It has been decided to backport bug fixes of the model layer from master to 14.0,
without the need of an upgrade script (no new field, ...).

In master, we use 2 ids (organizer event id + universal id) instead of only one,
to handle Odoo <-> Outlook sync correctly when several attendees sync their Outlook
calendar with their Odoo calendar. For that, we have added a new field.
To report this bug fix in 14.0, the existing field which stores the organizer event id,
is now a string storing both ids separated by a ':' as follow: 'organizer_event_id:universal_id'.

2 new compute fields have been added to be able to use these 2 ids more easily.

(all commits from the original PR have been squashed to ease forward-port)

closes odoo/odoo#95736

X-original-commit: 5e83318a7240585371efd31e407829793f3e732f
Signed-off-by: Arnaud Joset <arj@odoo.com>
Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
Rémy Baranx (bar)
2022-07-12 13:21:27 +02:00
parent d6ad39abba
commit 2fbfbf0dce
24 changed files with 3902 additions and 374 deletions
+25 -3
View File
@@ -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(),
@@ -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.
@@ -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
@@ -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):
@@ -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:
+2 -10
View File
@@ -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": ''
+92 -51
View File
@@ -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:
@@ -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"]),
)
@@ -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
+277 -143
View File
@@ -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()
+13 -12
View File
@@ -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))
+6 -2
View File
@@ -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
+503
View File
@@ -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('<p>%s</p>' % 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('<p>my simple event</p>'),
"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()
@@ -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")
@@ -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])
@@ -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.
"""
@@ -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)
@@ -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
)
File diff suppressed because it is too large Load Diff
@@ -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
@@ -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)
@@ -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
#####################################
@@ -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):
@@ -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