Initial commit: Security packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:51 +02:00
commit bb469e4763
1399 changed files with 278378 additions and 0 deletions

View file

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
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

View file

@ -0,0 +1,549 @@
import pytz
from datetime import datetime, timedelta
from markupsafe import Markup
from unittest.mock import patch, MagicMock
from contextlib import contextmanager
from freezegun import freeze_time
from odoo import fields
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,
})
# Add token validity with one hour of time window for properly checking the sync status.
for user in [self.organizer_user, self.attendee_user]:
user.microsoft_calendar_token_validity = fields.Datetime.now() + timedelta(hours=1)
# -----------------------------------------------------------------------------------------
# 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()
@contextmanager
def mock_datetime_and_now(self, mock_dt):
"""
Used when synchronization date (using env.cr.now()) is important
in addition to standard datetime mocks. Used mainly to detect sync
issues.
"""
with freeze_time(mock_dt), \
patch.object(self.env.cr, 'now', lambda: mock_dt):
yield
def sync_odoo_recurrences_with_outlook_feature(self):
"""
Returns the status of the recurrence synchronization feature with Outlook.
True if it is active and False otherwise. This function guides previous tests to abort before they are checked.
"""
return False
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 group of events
self.several_events = self.env["calendar.event"].search([("name", "like", "event%")])
if not self.several_events:
self.several_events = self.env["calendar.event"].with_user(self.organizer_user).create([
dict(
self.simple_event_values,
name=f"event{i}",
microsoft_id=combine_ids(f"e{i}", f"u{i}"),
)
for i in range(1, 4)
])
# 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
# Currently, it is forbidden to create recurrences in Odoo. A trick for deactivating the checking
# is needed below in this test setup: deactivating the synchronization during recurrences creation.
sync_previous_state = self.env.user.microsoft_synchronization_stopped
self.env.user.microsoft_synchronization_stopped = False
if not already_created:
self.recurrent_base_event = self.env["calendar.event"].with_context(dont_notify=True).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.with_context(dont_notify=True).write({
"microsoft_id": combine_ids("REC123", "REC456"),
})
for i, e in enumerate(self.recurrence.calendar_event_ids.sorted(key=lambda r: r.start)):
e.with_context(dont_notify=True).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)
# Rollback the synchronization status after setup.
self.env.user.microsoft_synchronization_stopped = sync_previous_state
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()

View file

@ -0,0 +1,211 @@
# -*- coding: utf-8 -*-
from unittest.mock import patch, ANY
from datetime import datetime, timedelta
from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService
from odoo.addons.microsoft_calendar.utils.microsoft_event import MicrosoftEvent
from odoo.addons.microsoft_calendar.models.res_users import User
from odoo.addons.microsoft_calendar.utils.event_id_storage import combine_ids
from odoo.addons.microsoft_calendar.tests.common import TestCommon, mock_get_token, _modified_date_in_the_future, patch_api
from odoo.tests import users
import json
from freezegun import freeze_time
@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"),
)
)
(self.organizer_user | self.attendee_user).microsoft_calendar_token_validity = datetime.now() + timedelta(hours=1)
@patch.object(MicrosoftCalendarService, '_get_single_event')
@patch.object(MicrosoftCalendarService, 'answer')
def test_attendee_accepts_event_from_odoo_calendar(self, mock_answer, mock_get_single_event):
attendee = self.env["calendar.attendee"].search([
('event_id', '=', self.simple_event.id),
('partner_id', '=', self.attendee_user.partner_id.id)
])
attendee_ms_organizer_event_id = 100
mock_get_single_event.return_value = (True, {'value': [{'id': attendee_ms_organizer_event_id}]})
attendee.with_user(self.attendee_user).do_accept()
self.call_post_commit_hooks()
self.simple_event.invalidate_recordset()
mock_answer.assert_called_once_with(
attendee_ms_organizer_event_id,
'accept',
{"comment": "", "sendResponse": True},
token=mock_get_token(self.attendee_user),
timeout=20,
)
@patch.object(MicrosoftCalendarService, '_get_single_event')
@patch.object(MicrosoftCalendarService, 'answer')
def test_attendee_declines_event_from_odoo_calendar(self, mock_answer, mock_get_single_event):
attendee = self.env["calendar.attendee"].search([
('event_id', '=', self.simple_event.id),
('partner_id', '=', self.attendee_user.partner_id.id)
])
attendee_ms_organizer_event_id = 100
mock_get_single_event.return_value = (True, {'value': [{'id': attendee_ms_organizer_event_id}]})
attendee.with_user(self.attendee_user).do_decline()
self.call_post_commit_hooks()
self.simple_event.invalidate_recordset()
mock_answer.assert_called_once_with(
attendee_ms_organizer_event_id,
'decline',
{"comment": "", "sendResponse": True},
token=mock_get_token(self.attendee_user),
timeout=20,
)
@freeze_time('2021-09-22')
@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")
@freeze_time('2021-09-22')
@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".
"""
@freeze_time('2021-09-22')
@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")
@users('admin')
def test_sync_data_with_stopped_sync(self):
self.authenticate(self.env.user.login, self.env.user.login)
self.env['ir.config_parameter'].sudo().set_param(
'microsoft_calendar_client_id',
'test_microsoft_calendar_client_id'
)
self.env.user.sudo().microsoft_calendar_rtoken = 'test_microsoft_calendar_rtoken'
self.env.user.stop_microsoft_synchronization()
payload = {
'params': {
'model': 'calendar.event'
}
}
# Sending the request to the sync_data
response = self.url_open(
'/microsoft_calendar/sync_data',
data=json.dumps(payload),
headers={'Content-Type': 'application/json'}
).json()
# the status must be sync_stopped
self.assertEqual(response['result']['status'], 'sync_stopped')
@patch.object(MicrosoftCalendarService, '_get_single_event')
@patch.object(MicrosoftCalendarService, 'answer')
def test_answer_event_with_external_organizer(self, mock_answer, mock_get_single_event):
""" Answer an event invitation from an outsider user and check if it was patched on Outlook side. """
# Simulate an event that came from an external provider: the organizer isn't registered in Odoo.
self.simple_event.write({'user_id': False, 'partner_id': False})
self.simple_event.attendee_ids.state = 'needsAction'
# Accept the event using the admin account and ensure that answer request is called.
attendee_ms_organizer_event_id = 100
mock_get_single_event.return_value = (True, {'value': [{'id': attendee_ms_organizer_event_id}]})
self.simple_event.attendee_ids[0].with_user(self.organizer_user)._microsoft_sync_event('accept')
mock_answer.assert_called_once_with(
attendee_ms_organizer_event_id,
'accept', {'comment': '', 'sendResponse': True},
token=mock_get_token(self.organizer_user),
timeout=20
)
# Decline the event using the admin account and ensure that answer request is called.
self.simple_event.attendee_ids[0].with_user(self.organizer_user)._microsoft_sync_event('decline')
mock_answer.assert_called_with(
attendee_ms_organizer_event_id,
'decline', {'comment': '', 'sendResponse': True},
token=mock_get_token(self.organizer_user),
timeout=20
)

View file

@ -0,0 +1,485 @@
from datetime import datetime, timedelta
from unittest.mock import patch
from odoo import Command, 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_calendar.models.res_users import User
from odoo.addons.microsoft_calendar.tests.common import TestCommon, mock_get_token
from odoo.exceptions import ValidationError, UserError
@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, 'get_events')
def test_create_simple_event_from_outlook_attendee_calendar_where_email_addresses_are_capitalized(self, mock_get_events):
"""
An event has been created in Outlook and synced in the Odoo attendee calendar.
The email addresses of the attendee and the organizer are in different case than in Odoo.
"""
# arrange
outlook_event = dict(self.simple_event_from_outlook_attendee, organizer={
'emailAddress': {'address': "Mike@organizer.com", 'name': "Mike Organizer"},
}, attendees=[{'type': 'required', 'status': {'response': 'none', 'time': '0001-01-01T00:00:00Z'},
'emailAddress': {'name': 'John Attendee', 'address': 'John@attendee.com'}}])
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, self.expected_odoo_event_from_outlook)
self.assertEqual(new_records.user_id, self.organizer_user)
@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.
"""
if not self.sync_odoo_recurrences_with_outlook_feature():
return
# 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.
"""
if not self.sync_odoo_recurrences_with_outlook_feature():
return
# >>> 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() )
"""
if not self.sync_odoo_recurrences_with_outlook_feature():
return
# >>> 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])
@patch.object(MicrosoftCalendarService, 'insert')
def test_forbid_recurrences_creation_synced_outlook_calendar(self, mock_insert):
"""
Forbids new recurrences creation in Odoo due to Outlook spam limitation of updating recurrent events.
"""
# Set custom calendar token validity to simulate real scenario.
self.env.user.microsoft_calendar_token_validity = datetime.now() + timedelta(minutes=5)
# Assert that synchronization with Outlook is active.
self.assertFalse(self.env.user.microsoft_synchronization_stopped)
with self.assertRaises(UserError):
self.env["calendar.event"].create(
self.recurrent_event_values
)
# Assert that no insert call was made.
mock_insert.assert_not_called()
@patch.object(MicrosoftCalendarService, 'get_events')
@patch.object(MicrosoftCalendarService, 'insert')
def test_create_event_for_another_user(self, mock_insert, mock_get_events):
"""
Allow the creation of event for another user only if the proposed user have its Odoo Calendar synced.
User A (self.organizer_user) is creating an event with user B as organizer (self.attendee_user).
"""
# Ensure that the calendar synchronization of user A is active. Deactivate user B synchronization for throwing an error.
self.assertTrue(self.env['calendar.event'].with_user(self.organizer_user)._check_microsoft_sync_status())
self.attendee_user.microsoft_synchronization_stopped = True
# Try creating an event with the organizer as the user B (self.attendee_user).
# A ValidationError must be thrown because user B's calendar is not synced.
self.simple_event_values['user_id'] = self.attendee_user.id
self.simple_event_values['partner_ids'] = [Command.set([self.organizer_user.partner_id.id])]
with self.assertRaises(ValidationError):
self.env['calendar.event'].with_user(self.organizer_user).create(self.simple_event_values)
# Activate the calendar synchronization of user B (self.attendee_user).
self.attendee_user.microsoft_synchronization_stopped = False
self.assertTrue(self.env['calendar.event'].with_user(self.attendee_user)._check_microsoft_sync_status())
# Try creating an event with organizer as the user B but not inserting B as an attendee. A ValidationError must be thrown.
with self.assertRaises(ValidationError):
self.env['calendar.event'].with_user(self.organizer_user).create(self.simple_event_values)
# Set mock return values for the event creation.
event_id = "123"
event_iCalUId = "456"
mock_insert.return_value = (event_id, event_iCalUId)
mock_get_events.return_value = ([], None)
# Create event matching the creation conditions: user B is synced and now listed as an attendee. Set mock return values.
self.simple_event_values['partner_ids'] = [Command.set([self.organizer_user.partner_id.id, self.attendee_user.partner_id.id])]
event = self.env['calendar.event'].with_user(self.organizer_user).create(self.simple_event_values)
self.call_post_commit_hooks()
event.invalidate_recordset()
# Ensure that event was inserted and user B (self.attendee_user) is the organizer and is also listed as attendee.
mock_insert.assert_called_once()
self.assertEqual(event.user_id, self.attendee_user, "Event organizer must be user B (self.attendee_user) after event creation by user A (self.organizer_user).")
self.assertTrue(self.attendee_user.partner_id.id in event.partner_ids.ids, "User B (self.attendee_user) should be listed as attendee after event creation.")
# Try creating an event with portal user (with no access rights) as organizer from Microsoft.
# In Odoo, this event will be created (behind the screens) by a synced Odoo user as attendee (self.attendee_user).
portal_group = self.env.ref('base.group_portal')
portal_user = self.env['res.users'].create({
'login': 'portal@user',
'email': 'portal@user',
'name': 'PortalUser',
'groups_id': [Command.set([portal_group.id])],
})
# Mock event from Microsoft and sync event with Odoo through self.attendee_user (synced user).
self.simple_event_from_outlook_organizer.update({
'id': 'portalUserEventID',
'iCalUId': 'portalUserEventICalUId',
'organizer': {'emailAddress': {'address': portal_user.login, 'name': portal_user.name}},
})
mock_get_events.return_value = (MicrosoftEvent([self.simple_event_from_outlook_organizer]), None)
self.assertTrue(self.env['calendar.event'].with_user(self.attendee_user)._check_microsoft_sync_status())
self.attendee_user.with_user(self.attendee_user).sudo()._sync_microsoft_calendar()
# Ensure that event was successfully created in Odoo (no ACL error was triggered blocking creation).
portal_user_events = self.env['calendar.event'].search([('user_id', '=', portal_user.id)])
self.assertEqual(len(portal_user_events), 1)
@patch.object(MicrosoftCalendarService, 'get_events')
def test_create_simple_event_from_outlook_without_organizer(self, mock_get_events):
"""
Allow creation of an event without organizer in Outlook and sync it in Odoo.
"""
# arrange
outlook_event = self.simple_event_from_outlook_attendee
outlook_event = dict(self.simple_event_from_outlook_attendee, organizer=None)
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, 'get_events')
@patch.object(MicrosoftCalendarService, 'insert')
def test_new_db_skip_odoo2microsoft_sync_previously_created_events(self, mock_insert, mock_get_events):
"""
Skip the synchronization of previously created events if the database never synchronized with
Outlook Calendar before. This is necessary for avoiding spamming lots of invitations in the first
synchronization of users. A single ICP parameter 'first_synchronization_date' is shared in the DB
to save down the first synchronization date of any of all users.
"""
# During preparation: ensure that no user ever synchronized with Outlook Calendar
# and create a local event waiting to be synchronized (need_sync_m: True).
with self.mock_datetime_and_now('2024-01-01 10:00:00'):
any_calendar_synchronized = self.env['res.users'].sudo().search_count(
domain=[('microsoft_calendar_sync_token', '!=', False)],
limit=1
)
self.assertFalse(any_calendar_synchronized)
self.organizer_user.microsoft_synchronization_stopped = True
event = self.env['calendar.event'].with_user(self.organizer_user).create({
'name': "Odoo Local Event",
'start': datetime(2024, 1, 1, 11, 0),
'stop': datetime(2024, 1, 1, 13, 0),
'user_id': self.organizer_user.id,
'partner_ids': [(4, self.organizer_user.partner_id.id)],
'need_sync_m': True
})
# For simulating a real world scenario, save the first synchronization date
# one day later after creating the event that won't be synchronized.
self.organizer_user._set_ICP_first_synchronization_date(
fields.Datetime.from_string('2024-01-02 10:00:00')
)
# Ten seconds later the ICP parameter saving, make the synchronization between Odoo
# and Outlook and ensure that insert was not called, i.e. the event got skipped.
with self.mock_datetime_and_now('2024-01-02 10:00:10'):
# Mock the return of 0 events from Outlook to Odoo, then activate the user's sync.
mock_get_events.return_value = ([], None)
self.organizer_user.microsoft_synchronization_stopped = False
self.organizer_user.microsoft_calendar_token_validity = datetime.now() + timedelta(minutes=60)
self.assertTrue(self.env['calendar.event'].with_user(self.organizer_user)._check_microsoft_sync_status())
# Synchronize the user's calendar and call post commit hooks for analyzing the API calls.
self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar()
self.call_post_commit_hooks()
event.invalidate_recordset()
mock_insert.assert_not_called()
@patch.object(MicrosoftCalendarService, 'get_events')
@patch.object(MicrosoftCalendarService, 'insert')
def test_old_db_odoo2microsoft_sync_previously_created_events(self, mock_insert, mock_get_events):
"""
Ensure that existing databases that are already synchronized with Outlook Calendar at some point
won't skip any events creation in Outlook side during the first synchronization of the users.
This test is important to make sure the behavior won't be changed for existing production envs.
"""
# During preparation: ensure that the organizer is not synchronized with Outlook and
# create a local event waiting to be synchronized (need_sync_m: True) without API calls.
with self.mock_datetime_and_now('2024-01-01 10:00:00'):
self.organizer_user.microsoft_synchronization_stopped = True
event = self.env['calendar.event'].with_user(self.organizer_user).create({
'name': "Odoo Local Event",
'start': datetime(2024, 1, 1, 11, 0),
'stop': datetime(2024, 1, 1, 13, 0),
'user_id': self.organizer_user.id,
'partner_ids': [(4, self.organizer_user.partner_id.id)],
'need_sync_m': True
})
# Assign a next sync token to ANY user to simulate a previous sync in the DB.
self.attendee_user.microsoft_calendar_sync_token = 'OngoingToken'
# Mock the return of 0 events from Outlook to Odoo, then activate the user's sync.
mock_get_events.return_value = ([], None)
mock_insert.return_value = ('LocalEventSyncID', 'event_iCalUId')
self.organizer_user.microsoft_synchronization_stopped = False
self.organizer_user.microsoft_calendar_token_validity = datetime.now() + timedelta(minutes=60)
self.assertTrue(self.env['calendar.event'].with_user(self.organizer_user)._check_microsoft_sync_status())
# Synchronize the user's calendar and call post commit hooks for analyzing the API calls.
# Our event must be synchronized normally in the first synchronization of the user.
self.organizer_user.with_user(self.organizer_user).sudo()._sync_microsoft_calendar()
self.call_post_commit_hooks()
event.invalidate_recordset()
mock_insert.assert_called_once()
self.assertEqual(mock_insert.call_args[0][0]['subject'], event.name)

View file

@ -0,0 +1,338 @@
# -*- coding: utf-8 -*-
from unittest.mock import patch, ANY, call
from datetime import timedelta
from odoo import fields
from odoo.exceptions import UserError
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, 'delete')
def test_archive_several_events_at_once(self, mock_delete):
"""
Archive several events at once should not produce any exception.
"""
# arrange
several_simple_events = self.several_events.filtered(lambda ev: not ev.recurrency and ev.microsoft_id)
# act
several_simple_events.action_archive()
self.call_post_commit_hooks()
several_simple_events.invalidate_recordset()
# assert
self.assertFalse(all(e.active for e in several_simple_events))
mock_delete.assert_has_calls([
call(e.ms_organizer_event_id, token=ANY, timeout=ANY)
for e in several_simple_events
])
@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):
if not self.sync_odoo_recurrences_with_outlook_feature():
return
# 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):
if not self.sync_odoo_recurrences_with_outlook_feature():
return
# 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):
if not self.sync_odoo_recurrences_with_outlook_feature():
return
# 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_context(dont_notify=True).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.
"""
@patch.object(MicrosoftCalendarService, 'delete')
def test_delete_single_event_from_recurrence_from_odoo_calendar(self, mock_delete):
"""
Deletes the base_event of a recurrence and checks if the event was archived and the recurrence was updated.
"""
if not self.sync_odoo_recurrences_with_outlook_feature():
return
# arrange
idx = 0
event_id = self.recurrent_events[idx].ms_organizer_event_id
# act
self.recurrent_events[idx].with_user(self.organizer_user).action_mass_archive('self_only')
self.call_post_commit_hooks()
# assert that event is not active anymore and that a new base_event was select for the recurrence
self.assertFalse(self.recurrent_events[idx].active)
self.assertNotEqual(self.id, self.recurrent_events[idx].recurrence_id.base_event_id.id)
self.assertTrue(self.id not in [rec.id for rec in self.recurrent_events[idx].recurrence_id.calendar_event_ids])
mock_delete.assert_called_once_with(
event_id,
token=mock_get_token(self.organizer_user),
timeout=ANY
)
@patch.object(MicrosoftCalendarService, 'delete')
def test_delete_recurrence_previously_synced(self, mock_delete):
# Arrange: select recurrent event and update token validity to simulate an active sync environment.
idx = 0
self.organizer_user.microsoft_calendar_token_validity = fields.Datetime.now() + timedelta(hours=1)
# Act: try to delete a recurrent event that was already synced.
with self.assertRaises(UserError):
self.recurrent_events[idx].with_user(self.organizer_user).action_mass_archive('all_events')
self.call_post_commit_hooks()
# Ensure that event remains undeleted after deletion attempt and delete method wasn't called.
self.assertTrue(self.recurrent_events[idx].with_user(self.organizer_user)._check_microsoft_sync_status())
self.assertTrue(self.recurrent_events[idx].active)
mock_delete.assert_not_called()
def test_forbid_recurrence_unlinking_list_view(self):
# Forbid recurrence unlinking from list view with sync on.
self.assertTrue(self.env['calendar.event'].with_user(self.organizer_user)._check_microsoft_sync_status())
with self.assertRaises(UserError):
self.recurrent_events.unlink()
# Allow recurrence unlinking when update comes from Microsoft (dont_notify=True).
self.recurrent_events[2:].with_context(dont_notify=True).unlink()
self.assertTrue(all(not event.exists() for event in self.recurrent_events[2:]), "Recurrent event must be deleted after unlink from Microsoft.")
# Allow unlinking recurrence when sync is off for the current user.
self.organizer_user.microsoft_synchronization_stopped = True
self.assertFalse(self.env['calendar.event'].with_user(self.organizer_user)._check_microsoft_sync_status())
self.recurrent_events[1].with_user(self.organizer_user).unlink()
self.assertFalse(self.recurrent_events[1].exists(), "Recurrent event must be deleted after unlink with sync off.")

View file

@ -0,0 +1,408 @@
from datetime import datetime
from dateutil.relativedelta import relativedelta
from pytz import UTC
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)
def test_search_set_ms_universal_event_id(self):
not_synced_events = self.env['calendar.event'].search([('ms_universal_event_id', '=', False)])
synced_events = self.env['calendar.event'].search([('ms_universal_event_id', '!=', False)])
self.assertIn(self.simple_event, synced_events)
self.assertNotIn(self.simple_event, not_synced_events)
self.simple_event.ms_universal_event_id = ''
not_synced_events = self.env['calendar.event'].search([('ms_universal_event_id', '=', False)])
synced_events = self.env['calendar.event'].search([('ms_universal_event_id', '!=', False)])
self.assertNotIn(self.simple_event, synced_events)
self.assertIn(self.simple_event, not_synced_events)
def test_microsoft_event_readonly(self):
event = MicrosoftEvent()
with self.assertRaises(TypeError):
event._events['foo'] = 'bar'
with self.assertRaises(AttributeError):
event._events.update({'foo': 'bar'})
with self.assertRaises(TypeError):
dict.update(event._events, {'foo': 'bar'})
def test_performance_check(self):
# Test what happens when microsoft returns a lot of data
# This test does not aim to check what we do with the data but it ensure that we are able to process it.
# Other tests take care of how we update odoo records with the api result.
start_date = datetime(2023, 9, 25, 17, 25)
record_count = 10000
single_event_data = [{
'@odata.type': '#microsoft.graph.event',
'@odata.etag': f'W/"AAAAAA{x}"',
'type': 'singleInstance',
'createdDateTime': (start_date + relativedelta(minutes=x)).isoformat(),
'lastModifiedDateTime': (datetime.now().astimezone(UTC) + relativedelta(days=3)).isoformat(),
'changeKey': f'ZS2uEVAVyU6BMZ3m6cH{x}mtgAADI/Dig==',
'categories': [],
'originalStartTimeZone': 'Romance Standard Time',
'originalEndTimeZone': 'Romance Standard Time',
'id': f'AA{x}',
'subject': f"Subject of {x}",
'bodyPreview': f"Body of {x}",
'start': {'dateTime': (start_date + relativedelta(minutes=x)).isoformat(), 'timeZone': 'UTC'},
'end': {'dateTime': (start_date + relativedelta(minutes=x)).isoformat(), 'timeZone': 'UTC'},
'isOrganizer': True,
'organizer': {'emailAddress': {'name': f'outlook_{x}@outlook.com', 'address': f'outlook_{x}@outlook.com'}},
} for x in range(record_count)]
events = MicrosoftEvent(single_event_data)
mapped = events._load_odoo_ids_from_db(self.env)
self.assertFalse(mapped, "No odoo record should correspond to the microsoft values")
recurring_event_data = [{
'@odata.type': '#microsoft.graph.event',
'@odata.etag': f'W/"{x}IaZKQ=="',
'createdDateTime': (start_date + relativedelta(minutes=(2*x))).isoformat(),
'lastModifiedDateTime': (datetime.now().astimezone(UTC) + relativedelta(days=3)).isoformat(),
'changeKey': 'ZS2uEVAVyU6BMZ3m6cHmtgAADIaZKQ==',
'categories': [],
'originalStartTimeZone': 'Romance Standard Time',
'originalEndTimeZone': 'Romance Standard Time',
'iCalUId': f'XX{x}',
'id': f'AAA{x}',
'reminderMinutesBeforeStart': 15,
'isReminderOn': True,
'hasAttachments': False,
'subject': f'My recurrent event {x}',
'bodyPreview': '', 'importance':
'normal', 'sensitivity': 'normal',
'isAllDay': False, 'isCancelled': False,
'isOrganizer': True, 'IsRoomRequested': False,
'AutoRoomBookingStatus': 'None',
'responseRequested': True,
'seriesMasterId': None,
'showAs': 'busy',
'type': 'seriesMaster',
'webLink': f'https://outlook.live.com/owa/?itemid={x}&exvsurl=1&path=/calendar/item',
'onlineMeetingUrl': None,
'isOnlineMeeting': False,
'onlineMeetingProvider': 'unknown', 'AllowNewTimeProposals': True,
'IsDraft': False,
'responseStatus': {'response': 'organizer', 'time': '0001-01-01T00:00:00Z'},
'body': {'contentType': 'html', 'content': ''},
'start': {'dateTime': '2020-05-03T14:30:00.0000000', 'timeZone': 'UTC'},
'end': {'dateTime': '2020-05-03T16:00:00.0000000', 'timeZone': 'UTC'},
'location': {'displayName': '',
'locationType': 'default',
'uniqueIdType': 'unknown',
'address': {},
'coordinates': {}},
'locations': [],
'recurrence': {'pattern':
{'type': 'daily',
'interval': 1,
'month': 0,
'dayOfMonth': 0,
'firstDayOfWeek': 'sunday',
'index': 'first'},
'range': {'type': 'endDate',
'startDate': '2020-05-03',
'endDate': '2020-05-05',
'recurrenceTimeZone': 'Romance Standard Time',
'numberOfOccurrences': 0}
},
'attendees': [],
'organizer': {'emailAddress': {'name': f'outlook_{x}@outlook.com',
'address': f'outlook_{x}@outlook.com'}}
} for x in range(record_count)]
recurrences = MicrosoftEvent(recurring_event_data)
mapped = recurrences._load_odoo_ids_from_db(self.env)
self.assertFalse(mapped, "No odoo record should correspond to the microsoft values")

View file

@ -0,0 +1,463 @@
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(), days=365).strftime("%Y-%m-%dT00:00:00Z"),
'endDateTime': fields.Datetime.add(fields.Datetime.now(), days=365 * 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(MicrosoftCalendarService, "_check_full_sync_required")
@patch.object(MicrosoftService, "_do_request")
def test_get_events_delta_token_error(self, mock_do_request, mock_check_full_sync_required):
"""
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": []}),
]
mock_check_full_sync_required.return_value = (True)
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(), days=365).strftime("%Y-%m-%dT00:00:00Z"),
'endDateTime': fields.Datetime.add(fields.Datetime.now(), days=365 * 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
)
@patch.object(MicrosoftCalendarService, "_check_full_sync_required")
@patch.object(MicrosoftService, "_do_request")
def test_get_events_delta_with_outdated_sync_token(self, mock_do_request, mock_check_full_sync_required):
""" When an outdated sync token is provided, we must fetch all events again for updating the old token. """
# Throw a 'HTTPError' when the token is outdated, thus triggering the fetching of all events.
# Simulate a scenario which the full sync is required, such as when getting the 'SyncStateNotFound' error code.
mock_do_request.side_effect = [
requests.HTTPError(response=MagicMock(status_code=410, error={'code': "SyncStateNotFound"})),
self._do_request_result({"value": []}),
]
mock_check_full_sync_required.return_value = (True)
# Call the regular 'delta' get events with an outdated token for triggering the all events fetching.
self.env.user.microsoft_calendar_sync_token = self.fake_sync_token
self.service._get_events_delta(token=self.fake_token, sync_token=self.fake_sync_token, timeout=DEFAULT_TIMEOUT)
# Two calls must have been made: one call with the outdated sync token and another one with no sync token.
mock_do_request.assert_has_calls([
self.call_with_sync_token,
self.call_without_sync_token
])

View file

@ -0,0 +1,374 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService, MicrosoftEvent
from odoo.exceptions import ValidationError
import pytz
from datetime import datetime, date
from odoo.tests.common import TransactionCase
from dateutil.relativedelta import relativedelta
class TestSyncMicrosoft2Odoo(TransactionCase):
@property
def now(self):
return pytz.utc.localize(datetime.now()).isoformat()
def setUp(self):
super().setUp()
self.recurrence_id = 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA'
values = [
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"ZS2uEVAVyU6BMZ3m6cHmtgAACyq4xQ=="', 'createdDateTime': '2020-05-06T07:03:49.1444085Z', 'lastModifiedDateTime': '2020-05-06T07:00:00Z', 'changeKey': 'ZS2uEVAVyU6BMZ3m6cHmtgAACyq4xQ==', 'categories': [], 'originalStartTimeZone': 'Romance Standard Time', 'originalEndTimeZone': 'Romance Standard Time', 'iCalUId': '040000008200E00074C5B7101A82E00800000000874F057E7423D601000D848B1B7F8C', 'reminderMinutesBeforeStart': 15, 'isReminderOn': True, 'hasAttachments': False, 'subject': 'My recurrent event', 'bodyPreview': '', 'importance': 'normal', 'sensitivity': 'normal', 'isAllDay': False, 'isCancelled': False, 'isOrganizer': True, 'IsRoomRequested': False, 'AutoRoomBookingStatus': 'None', 'responseRequested': True, 'seriesMasterId': None, 'showAs': 'busy', 'type': 'seriesMaster', 'webLink': 'https://outlook.live.com/owa/?itemid=AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFAAALLLTEAAAA&exvsurl=1&path=/calendar/item', 'onlineMeetingUrl': None, 'isOnlineMeeting': False, 'onlineMeetingProvider': 'unknown', 'AllowNewTimeProposals': True, 'IsDraft': False, 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'responseStatus': {'response': 'organizer', 'time': '0001-01-01T00:00:00Z'}, 'body': {'contentType': 'html', 'content': ''}, 'start': {'dateTime': '2020-05-03T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-03T16:00:00.0000000', 'timeZone': 'UTC'}, 'location': {'displayName': '', 'locationType': 'default', 'uniqueIdType': 'unknown', 'address': {}, 'coordinates': {}}, 'locations': [], 'recurrence': {'pattern': {'type': 'daily', 'interval': 1, 'month': 0, 'dayOfMonth': 0, 'firstDayOfWeek': 'sunday', 'index': 'first'}, 'range': {'type': 'endDate', 'startDate': '2020-05-03', 'endDate': '2020-05-05', 'recurrenceTimeZone': 'Romance Standard Time', 'numberOfOccurrences': 0}}, 'attendees': [], 'organizer': {'emailAddress': {'name': 'outlook_7BA43549E5FD4413@outlook.com', 'address': 'outlook_7BA43549E5FD4413@outlook.com'}}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAABlLa4RUBXJToExnebpwea2AAALKrjF"', 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'type': 'occurrence', 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX7vTsS0AARgAAAkBy7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAAEA==', 'start': {'dateTime': '2020-05-03T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-03T16:00:00.0000000', 'timeZone': 'UTC'}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAABlLa4RUBXJToExnebpwea2AAALKrjF"', 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'type': 'occurrence', 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX774WtQAAAEYAAAJAcu19N72jSr9Rp1mE2xWABwBlLa4RUBXJToExnebpwea2AAACAQ0AAABlLa4RUBXJToExnebpwea2AAAACyy0xAAAABA=', 'start': {'dateTime': '2020-05-04T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-04T16:00:00.0000000', 'timeZone': 'UTC'}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAABlLa4RUBXJToExnebpwea2AAALKrjF"', 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'type': 'occurrence', 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX8IdBHsAARgAAAkBy7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAAEA==', 'start': {'dateTime': '2020-05-05T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-05T16:00:00.0000000', 'timeZone': 'UTC'}}
]
self.single_event = [
{
'@odata.type': '#microsoft.graph.event',
'@odata.etag': 'W/"AAAAA"',
'type': 'singleInstance',
'id': "CCCCC",
'start': {
'dateTime': '2020-05-05T14:30:00.0000000',
'timeZone': 'UTC'
},
'end': {
'dateTime': '2020-05-05T16:00:00.0000000',
'timeZone': 'UTC'
},
'location': {
'displayName': "a meeting room at Odoo"
}
}
]
self.env['calendar.event']._sync_microsoft2odoo(MicrosoftEvent(values))
self.datetime_future = pytz.utc.localize(datetime.now() + relativedelta(days=1)).isoformat()
def sync(self, events):
self.env['calendar.event']._sync_microsoft2odoo(events)
def test_new_microsoft_recurrence(self):
recurrence = self.env['calendar.recurrence'].search([('microsoft_id', '=', self.recurrence_id)])
events = recurrence.calendar_event_ids
self.assertTrue(recurrence, "It should have created an recurrence")
self.assertEqual(len(events), 3, "It should have created 3 events")
self.assertEqual(recurrence.base_event_id, events[0])
self.assertEqual(events.mapped('name'), ['My recurrent event', 'My recurrent event', 'My recurrent event'])
self.assertFalse(events[0].allday)
self.assertEqual(events[0].start, datetime(2020, 5, 3, 14, 30))
self.assertEqual(events[0].stop, datetime(2020, 5, 3, 16, 00))
self.assertEqual(events[1].start, datetime(2020, 5, 4, 14, 30))
self.assertEqual(events[1].stop, datetime(2020, 5, 4, 16, 00))
self.assertEqual(events[2].start, datetime(2020, 5, 5, 14, 30))
self.assertEqual(events[2].stop, datetime(2020, 5, 5, 16, 00))
def test_microsoft_recurrence_delete_one_event(self):
values = [
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"ZS2uEVAVyU6BMZ3m6cHmtgAADIaZKQ=="', 'createdDateTime': '2020-05-06T07:03:49.1444085Z', 'lastModifiedDateTime': self.datetime_future, 'changeKey': 'ZS2uEVAVyU6BMZ3m6cHmtgAADIaZKQ==', 'categories': [], 'originalStartTimeZone': 'Romance Standard Time', 'originalEndTimeZone': 'Romance Standard Time', 'iCalUId': '040000008200E00074C5B7101A82E00800000000874F057E7423D601000000000000000010000000C6918C4B44D2D84586351FEC8B1B7F8C', 'reminderMinutesBeforeStart': 15, 'isReminderOn': True, 'hasAttachments': False, 'subject': 'My recurrent event', 'bodyPreview': '', 'importance': 'normal', 'sensitivity': 'normal', 'isAllDay': False, 'isCancelled': False, 'isOrganizer': True, 'IsRoomRequested': False, 'AutoRoomBookingStatus': 'None', 'responseRequested': True, 'seriesMasterId': None, 'showAs': 'busy', 'type': 'seriesMaster', 'webLink': 'https://outlook.live.com/owa/?itemid=AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA&exvsurl=1&path=/calendar/item', 'onlineMeetingUrl': None, 'isOnlineMeeting': False, 'onlineMeetingProvider': 'unknown', 'AllowNewTimeProposals': True, 'IsDraft': False, 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'responseStatus': {'response': 'organizer', 'time': '0001-01-01T00:00:00Z'}, 'body': {'contentType': 'html', 'content': ''}, 'start': {'dateTime': '2020-05-03T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-03T16:00:00.0000000', 'timeZone': 'UTC'}, 'location': {'displayName': '', 'locationType': 'default', 'uniqueIdType': 'unknown', 'address': {}, 'coordinates': {}}, 'locations': [], 'recurrence': {'pattern': {'type': 'daily', 'interval': 1, 'month': 0, 'dayOfMonth': 0, 'firstDayOfWeek': 'sunday', 'index': 'first'}, 'range': {'type': 'endDate', 'startDate': '2020-05-03', 'endDate': '2020-05-05', 'recurrenceTimeZone': 'Romance Standard Time', 'numberOfOccurrences': 0}}, 'attendees': [], 'organizer': {'emailAddress': {'name': 'outlook_7BA43549E5FD4413@outlook.com', 'address': 'outlook_7BA43549E5FD4413@outlook.com'}}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAABlLa4RUBXJToExnebpwea2AAAMhpkp"', 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'type': 'occurrence', 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX7vTsS0AARgAAAkBy7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAAEA==', 'start': {'dateTime': '2020-05-03T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-03T16:00:00.0000000', 'timeZone': 'UTC'}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAABlLa4RUBXJToExnebpwea2AAAMhpkp"', 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'type': 'occurrence', 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX8IdBHsAARgAAAkBy7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAAEA==', 'start': {'dateTime': '2020-05-05T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-05T16:00:00.0000000', 'timeZone': 'UTC'}}
]
self.env['calendar.event']._sync_microsoft2odoo(MicrosoftEvent(values))
recurrence = self.env['calendar.recurrence'].search([('microsoft_id', '=', self.recurrence_id)])
events = self.env['calendar.event'].search([('recurrence_id', '=', recurrence.id)], order='start asc')
self.assertTrue(recurrence, "It should keep the recurrence")
self.assertEqual(len(events), 2, "It should keep 2 events")
self.assertEqual(recurrence.base_event_id, events[0])
self.assertEqual(events.mapped('name'), ['My recurrent event', 'My recurrent event'])
def test_microsoft_recurrence_change_name_one_event(self):
values = [
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"ZS2uEVAVyU6BMZ3m6cHmtgAADIaZKQ=="', 'createdDateTime': '2020-05-06T07:03:49.1444085Z', 'lastModifiedDateTime': self.datetime_future, 'changeKey': 'ZS2uEVAVyU6BMZ3m6cHmtgAADIaZKQ==', 'categories': [], 'originalStartTimeZone': 'Romance Standard Time', 'originalEndTimeZone': 'Romance Standard Time', 'iCalUId': '040000008200E00074C5B7101A82E00800000000874F057E7423D601000000000000000010000000C6918C4B44D2D84586351FEC8B1B7F8C', 'reminderMinutesBeforeStart': 15, 'isReminderOn': True, 'hasAttachments': False, 'subject': 'My recurrent event', 'bodyPreview': '', 'importance': 'normal', 'sensitivity': 'normal', 'isAllDay': False, 'isCancelled': False, 'isOrganizer': True, 'IsRoomRequested': False, 'AutoRoomBookingStatus': 'None', 'responseRequested': True, 'seriesMasterId': None, 'showAs': 'busy', 'type': 'seriesMaster', 'webLink': 'https://outlook.live.com/owa/?itemid=AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA&exvsurl=1&path=/calendar/item', 'onlineMeetingUrl': None, 'isOnlineMeeting': False, 'onlineMeetingProvider': 'unknown', 'AllowNewTimeProposals': True, 'IsDraft': False, 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'responseStatus': {'response': 'organizer', 'time': '0001-01-01T00:00:00Z'}, 'body': {'contentType': 'html', 'content': ''}, 'start': {'dateTime': '2020-05-03T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-03T16:00:00.0000000', 'timeZone': 'UTC'}, 'location': {'displayName': '', 'locationType': 'default', 'uniqueIdType': 'unknown', 'address': {}, 'coordinates': {}}, 'locations': [], 'recurrence': {'pattern': {'type': 'daily', 'interval': 1, 'month': 0, 'dayOfMonth': 0, 'firstDayOfWeek': 'sunday', 'index': 'first'}, 'range': {'type': 'endDate', 'startDate': '2020-05-03', 'endDate': '2020-05-05', 'recurrenceTimeZone': 'Romance Standard Time', 'numberOfOccurrences': 0}}, 'attendees': [], 'organizer': {'emailAddress': {'name': 'outlook_7BA43549E5FD4413@outlook.com', 'address': 'outlook_7BA43549E5FD4413@outlook.com'}}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAABlLa4RUBXJToExnebpwea2AAAMhpkp"', 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'type': 'occurrence', 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX7vTsS0AARgAAAkBy7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAAEA==', 'start': {'dateTime': '2020-05-03T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-03T16:00:00.0000000', 'timeZone': 'UTC'}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"ZS2uEVAVyU6BMZ3m6cHmtgAADIaZKQ=="', 'createdDateTime': '2020-05-06T08:01:32.4884797Z', 'lastModifiedDateTime': self.datetime_future, 'changeKey': 'ZS2uEVAVyU6BMZ3m6cHmtgAADIaZKQ==', 'categories': [], 'originalStartTimeZone': 'Romance Standard Time', 'originalEndTimeZone': 'Romance Standard Time', 'iCalUId': '040000008200E00074C5B7101A82E00807E40504874F057E7423D601000000000000000010000000C6918C4B44D2D84586351FEC8B1B7F8C', 'reminderMinutesBeforeStart': 15, 'isReminderOn': True, 'hasAttachments': False, 'subject': 'My recurrent event 2', 'bodyPreview': '', 'importance': 'normal', 'sensitivity': 'normal', 'originalStart': '2020-05-04T14:30:00Z', 'isAllDay': False, 'isCancelled': False, 'isOrganizer': True, 'IsRoomRequested': False, 'AutoRoomBookingStatus': 'None', 'responseRequested': True, 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'showAs': 'busy', 'type': 'exception', 'webLink': 'https://outlook.live.com/owa/?itemid=AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX774WtQAAAEYAAAJAcu19N72jSr9Rp1mE2xWABwBlLa4RUBXJToExnebpwea2AAACAQ0AAABlLa4RUBXJToExnebpwea2AAAACyy0xAAAABA%3D&exvsurl=1&path=/calendar/item', 'onlineMeetingUrl': None, 'isOnlineMeeting': False, 'onlineMeetingProvider': 'unknown', 'AllowNewTimeProposals': True, 'IsDraft': False, 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX774WtQAAAEYAAAJAcu19N72jSr9Rp1mE2xWABwBlLa4RUBXJToExnebpwea2AAACAQ0AAABlLa4RUBXJToExnebpwea2AAAACyy0xAAAABA=', 'responseStatus': {'response': 'organizer', 'time': '0001-01-01T00:00:00Z'}, 'body': {'contentType': 'html', 'content': ''}, 'start': {'dateTime': '2020-05-04T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-04T16:00:00.0000000', 'timeZone': 'UTC'}, 'location': {'displayName': '', 'locationType': 'default', 'uniqueIdType': 'unknown', 'address': {}, 'coordinates': {}}, 'locations': [], 'attendees': [], 'organizer': {'emailAddress': {'name': 'outlook_7BA43549E5FD4413@outlook.com', 'address': 'outlook_7BA43549E5FD4413@outlook.com'}}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAABlLa4RUBXJToExnebpwea2AAAMhpkp"', 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'type': 'occurrence', 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX8IdBHsAARgAAAkBy7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAAEA==', 'start': {'dateTime': '2020-05-05T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-05T16:00:00.0000000', 'timeZone': 'UTC'}}
]
self.env['calendar.event']._sync_microsoft2odoo(MicrosoftEvent(values))
recurrence = self.env['calendar.recurrence'].search([('microsoft_id', '=', self.recurrence_id)])
events = self.env['calendar.event'].search([('recurrence_id', '=', recurrence.id)], order='start asc')
self.assertTrue(recurrence, "It should have created an recurrence")
self.assertEqual(len(events), 3, "It should have created 3 events")
self.assertEqual(recurrence.base_event_id, events[0])
self.assertEqual(events.mapped('name'), ['My recurrent event', 'My recurrent event 2', 'My recurrent event'])
def test_microsoft_recurrence_change_name_all_event(self):
values = [
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"ZS2uEVAVyU6BMZ3m6cHmtgAADIaZKQ=="', 'createdDateTime': '2020-05-06T07:03:49.1444085Z', 'lastModifiedDateTime': self.datetime_future, 'changeKey': 'ZS2uEVAVyU6BMZ3m6cHmtgAADIaZKQ==', 'categories': [], 'originalStartTimeZone': 'Romance Standard Time', 'originalEndTimeZone': 'Romance Standard Time', 'iCalUId': '040000008200E00074C5B7101A82E00800000000874F057E7423D601000000000000000010000000C6918C4B44D2D84586351FEC8B1B7F8C', 'reminderMinutesBeforeStart': 15, 'isReminderOn': True, 'hasAttachments': False, 'subject': 'My recurrent event 2', 'bodyPreview': '', 'importance': 'normal', 'sensitivity': 'normal', 'isAllDay': False, 'isCancelled': False, 'isOrganizer': True, 'IsRoomRequested': False, 'AutoRoomBookingStatus': 'None', 'responseRequested': True, 'seriesMasterId': None, 'showAs': 'busy', 'type': 'seriesMaster', 'webLink': 'https://outlook.live.com/owa/?itemid=AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA&exvsurl=1&path=/calendar/item', 'onlineMeetingUrl': None, 'isOnlineMeeting': False, 'onlineMeetingProvider': 'unknown', 'AllowNewTimeProposals': True, 'IsDraft': False, 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'responseStatus': {'response': 'organizer', 'time': '0001-01-01T00:00:00Z'}, 'body': {'contentType': 'html', 'content': ''}, 'start': {'dateTime': '2020-05-03T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-03T16:00:00.0000000', 'timeZone': 'UTC'}, 'location': {'displayName': '', 'locationType': 'default', 'uniqueIdType': 'unknown', 'address': {}, 'coordinates': {}}, 'locations': [], 'recurrence': {'pattern': {'type': 'daily', 'interval': 1, 'month': 0, 'dayOfMonth': 0, 'firstDayOfWeek': 'sunday', 'index': 'first'}, 'range': {'type': 'endDate', 'startDate': '2020-05-03', 'endDate': '2020-05-05', 'recurrenceTimeZone': 'Romance Standard Time', 'numberOfOccurrences': 0}}, 'attendees': [], 'organizer': {'emailAddress': {'name': 'outlook_7BA43549E5FD4413@outlook.com', 'address': 'outlook_7BA43549E5FD4413@outlook.com'}}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAABlLa4RUBXJToExnebpwea2AAAMhpkp"', 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'type': 'occurrence', 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX7vTsS0AARgAAAkBy7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAAEA==', 'start': {'dateTime': '2020-05-03T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-03T16:00:00.0000000', 'timeZone': 'UTC'}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAABlLa4RUBXJToExnebpwea2AAAMhpkp"', 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'type': 'occurrence', 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX774WtQAAAEYAAAJAcu19N72jSr9Rp1mE2xWABwBlLa4RUBXJToExnebpwea2AAACAQ0AAABlLa4RUBXJToExnebpwea2AAAACyy0xAAAABA=', 'start': {'dateTime': '2020-05-04T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-04T16:00:00.0000000', 'timeZone': 'UTC'}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAABlLa4RUBXJToExnebpwea2AAAMhpkp"', 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'type': 'occurrence', 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX8IdBHsAARgAAAkBy7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAAEA==', 'start': {'dateTime': '2020-05-05T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-05T16:00:00.0000000', 'timeZone': 'UTC'}}
]
self.env['calendar.event']._sync_microsoft2odoo(MicrosoftEvent(values))
recurrence = self.env['calendar.recurrence'].search([('microsoft_id', '=', self.recurrence_id)])
events = self.env['calendar.event'].search([('recurrence_id', '=', recurrence.id)], order='start asc')
self.assertTrue(recurrence, "It should keep the recurrence")
self.assertEqual(len(events), 3, "It should keep the 3 events")
self.assertEqual(recurrence.base_event_id, events[0])
self.assertEqual(events.mapped('name'), ['My recurrent event 2', 'My recurrent event 2', 'My recurrent event 2'])
def test_microsoft_recurrence_change_date_one_event(self):
values = [
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"ZS2uEVAVyU6BMZ3m6cHmtgAADIaZPA=="', 'createdDateTime': '2020-05-06T07:03:49.1444085Z', 'lastModifiedDateTime': self.datetime_future, 'changeKey': 'ZS2uEVAVyU6BMZ3m6cHmtgAADIaZPA==', 'categories': [], 'originalStartTimeZone': 'Romance Standard Time', 'originalEndTimeZone': 'Romance Standard Time', 'iCalUId': '040000008200E00074C5B7101A82E00800000000874F057E7423D601000000000000000010000000C6918C4B44D2D84586351FEC8B1B7F8C', 'reminderMinutesBeforeStart': 15, 'isReminderOn': True, 'hasAttachments': False, 'subject': 'My recurrent event', 'bodyPreview': '', 'importance': 'normal', 'sensitivity': 'normal', 'isAllDay': False, 'isCancelled': False, 'isOrganizer': True, 'IsRoomRequested': False, 'AutoRoomBookingStatus': 'None', 'responseRequested': True, 'seriesMasterId': None, 'showAs': 'busy', 'type': 'seriesMaster', 'webLink': 'https://outlook.live.com/owa/?itemid=AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA&exvsurl=1&path=/calendar/item', 'onlineMeetingUrl': None, 'isOnlineMeeting': False, 'onlineMeetingProvider': 'unknown', 'AllowNewTimeProposals': True, 'IsDraft': False, 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'responseStatus': {'response': 'organizer', 'time': '0001-01-01T00:00:00Z'}, 'body': {'contentType': 'html', 'content': ''}, 'start': {'dateTime': '2020-05-03T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-03T16:00:00.0000000', 'timeZone': 'UTC'}, 'location': {'displayName': '', 'locationType': 'default', 'uniqueIdType': 'unknown', 'address': {}, 'coordinates': {}}, 'locations': [], 'recurrence': {'pattern': {'type': 'daily', 'interval': 1, 'month': 0, 'dayOfMonth': 0, 'firstDayOfWeek': 'sunday', 'index': 'first'}, 'range': {'type': 'endDate', 'startDate': '2020-05-03', 'endDate': '2020-05-05', 'recurrenceTimeZone': 'Romance Standard Time', 'numberOfOccurrences': 0}}, 'attendees': [], 'organizer': {'emailAddress': {'name': 'outlook_7BA43549E5FD4413@outlook.com', 'address': 'outlook_7BA43549E5FD4413@outlook.com'}}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAABlLa4RUBXJToExnebpwea2AAAMhpk8"', 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'type': 'occurrence', 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX7vTsS0AARgAAAkBy7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAAEA==', 'start': {'dateTime': '2020-05-03T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-03T16:00:00.0000000', 'timeZone': 'UTC'}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"ZS2uEVAVyU6BMZ3m6cHmtgAADIaZPA=="', 'createdDateTime': '2020-05-06T08:41:52.1067613Z', 'lastModifiedDateTime': self.datetime_future, 'changeKey': 'ZS2uEVAVyU6BMZ3m6cHmtgAADIaZPA==', 'categories': [], 'originalStartTimeZone': 'Romance Standard Time', 'originalEndTimeZone': 'Romance Standard Time', 'iCalUId': '040000008200E00074C5B7101A82E00807E40504874F057E7423D601000000000000000010000000C6918C4B44D2D84586351FEC8B1B7F8C', 'reminderMinutesBeforeStart': 15, 'isReminderOn': True, 'hasAttachments': False, 'subject': 'My recurrent event', 'bodyPreview': '', 'importance': 'normal', 'sensitivity': 'normal', 'originalStart': '2020-05-04T14:30:00Z', 'isAllDay': False, 'isCancelled': False, 'isOrganizer': True, 'IsRoomRequested': False, 'AutoRoomBookingStatus': 'None', 'responseRequested': True, 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'showAs': 'busy', 'type': 'exception', 'webLink': 'https://outlook.live.com/owa/?itemid=AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX774WtQAAAEYAAAJAcu19N72jSr9Rp1mE2xWABwBlLa4RUBXJToExnebpwea2AAACAQ0AAABlLa4RUBXJToExnebpwea2AAAACyy0xAAAABA%3D&exvsurl=1&path=/calendar/item', 'onlineMeetingUrl': None, 'isOnlineMeeting': False, 'onlineMeetingProvider': 'unknown', 'AllowNewTimeProposals': True, 'IsDraft': False, 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX774WtQAAAEYAAAJAcu19N72jSr9Rp1mE2xWABwBlLa4RUBXJToExnebpwea2AAACAQ0AAABlLa4RUBXJToExnebpwea2AAAACyy0xAAAABA=', 'responseStatus': {'response': 'organizer', 'time': '0001-01-01T00:00:00Z'}, 'body': {'contentType': 'html', 'content': ''}, 'start': {'dateTime': '2020-05-04T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-04T17:00:00.0000000', 'timeZone': 'UTC'}, 'location': {'displayName': '', 'locationType': 'default', 'uniqueIdType': 'unknown', 'address': {}, 'coordinates': {}}, 'locations': [], 'attendees': [], 'organizer': {'emailAddress': {'name': 'outlook_7BA43549E5FD4413@outlook.com', 'address': 'outlook_7BA43549E5FD4413@outlook.com'}}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAABlLa4RUBXJToExnebpwea2AAAMhpk8"', 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'type': 'occurrence', 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX8IdBHsAARgAAAkBy7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAAEA==', 'start': {'dateTime': '2020-05-05T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-05T16:00:00.0000000', 'timeZone': 'UTC'}}
]
self.env['calendar.event']._sync_microsoft2odoo(MicrosoftEvent(values))
recurrence = self.env['calendar.recurrence'].search([('microsoft_id', '=', self.recurrence_id)])
events = self.env['calendar.event'].search([('recurrence_id', '=', recurrence.id)], order='start asc')
special_event = self.env['calendar.event'].search([('microsoft_id', '=', 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX774WtQAAAEYAAAJAcu19N72jSr9Rp1mE2xWABwBlLa4RUBXJToExnebpwea2AAACAQ0AAABlLa4RUBXJToExnebpwea2AAAACyy0xAAAABA=')])
self.assertTrue(recurrence, "It should have created an recurrence")
self.assertTrue(special_event, "It should have created an special event")
self.assertEqual(len(events), 3, "It should have created 3 events")
self.assertTrue(special_event in events)
self.assertEqual(recurrence.base_event_id, events[0])
self.assertEqual(events.mapped('name'), ['My recurrent event', 'My recurrent event', 'My recurrent event'])
event_not_special = events - special_event
self.assertEqual(event_not_special[0].start, datetime(2020, 5, 3, 14, 30))
self.assertEqual(event_not_special[0].stop, datetime(2020, 5, 3, 16, 00))
self.assertEqual(event_not_special[1].start, datetime(2020, 5, 5, 14, 30))
self.assertEqual(event_not_special[1].stop, datetime(2020, 5, 5, 16, 00))
self.assertEqual(special_event.start, datetime(2020, 5, 4, 14, 30))
self.assertEqual(special_event.stop, datetime(2020, 5, 4, 17, 00))
def test_microsoft_recurrence_delete_first_event(self):
values = [
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"ZS2uEVAVyU6BMZ3m6cHmtgAADI/Bnw=="', 'createdDateTime': '2020-05-06T07:03:49.1444085Z', 'lastModifiedDateTime': self.datetime_future, 'changeKey': 'ZS2uEVAVyU6BMZ3m6cHmtgAADI/Bnw==', 'categories': [], 'originalStartTimeZone': 'Romance Standard Time', 'originalEndTimeZone': 'Romance Standard Time', 'iCalUId': '040000008200E00074C5B7101A82E00800000000874F057E7423D601000000000000000010000000C6918C4B44D2D84586351FEC8B1B7F8C', 'reminderMinutesBeforeStart': 15, 'isReminderOn': True, 'hasAttachments': False, 'subject': 'My recurrent event', 'bodyPreview': '', 'importance': 'normal', 'sensitivity': 'normal', 'isAllDay': False, 'isCancelled': False, 'isOrganizer': True, 'IsRoomRequested': False, 'AutoRoomBookingStatus': 'None', 'responseRequested': True, 'seriesMasterId': None, 'showAs': 'busy', 'type': 'seriesMaster', 'webLink': 'https://outlook.live.com/owa/?itemid=AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA&exvsurl=1&path=/calendar/item', 'onlineMeetingUrl': None, 'isOnlineMeeting': False, 'onlineMeetingProvider': 'unknown', 'AllowNewTimeProposals': True, 'IsDraft': False, 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'responseStatus': {'response': 'organizer', 'time': '0001-01-01T00:00:00Z'}, 'body': {'contentType': 'html', 'content': ''}, 'start': {'dateTime': '2020-05-03T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-03T16:00:00.0000000', 'timeZone': 'UTC'}, 'location': {'displayName': '', 'locationType': 'default', 'uniqueIdType': 'unknown', 'address': {}, 'coordinates': {}}, 'locations': [], 'recurrence': {'pattern': {'type': 'daily', 'interval': 1, 'month': 0, 'dayOfMonth': 0, 'firstDayOfWeek': 'sunday', 'index': 'first'}, 'range': {'type': 'endDate', 'startDate': '2020-05-03', 'endDate': '2020-05-05', 'recurrenceTimeZone': 'Romance Standard Time', 'numberOfOccurrences': 0}}, 'attendees': [], 'organizer': {'emailAddress': {'name': 'outlook_7BA43549E5FD4413@outlook.com', 'address': 'outlook_7BA43549E5FD4413@outlook.com'}}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAABlLa4RUBXJToExnebpwea2AAAMj8Gf"', 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'type': 'occurrence', 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX774WtQAAAEYAAAJAcu19N72jSr9Rp1mE2xWABwBlLa4RUBXJToExnebpwea2AAACAQ0AAABlLa4RUBXJToExnebpwea2AAAACyy0xAAAABA=', 'start': {'dateTime': '2020-05-04T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-04T16:00:00.0000000', 'timeZone': 'UTC'}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAABlLa4RUBXJToExnebpwea2AAAMj8Gf"', 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'type': 'occurrence', 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX8IdBHsAARgAAAkBy7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAAEA==', 'start': {'dateTime': '2020-05-05T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-05T16:00:00.0000000', 'timeZone': 'UTC'}}
]
self.env['calendar.event']._sync_microsoft2odoo(MicrosoftEvent(values))
recurrence = self.env['calendar.recurrence'].search([('microsoft_id', '=', self.recurrence_id)])
events = self.env['calendar.event'].search([('recurrence_id', '=', recurrence.id)], order='start asc')
self.assertTrue(recurrence, "It should have created an recurrence")
self.assertEqual(len(events), 2, "It should left 2 events")
self.assertEqual(recurrence.base_event_id, events[0])
self.assertEqual(events[0].start, datetime(2020, 5, 4, 14, 30))
self.assertEqual(events[0].stop, datetime(2020, 5, 4, 16, 00))
self.assertEqual(events[1].start, datetime(2020, 5, 5, 14, 30))
self.assertEqual(events[1].stop, datetime(2020, 5, 5, 16, 00))
# Now we delete lastest event in Outlook.
values = [
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"ZS2uEVAVyU6BMZ3m6cHmtgAADI/Bpg=="', 'createdDateTime': '2020-05-06T07:03:49.1444085Z', 'lastModifiedDateTime': self.datetime_future, 'changeKey': 'ZS2uEVAVyU6BMZ3m6cHmtgAADI/Bpg==', 'categories': [], 'originalStartTimeZone': 'Romance Standard Time', 'originalEndTimeZone': 'Romance Standard Time', 'iCalUId': '040000008200E00074C5B7101A82E00800000000874F057E7423D601000000000000000010000000C6918C4B44D2D84586351FEC8B1B7F8C', 'reminderMinutesBeforeStart': 15, 'isReminderOn': True, 'hasAttachments': False, 'subject': 'My recurrent event', 'bodyPreview': '', 'importance': 'normal', 'sensitivity': 'normal', 'isAllDay': False, 'isCancelled': False, 'isOrganizer': True, 'IsRoomRequested': False, 'AutoRoomBookingStatus': 'None', 'responseRequested': True, 'seriesMasterId': None, 'showAs': 'busy', 'type': 'seriesMaster', 'webLink': 'https://outlook.live.com/owa/?itemid=AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA&exvsurl=1&path=/calendar/item', 'onlineMeetingUrl': None, 'isOnlineMeeting': False, 'onlineMeetingProvider': 'unknown', 'AllowNewTimeProposals': True, 'IsDraft': False, 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'responseStatus': {'response': 'organizer', 'time': '0001-01-01T00:00:00Z'}, 'body': {'contentType': 'html', 'content': ''}, 'start': {'dateTime': '2020-05-03T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-03T16:00:00.0000000', 'timeZone': 'UTC'}, 'location': {'displayName': '', 'locationType': 'default', 'uniqueIdType': 'unknown', 'address': {}, 'coordinates': {}}, 'locations': [], 'recurrence': {'pattern': {'type': 'daily', 'interval': 1, 'month': 0, 'dayOfMonth': 0, 'firstDayOfWeek': 'sunday', 'index': 'first'}, 'range': {'type': 'endDate', 'startDate': '2020-05-03', 'endDate': '2020-05-05', 'recurrenceTimeZone': 'Romance Standard Time', 'numberOfOccurrences': 0}}, 'attendees': [], 'organizer': {'emailAddress': {'name': 'outlook_7BA43549E5FD4413@outlook.com', 'address': 'outlook_7BA43549E5FD4413@outlook.com'}}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAABlLa4RUBXJToExnebpwea2AAAMj8Gm"', 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'type': 'occurrence', 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX774WtQAAAEYAAAJAcu19N72jSr9Rp1mE2xWABwBlLa4RUBXJToExnebpwea2AAACAQ0AAABlLa4RUBXJToExnebpwea2AAAACyy0xAAAABA=', 'start': {'dateTime': '2020-05-04T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-04T16:00:00.0000000', 'timeZone': 'UTC'}}
]
self.env['calendar.event']._sync_microsoft2odoo(MicrosoftEvent(values))
events = self.env['calendar.event'].search([('recurrence_id', '=', recurrence.id)], order='start asc')
self.assertEqual(len(events), 1, "It should have created 1 events")
self.assertEqual(recurrence.base_event_id, events[0])
# Now, we change end datetime of recurrence in Outlook, so all recurrence is recreated (even deleted events)
values = [
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"ZS2uEVAVyU6BMZ3m6cHmtgAADI/Bqg=="', 'createdDateTime': '2020-05-06T07:03:49.1444085Z', 'lastModifiedDateTime': self.datetime_future, 'changeKey': 'ZS2uEVAVyU6BMZ3m6cHmtgAADI/Bqg==', 'categories': [], 'originalStartTimeZone': 'Romance Standard Time', 'originalEndTimeZone': 'Romance Standard Time', 'iCalUId': '040000008200E00074C5B7101A82E00800000000874F057E7423D601000000000000000010000000C6918C4B44D2D84586351FEC8B1B7F8C', 'reminderMinutesBeforeStart': 15, 'isReminderOn': True, 'hasAttachments': False, 'subject': 'My recurrent event', 'bodyPreview': '', 'importance': 'normal', 'sensitivity': 'normal', 'isAllDay': False, 'isCancelled': False, 'isOrganizer': True, 'IsRoomRequested': False, 'AutoRoomBookingStatus': 'None', 'responseRequested': True, 'seriesMasterId': None, 'showAs': 'busy', 'type': 'seriesMaster', 'webLink': 'https://outlook.live.com/owa/?itemid=AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA&exvsurl=1&path=/calendar/item', 'onlineMeetingUrl': None, 'isOnlineMeeting': False, 'onlineMeetingProvider': 'unknown', 'AllowNewTimeProposals': True, 'IsDraft': False, 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'responseStatus': {'response': 'organizer', 'time': '0001-01-01T00:00:00Z'}, 'body': {'contentType': 'html', 'content': ''}, 'start': {'dateTime': '2020-05-03T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-03T16:30:00.0000000', 'timeZone': 'UTC'}, 'location': {'displayName': '', 'locationType': 'default', 'uniqueIdType': 'unknown', 'address': {}, 'coordinates': {}}, 'locations': [], 'recurrence': {'pattern': {'type': 'daily', 'interval': 1, 'month': 0, 'dayOfMonth': 0, 'firstDayOfWeek': 'sunday', 'index': 'first'}, 'range': {'type': 'endDate', 'startDate': '2020-05-03', 'endDate': '2020-05-05', 'recurrenceTimeZone': 'Romance Standard Time', 'numberOfOccurrences': 0}}, 'attendees': [], 'organizer': {'emailAddress': {'name': 'outlook_7BA43549E5FD4413@outlook.com', 'address': 'outlook_7BA43549E5FD4413@outlook.com'}}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAABlLa4RUBXJToExnebpwea2AAAMj8Gq"', 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'type': 'occurrence', 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX7vTsS0AARgAAAkBy7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAAEA==', 'start': {'dateTime': '2020-05-03T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-03T16:30:00.0000000', 'timeZone': 'UTC'}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAABlLa4RUBXJToExnebpwea2AAAMj8Gq"', 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'type': 'occurrence', 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX774WtQAAAEYAAAJAcu19N72jSr9Rp1mE2xWABwBlLa4RUBXJToExnebpwea2AAACAQ0AAABlLa4RUBXJToExnebpwea2AAAACyy0xAAAABA=', 'start': {'dateTime': '2020-05-04T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-04T16:30:00.0000000', 'timeZone': 'UTC'}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAABlLa4RUBXJToExnebpwea2AAAMj8Gq"', 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'type': 'occurrence', 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX8IdBHsAARgAAAkBy7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAAEA==', 'start': {'dateTime': '2020-05-05T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-05T16:30:00.0000000', 'timeZone': 'UTC'}}
]
self.env['calendar.event']._sync_microsoft2odoo(MicrosoftEvent(values))
events = self.env['calendar.event'].search([('recurrence_id', '=', recurrence.id)], order='start asc')
self.assertEqual(len(events), 3, "It should have created 3 events")
self.assertEqual(recurrence.base_event_id, events[0])
self.assertEqual(events.mapped('name'), ['My recurrent event', 'My recurrent event', 'My recurrent event'])
self.assertEqual(events[0].start, datetime(2020, 5, 3, 14, 30))
self.assertEqual(events[0].stop, datetime(2020, 5, 3, 16, 30))
self.assertEqual(events[1].start, datetime(2020, 5, 4, 14, 30))
self.assertEqual(events[1].stop, datetime(2020, 5, 4, 16, 30))
self.assertEqual(events[2].start, datetime(2020, 5, 5, 14, 30))
self.assertEqual(events[2].stop, datetime(2020, 5, 5, 16, 30))
def test_microsoft_recurrence_split_recurrence(self):
values = [
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"ZS2uEVAVyU6BMZ3m6cHmtgAADI/Dig=="', 'createdDateTime': '2020-05-06T07:03:49.1444085Z', 'lastModifiedDateTime': self.datetime_future, 'changeKey': 'ZS2uEVAVyU6BMZ3m6cHmtgAADI/Dig==', 'categories': [], 'originalStartTimeZone': 'Romance Standard Time', 'originalEndTimeZone': 'Romance Standard Time', 'iCalUId': '040000008200E00074C5B7101A82E00800000000874F057E7423D601000000000000000010000000C6918C4B44D2D84586351FEC8B1B7F8C', 'reminderMinutesBeforeStart': 15, 'isReminderOn': True, 'hasAttachments': False, 'subject': 'My recurrent event', 'bodyPreview': '', 'importance': 'normal', 'sensitivity': 'normal', 'isAllDay': False, 'isCancelled': False, 'isOrganizer': True, 'IsRoomRequested': False, 'AutoRoomBookingStatus': 'None', 'responseRequested': True, 'seriesMasterId': None, 'showAs': 'busy', 'type': 'seriesMaster', 'webLink': 'https://outlook.live.com/owa/?itemid=AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA&exvsurl=1&path=/calendar/item', 'onlineMeetingUrl': None, 'isOnlineMeeting': False, 'onlineMeetingProvider': 'unknown', 'AllowNewTimeProposals': True, 'IsDraft': False, 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'responseStatus': {'response': 'organizer', 'time': '0001-01-01T00:00:00Z'}, 'body': {'contentType': 'html', 'content': ''}, 'start': {'dateTime': '2020-05-03T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-03T16:30:00.0000000', 'timeZone': 'UTC'}, 'location': {'displayName': '', 'locationType': 'default', 'uniqueIdType': 'unknown', 'address': {}, 'coordinates': {}}, 'locations': [], 'recurrence': {'pattern': {'type': 'daily', 'interval': 1, 'month': 0, 'dayOfMonth': 0, 'firstDayOfWeek': 'sunday', 'index': 'first'}, 'range': {'type': 'endDate', 'startDate': '2020-05-03', 'endDate': '2020-05-03', 'recurrenceTimeZone': 'Romance Standard Time', 'numberOfOccurrences': 0}}, 'attendees': [], 'organizer': {'emailAddress': {'name': 'outlook_7BA43549E5FD4413@outlook.com', 'address': 'outlook_7BA43549E5FD4413@outlook.com'}}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"ZS2uEVAVyU6BMZ3m6cHmtgAADI/Dkw=="', 'createdDateTime': '2020-05-06T13:24:10.0507138Z', 'lastModifiedDateTime': self.datetime_future, 'changeKey': 'ZS2uEVAVyU6BMZ3m6cHmtgAADI/Dkw==', 'categories': [], 'originalStartTimeZone': 'Romance Standard Time', 'originalEndTimeZone': 'Romance Standard Time', 'iCalUId': '040000008200E00074C5B7101A82E008000000001A4457A0A923D601000000000000000010000000476AE6084FD718418262DA1AE3E41411', 'reminderMinutesBeforeStart': 15, 'isReminderOn': True, 'hasAttachments': False, 'subject': 'My recurrent event', 'bodyPreview': '', 'importance': 'normal', 'sensitivity': 'normal', 'isAllDay': False, 'isCancelled': False, 'isOrganizer': True, 'IsRoomRequested': False, 'AutoRoomBookingStatus': 'None', 'responseRequested': True, 'seriesMasterId': None, 'showAs': 'busy', 'type': 'seriesMaster', 'webLink': 'https://outlook.live.com/owa/?itemid=AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAAMkgQrAAAA&exvsurl=1&path=/calendar/item', 'onlineMeetingUrl': None, 'isOnlineMeeting': False, 'onlineMeetingProvider': 'unknown', 'AllowNewTimeProposals': True, 'IsDraft': False, 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAAMkgQrAAAA', 'responseStatus': {'response': 'organizer', 'time': '0001-01-01T00:00:00Z'}, 'body': {'contentType': 'html', 'content': ''}, 'start': {'dateTime': '2020-05-04T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-04T17:00:00.0000000', 'timeZone': 'UTC'}, 'location': {'displayName': '', 'locationType': 'default', 'uniqueIdType': 'unknown', 'address': {}, 'coordinates': {}}, 'locations': [], 'recurrence': {'pattern': {'type': 'daily', 'interval': 1, 'month': 0, 'dayOfMonth': 0, 'firstDayOfWeek': 'sunday', 'index': 'first'}, 'range': {'type': 'endDate', 'startDate': '2020-05-04', 'endDate': '2020-05-06', 'recurrenceTimeZone': 'Romance Standard Time', 'numberOfOccurrences': 0}}, 'attendees': [], 'organizer': {'emailAddress': {'name': 'outlook_7BA43549E5FD4413@outlook.com', 'address': 'outlook_7BA43549E5FD4413@outlook.com'}}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAABlLa4RUBXJToExnebpwea2AAAMj8OK"', 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', 'type': 'occurrence', 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX7vTsS0AARgAAAkBy7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAAEA==', 'start': {'dateTime': '2020-05-03T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-03T16:30:00.0000000', 'timeZone': 'UTC'}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAABlLa4RUBXJToExnebpwea2AAAMj8OT"', 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAAMkgQrAAAA', 'type': 'occurrence', 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX774WtQAAAEYAAAJAcu19N72jSr9Rp1mE2xWABwBlLa4RUBXJToExnebpwea2AAACAQ0AAABlLa4RUBXJToExnebpwea2AAAADJIEKwAAABA=', 'start': {'dateTime': '2020-05-04T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-04T17:00:00.0000000', 'timeZone': 'UTC'}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"ZS2uEVAVyU6BMZ3m6cHmtgAADI/Dkw=="', 'createdDateTime': '2020-05-06T13:25:05.9240043Z', 'lastModifiedDateTime': self.datetime_future, 'changeKey': 'ZS2uEVAVyU6BMZ3m6cHmtgAADI/Dkw==', 'categories': [], 'originalStartTimeZone': 'Romance Standard Time', 'originalEndTimeZone': 'Romance Standard Time', 'iCalUId': '040000008200E00074C5B7101A82E00807E405051A4457A0A923D601000000000000000010000000476AE6084FD718418262DA1AE3E41411', 'reminderMinutesBeforeStart': 15, 'isReminderOn': True, 'hasAttachments': False, 'subject': 'My recurrent event 2', 'bodyPreview': '', 'importance': 'normal', 'sensitivity': 'normal', 'originalStart': '2020-05-05T14:30:00Z', 'isAllDay': False, 'isCancelled': False, 'isOrganizer': True, 'IsRoomRequested': False, 'AutoRoomBookingStatus': 'None', 'responseRequested': True, 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAAMkgQrAAAA', 'showAs': 'busy', 'type': 'exception', 'webLink': 'https://outlook.live.com/owa/?itemid=AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX8IdBHsAARgAAAkBy7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAAMkgQrAAAAEA%3D%3D&exvsurl=1&path=/calendar/item', 'onlineMeetingUrl': None, 'isOnlineMeeting': False, 'onlineMeetingProvider': 'unknown', 'AllowNewTimeProposals': True, 'IsDraft': False, 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX8IdBHsAARgAAAkBy7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAAMkgQrAAAAEA==', 'responseStatus': {'response': 'organizer', 'time': '0001-01-01T00:00:00Z'}, 'body': {'contentType': 'html', 'content': ''}, 'start': {'dateTime': '2020-05-05T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-05T17:00:00.0000000', 'timeZone': 'UTC'}, 'location': {'displayName': '', 'locationType': 'default', 'uniqueIdType': 'unknown', 'address': {}, 'coordinates': {}}, 'locations': [], 'attendees': [], 'organizer': {'emailAddress': {'name': 'outlook_7BA43549E5FD4413@outlook.com', 'address': 'outlook_7BA43549E5FD4413@outlook.com'}}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAABlLa4RUBXJToExnebpwea2AAAMj8OT"', 'seriesMasterId': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAAMkgQrAAAA', 'type': 'occurrence', 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoBUQAICADX8VBriIAARgAAAkBy7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAAMkgQrAAAAEA==', 'start': {'dateTime': '2020-05-06T14:30:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2020-05-06T17:00:00.0000000', 'timeZone': 'UTC'}}
]
self.env['calendar.event']._sync_microsoft2odoo(MicrosoftEvent(values))
recurrence_1 = self.env['calendar.recurrence'].search([('microsoft_id', '=', self.recurrence_id)])
recurrence_2 = self.env['calendar.recurrence'].search([('microsoft_id', '=', 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAAMkgQrAAAA')])
events_1 = self.env['calendar.event'].search([('recurrence_id', '=', recurrence_1.id)], order='start asc')
events_2 = self.env['calendar.event'].search([('recurrence_id', '=', recurrence_2.id)], order='start asc')
self.assertTrue(recurrence_1, "It should have created an recurrence")
self.assertTrue(recurrence_2, "It should have created an recurrence")
self.assertEqual(len(events_1), 1, "It should left 1 event")
self.assertEqual(len(events_2), 3, "It should have created 3 events")
self.assertEqual(recurrence_1.base_event_id, events_1[0])
self.assertEqual(recurrence_2.base_event_id, events_2[0])
self.assertEqual(events_1.mapped('name'), ['My recurrent event'])
self.assertEqual(events_2.mapped('name'), ['My recurrent event', 'My recurrent event 2', 'My recurrent event'])
self.assertEqual(events_1[0].start, datetime(2020, 5, 3, 14, 30))
self.assertEqual(events_1[0].stop, datetime(2020, 5, 3, 16, 30))
self.assertEqual(events_2[0].start, datetime(2020, 5, 4, 14, 30))
self.assertEqual(events_2[0].stop, datetime(2020, 5, 4, 17, 00))
self.assertEqual(events_2[1].start, datetime(2020, 5, 5, 14, 30))
self.assertEqual(events_2[1].stop, datetime(2020, 5, 5, 17, 00))
self.assertEqual(events_2[2].start, datetime(2020, 5, 6, 14, 30))
self.assertEqual(events_2[2].stop, datetime(2020, 5, 6, 17, 00))
def test_microsoft_recurrence_delete(self):
recurrence_id = self.env['calendar.recurrence'].search([('microsoft_id', '=', self.recurrence_id)])
event_ids = self.env['calendar.event'].search([('recurrence_id', '=', recurrence_id.id)], order='start asc').ids
values = [{'@odata.type': '#microsoft.graph.event', 'id': 'AQ8PojGtrADQATM3ZmYAZS0yY2MAMC00MDg1LTAwAi0wMAoARgAAA0By7X03vaNKv1GnWYTbFYAHAGUtrhFQFclOgTGd5unB5rYAAAIBDQAAAGUtrhFQFclOgTGd5unB5rYAAAALLLTEAAAA', '@removed': {'reason': 'deleted'}}]
self.env['calendar.event']._sync_microsoft2odoo(MicrosoftEvent(values))
recurrence = self.env['calendar.recurrence'].search([('microsoft_id', '=', self.recurrence_id)])
events = self.env['calendar.event'].browse(event_ids).exists()
self.assertFalse(recurrence, "It should remove recurrence")
self.assertFalse(events, "It should remove all events")
def test_attendees_must_have_email(self):
"""
Synching with a partner without mail raises a ValidationError because Microsoft don't accept attendees without one.
"""
MicrosoftCal = MicrosoftCalendarService(self.env['microsoft.service'])
partner = self.env['res.partner'].create({
'name': 'SuperPartner',
})
event = self.env['calendar.event'].create({
'name': "SuperEvent",
'start': datetime(2020, 3, 16, 11, 0),
'stop': datetime(2020, 3, 16, 13, 0),
'partner_ids': [(4, partner.id)],
})
with self.assertRaises(ValidationError):
event._sync_odoo2microsoft(MicrosoftCal)
def test_cancel_occurence_of_recurrent_event(self):
""" The user is invited to a recurrent event. When synced, all events are present, there are three occurrences:
- 07/15/2021, 15:00-15:30
- 07/16/2021, 15:00-15:30
- 07/17/2021, 15:00-15:30
Then, the organizer cancels the second occurrence -> The latter should not be displayed anymore
"""
microsoft_id = 'AQMkADAwATM3ZmYAZS0zZmMyLWYxYjQtMDACLTAwCgBGAAADZ59RIxdyh0Kt-MXfyCpfwAcApynKRnkCyUmnqILQHcLZEQAAAgENAAAApynKRnkCyUmnqILQHcLZEQAAAARKsSQAAAA='
# self.env.user.partner_id.email = "odoo_bf_user01@outlook.com"
first_sync_values = [
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"pynKRnkCyUmnqILQHcLZEQAABElcNQ=="', 'createdDateTime': '2021-07-15T14:47:40.2996962Z', 'lastModifiedDateTime': '2021-07-15T14:47:40.3783507Z', 'changeKey': 'pynKRnkCyUmnqILQHcLZEQAABElcNQ==', 'categories': [], 'transactionId': None, 'originalStartTimeZone': 'Romance Standard Time', 'originalEndTimeZone': 'Romance Standard Time', 'iCalUId': '040000008200E00074C5B7101A82E00800000000B35B3B5A8879D70100000000000000001000000008A0949F4EC0A1479E4ED178D87EF679', 'reminderMinutesBeforeStart': 15, 'isReminderOn': True, 'hasAttachments': False, 'subject': 'Recurrent Event 1646', 'bodyPreview': '', 'importance': 'normal', 'sensitivity': 'normal', 'isAllDay': False, 'isCancelled': False, 'isOrganizer': False, 'IsRoomRequested': False, 'AutoRoomBookingStatus': 'None', 'responseRequested': True, 'seriesMasterId': None, 'showAs': 'tentative', 'type': 'seriesMaster', 'webLink': 'https://outlook.live.com/owa/?itemid=AQMkADAwATM3ZmYAZS0zZmMyLWYxYjQtMDACLTAwCgBGAAADZ59RIxdyh0Kt%2FMXfyCpfwAcApynKRnkCyUmnqILQHcLZEQAAAgENAAAApynKRnkCyUmnqILQHcLZEQAAAARKsSQAAAA%3D&exvsurl=1&path=/calendar/item', 'onlineMeetingUrl': None, 'isOnlineMeeting': False, 'onlineMeetingProvider': 'unknown', 'allowNewTimeProposals': True, 'OccurrenceId': None, 'isDraft': False, 'hideAttendees': False, 'CalendarEventClassifications': [], 'AutoRoomBookingOptions': None, 'onlineMeeting': None, 'id': microsoft_id, 'responseStatus': {'response': 'notResponded', 'time': '0001-01-01T00:00:00Z'}, 'body': {'contentType': 'html', 'content': ''}, 'start': {'dateTime': '2021-07-15T15:00:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2021-07-15T15:30:00.0000000', 'timeZone': 'UTC'}, 'location': {'displayName': '', 'locationType': 'default', 'uniqueIdType': 'unknown', 'address': {}, 'coordinates': {}}, 'locations': [], 'recurrence': {'pattern': {'type': 'daily', 'interval': 1, 'month': 0, 'dayOfMonth': 0, 'firstDayOfWeek': 'sunday', 'index': 'first'}, 'range': {'type': 'endDate', 'startDate': '2021-07-15', 'endDate': '2021-07-17', 'recurrenceTimeZone': 'Romance Standard Time', 'numberOfOccurrences': 0}}, 'attendees': [{'type': 'required', 'status': {'response': 'none', 'time': '0001-01-01T00:00:00Z'}, 'emailAddress': {'name': 'Odoo02 Outlook02', 'address': 'odoo_bf_user02@outlook.com'}}, {'type': 'required', 'status': {'response': 'none', 'time': '0001-01-01T00:00:00Z'}, 'emailAddress': {'name': 'Odoo01 Outlook01', 'address': 'odoo_bf_user01@outlook.com'}}], 'organizer': {'emailAddress': {'name': 'Odoo02 Outlook02', 'address': 'odoo_bf_user02@outlook.com'}}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAACnKcpGeQLJSaeogtAdwtkRAAAESVw1"', 'seriesMasterId': ('%s' % microsoft_id), 'type': 'occurrence', 'id': 'AQMkADAwATM3ZmYAZS0zZmMyLWYxYjQtMDACLTAwCgFRAAgIANlHI305wABGAAACZ59RIxdyh0Kt-MXfyCpfwAcApynKRnkCyUmnqILQHcLZEQAAAgENAAAApynKRnkCyUmnqILQHcLZEQAAAARKsSQAAAAQ', 'start': {'dateTime': '2021-07-15T15:00:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2021-07-15T15:30:00.0000000', 'timeZone': 'UTC'}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAACnKcpGeQLJSaeogtAdwtkRAAAESVw1"', 'seriesMasterId': microsoft_id, 'type': 'occurrence', 'id': 'AQMkADAwATM3ZmYAZS0zZmMyLWYxYjQtMDACLTAwCgFRAAgIANlH7KejgABGAAACZ59RIxdyh0Kt-MXfyCpfwAcApynKRnkCyUmnqILQHcLZEQAAAgENAAAApynKRnkCyUmnqILQHcLZEQAAAARKsSQAAAAQ', 'start': {'dateTime': '2021-07-16T15:00:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2021-07-16T15:30:00.0000000', 'timeZone': 'UTC'}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAACnKcpGeQLJSaeogtAdwtkRAAAESVw1"', 'seriesMasterId': microsoft_id, 'type': 'occurrence', 'id': 'AQMkADAwATM3ZmYAZS0zZmMyLWYxYjQtMDACLTAwCgFRAAgIANlItdINQABGAAACZ59RIxdyh0Kt-MXfyCpfwAcApynKRnkCyUmnqILQHcLZEQAAAgENAAAApynKRnkCyUmnqILQHcLZEQAAAARKsSQAAAAQ', 'start': {'dateTime': '2021-07-17T15:00:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2021-07-17T15:30:00.0000000', 'timeZone': 'UTC'}}
]
second_sync_values = [
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"pynKRnkCyUmnqILQHcLZEQAABElcUw=="', 'createdDateTime': '2021-07-15T14:47:40.2996962Z', 'lastModifiedDateTime': '2021-07-15T14:51:25.2560888Z', 'changeKey': 'pynKRnkCyUmnqILQHcLZEQAABElcUw==', 'categories': [], 'transactionId': None, 'originalStartTimeZone': 'Romance Standard Time', 'originalEndTimeZone': 'Romance Standard Time', 'iCalUId': '040000008200E00074C5B7101A82E00800000000B35B3B5A8879D70100000000000000001000000008A0949F4EC0A1479E4ED178D87EF679', 'reminderMinutesBeforeStart': 15, 'isReminderOn': True, 'hasAttachments': False, 'subject': 'Recurrent Event 1646', 'bodyPreview': '', 'importance': 'normal', 'sensitivity': 'normal', 'isAllDay': False, 'isCancelled': False, 'isOrganizer': False, 'IsRoomRequested': False, 'AutoRoomBookingStatus': 'None', 'responseRequested': True, 'seriesMasterId': None, 'showAs': 'tentative', 'type': 'seriesMaster', 'webLink': 'https://outlook.live.com/owa/?itemid=AQMkADAwATM3ZmYAZS0zZmMyLWYxYjQtMDACLTAwCgBGAAADZ59RIxdyh0Kt%2FMXfyCpfwAcApynKRnkCyUmnqILQHcLZEQAAAgENAAAApynKRnkCyUmnqILQHcLZEQAAAARKsSQAAAA%3D&exvsurl=1&path=/calendar/item', 'onlineMeetingUrl': None, 'isOnlineMeeting': False, 'onlineMeetingProvider': 'unknown', 'allowNewTimeProposals': True, 'OccurrenceId': None, 'isDraft': False, 'hideAttendees': False, 'CalendarEventClassifications': [], 'id': microsoft_id, 'responseStatus': {'response': 'notResponded', 'time': '0001-01-01T00:00:00Z'}, 'body': {'contentType': 'html', 'content': ''}, 'start': {'dateTime': '2021-07-15T15:00:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2021-07-15T15:30:00.0000000', 'timeZone': 'UTC'}, 'location': {'displayName': '', 'locationType': 'default', 'uniqueIdType': 'unknown', 'address': {}, 'coordinates': {}}, 'locations': [], 'recurrence': {'pattern': {'type': 'daily', 'interval': 1, 'month': 0, 'dayOfMonth': 0, 'firstDayOfWeek': 'sunday', 'index': 'first'}, 'range': {'type': 'endDate', 'startDate': '2021-07-15', 'endDate': '2021-07-17', 'recurrenceTimeZone': 'Romance Standard Time', 'numberOfOccurrences': 0}}, 'attendees': [{'type': 'required', 'status': {'response': 'none', 'time': '0001-01-01T00:00:00Z'}, 'emailAddress': {'name': 'Odoo02 Outlook02', 'address': 'odoo_bf_user02@outlook.com'}}, {'type': 'required', 'status': {'response': 'none', 'time': '0001-01-01T00:00:00Z'}, 'emailAddress': {'name': 'Odoo01 Outlook01', 'address': 'odoo_bf_user01@outlook.com'}}], 'organizer': {'emailAddress': {'name': 'Odoo02 Outlook02', 'address': 'odoo_bf_user02@outlook.com'}}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAACnKcpGeQLJSaeogtAdwtkRAAAESVxT"', 'seriesMasterId': microsoft_id, 'type': 'occurrence', 'id': 'AQMkADAwATM3ZmYAZS0zZmMyLWYxYjQtMDACLTAwCgFRAAgIANlHI305wABGAAACZ59RIxdyh0Kt-MXfyCpfwAcApynKRnkCyUmnqILQHcLZEQAAAgENAAAApynKRnkCyUmnqILQHcLZEQAAAARKsSQAAAAQ', 'start': {'dateTime': '2021-07-15T15:00:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2021-07-15T15:30:00.0000000', 'timeZone': 'UTC'}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"pynKRnkCyUmnqILQHcLZEQAABElcUw=="', 'createdDateTime': '2021-07-15T14:51:25.1366139Z', 'lastModifiedDateTime': '2021-07-15T14:51:25.136614Z', 'changeKey': 'pynKRnkCyUmnqILQHcLZEQAABElcUw==', 'categories': [], 'transactionId': None, 'originalStartTimeZone': 'Romance Standard Time', 'originalEndTimeZone': 'Romance Standard Time', 'iCalUId': '040000008200E00074C5B7101A82E00807E50710B35B3B5A8879D70100000000000000001000000008A0949F4EC0A1479E4ED178D87EF679', 'reminderMinutesBeforeStart': 15, 'isReminderOn': True, 'hasAttachments': False, 'subject': 'Canceled: Recurrent Event 1646', 'bodyPreview': '', 'importance': 'high', 'sensitivity': 'normal', 'originalStart': '2021-07-16T15:00:00Z', 'isAllDay': False, 'isCancelled': True, 'isOrganizer': False, 'IsRoomRequested': False, 'AutoRoomBookingStatus': 'None', 'responseRequested': True, 'seriesMasterId': microsoft_id, 'showAs': 'free', 'type': 'exception', 'webLink': 'https://outlook.live.com/owa/?itemid=AQMkADAwATM3ZmYAZS0zZmMyLWYxYjQtMDACLTAwCgFRAAgIANlH7KejgABGAAACZ59RIxdyh0Kt%2FMXfyCpfwAcApynKRnkCyUmnqILQHcLZEQAAAgENAAAApynKRnkCyUmnqILQHcLZEQAAAARKsSQAAAAQ&exvsurl=1&path=/calendar/item', 'onlineMeetingUrl': None, 'isOnlineMeeting': False, 'onlineMeetingProvider': 'unknown', 'allowNewTimeProposals': True, 'OccurrenceId': ('OID.%s.2021-07-16' % microsoft_id), 'isDraft': False, 'hideAttendees': False, 'CalendarEventClassifications': [], 'id': 'AQMkADAwATM3ZmYAZS0zZmMyLWYxYjQtMDACLTAwCgFRAAgIANlH7KejgABGAAACZ59RIxdyh0Kt-MXfyCpfwAcApynKRnkCyUmnqILQHcLZEQAAAgENAAAApynKRnkCyUmnqILQHcLZEQAAAARKsSQAAAAQ', 'responseStatus': {'response': 'notResponded', 'time': '0001-01-01T00:00:00Z'}, 'body': {'contentType': 'html', 'content': '<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8">\r\n<meta name="Generator" content="Microsoft Exchange Server">\r\n<!-- converted from text -->\r\n<style><!-- .EmailQuote { margin-left: 1pt; padding-left: 4pt; border-left: #800000 2px solid; } --></style></head>\r\n<body>\r\n<font size="2"><span style="font-size:11pt;"><div class="PlainText">&nbsp;</div></span></font>\r\n</body>\r\n</html>\r\n'}, 'start': {'dateTime': '2021-07-16T15:00:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2021-07-16T15:30:00.0000000', 'timeZone': 'UTC'}, 'location': {'displayName': '', 'locationType': 'default', 'uniqueIdType': 'unknown', 'address': {}, 'coordinates': {}}, 'locations': [], 'attendees': [{'type': 'required', 'status': {'response': 'none', 'time': '0001-01-01T00:00:00Z'}, 'emailAddress': {'name': 'Odoo02 Outlook02', 'address': 'odoo_bf_user02@outlook.com'}}, {'type': 'required', 'status': {'response': 'none', 'time': '0001-01-01T00:00:00Z'}, 'emailAddress': {'name': 'Odoo01 Outlook01', 'address': 'odoo_bf_user01@outlook.com'}}], 'organizer': {'emailAddress': {'name': 'Odoo02 Outlook02', 'address': 'odoo_bf_user02@outlook.com'}}},
{'@odata.type': '#microsoft.graph.event', '@odata.etag': 'W/"DwAAABYAAACnKcpGeQLJSaeogtAdwtkRAAAESVxT"', 'seriesMasterId': microsoft_id, 'type': 'occurrence', 'id': 'AQMkADAwATM3ZmYAZS0zZmMyLWYxYjQtMDACLTAwCgFRAAgIANlItdINQABGAAACZ59RIxdyh0Kt-MXfyCpfwAcApynKRnkCyUmnqILQHcLZEQAAAgENAAAApynKRnkCyUmnqILQHcLZEQAAAARKsSQAAAAQ', 'start': {'dateTime': '2021-07-17T15:00:00.0000000', 'timeZone': 'UTC'}, 'end': {'dateTime': '2021-07-17T15:30:00.0000000', 'timeZone': 'UTC'}}
]
self.env['calendar.event']._sync_microsoft2odoo(MicrosoftEvent(first_sync_values))
recurrent_event = self.env['calendar.recurrence'].search([('microsoft_id', '=', 'AQMkADAwATM3ZmYAZS0zZmMyLWYxYjQtMDACLTAwCgBGAAADZ59RIxdyh0Kt-MXfyCpfwAcApynKRnkCyUmnqILQHcLZEQAAAgENAAAApynKRnkCyUmnqILQHcLZEQAAAARKsSQAAAA=')])
self.assertEqual(len(recurrent_event.calendar_event_ids), 3)
# Need to cheat on the write date, otherwise the second sync won't update the events
recurrent_event.write_date = datetime(2021, 7, 15, 14, 00)
self.env['calendar.event']._sync_microsoft2odoo(MicrosoftEvent(second_sync_values))
self.assertEqual(len(recurrent_event.calendar_event_ids), 2)
events = recurrent_event.calendar_event_ids.sorted(key=lambda e: e.start)
self.assertEqual(events[0].start, datetime(2021, 7, 15, 15, 00))
self.assertEqual(events[0].stop, datetime(2021, 7, 15, 15, 30))
self.assertEqual(events[1].start, datetime(2021, 7, 17, 15, 00))
self.assertEqual(events[1].stop, datetime(2021, 7, 17, 15, 30))
def test_use_classic_location(self):
ms_event = self.single_event
self.env['calendar.event']._sync_microsoft2odoo(MicrosoftEvent(ms_event))
event = self.env['calendar.event'].search([("microsoft_id", "=", ms_event[0]["id"])])
self.assertEqual(event.location, ms_event[0]["location"]["displayName"])
def test_use_url_location(self):
ms_event = self.single_event
ms_event[0]["location"]["displayName"] = "https://mylocation.com/meeting-room"
self.env['calendar.event']._sync_microsoft2odoo(MicrosoftEvent(ms_event))
event = self.env['calendar.event'].search([("microsoft_id", "=", ms_event[0]["id"])])
self.assertEqual(event.location, ms_event[0]["location"]["displayName"])
def test_use_specific_virtual_location(self):
"""
If the location of the Outlook event is a specific virtual location (such as a video Teams meeting),
use it as videocall location.
"""
ms_event = self.single_event
ms_event[0]["location"]["displayName"] = "https://teams.microsoft.com/l/meeting/1234"
self.env['calendar.event']._sync_microsoft2odoo(MicrosoftEvent(ms_event))
event = self.env['calendar.event'].search([("microsoft_id", "=", ms_event[0]["id"])])
self.assertEqual(event.location, False)
self.assertEqual(event.videocall_location, ms_event[0]["location"]["displayName"])
def test_outlook_event_has_online_meeting_url(self):
ms_event = self.single_event
ms_event[0].update({
'isOnlineMeeting': True,
'onlineMeeting': {'joinUrl': 'https://video-meeting.com/1234'}
})
self.env['calendar.event']._sync_microsoft2odoo(MicrosoftEvent(ms_event))
event = self.env['calendar.event'].search([("microsoft_id", "=", ms_event[0]["id"])])
self.assertEqual(event.videocall_location, ms_event[0]["onlineMeeting"]["joinUrl"])
def test_event_reminder_emails_with_microsoft_id(self):
"""
Odoo shouldn't send email reminders for synced events.
Test that events synced to Microsoft (with a `microsoft_id`)
are excluded from email alarm notifications.
"""
now = datetime.now()
start = now - relativedelta(minutes=30)
end = now + relativedelta(hours=2)
alarm = self.env['calendar.alarm'].create({
'name': 'Alarm',
'alarm_type': 'email',
'interval': 'minutes',
'duration': 30,
})
ms_event = self.single_event
ms_event[0].update({
'isOnlineMeeting': True,
'alarm_id': alarm.id,
'start': {
'dateTime': pytz.utc.localize(start).isoformat(),
'timeZone': 'Europe/Brussels'
},
'reminders': {'overrides': [{"method": "email", "minutes": 30}], 'useDefault': False},
'end': {
'dateTime': pytz.utc.localize(end).isoformat(),
'timeZone': 'Europe/Brussels'
},
})
self.env['calendar.event']._sync_microsoft2odoo(MicrosoftEvent(ms_event))
events_by_alarm = self.env['calendar.alarm_manager']._get_events_by_alarm_to_notify('email')
self.assertFalse(events_by_alarm, "Events with microsoft_id should not trigger reminders")

View file

@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, date
from dateutil.relativedelta import relativedelta
from unittest.mock import MagicMock, patch
from odoo.tests.common import TransactionCase
from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService
from odoo.addons.microsoft_calendar.models.res_users import User
from odoo.addons.microsoft_calendar.models.microsoft_sync import MicrosoftSync
from odoo.modules.registry import Registry
from odoo.addons.microsoft_account.models.microsoft_service import TIMEOUT
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
@patch.object(User, '_get_microsoft_calendar_token', lambda user: 'dummy-token')
class TestSyncOdoo2Microsoft(TransactionCase):
def setUp(self):
super().setUp()
self.microsoft_service = MicrosoftCalendarService(self.env['microsoft.service'])
def assertMicrosoftEventInserted(self, values):
MicrosoftSync._microsoft_insert.assert_called_once_with(self.microsoft_service, values)
def assertMicrosoftEventNotInserted(self):
MicrosoftSync._microsoft_insert.assert_not_called()
def assertMicrosoftEventPatched(self, microsoft_id, values, timeout=None):
expected_args = (microsoft_id, values)
expected_kwargs = {'timeout': timeout} if timeout else {}
MicrosoftSync._microsoft_patch.assert_called_once()
args, kwargs = MicrosoftSync._microsoft_patch.call_args
self.assertEqual(args[1:], expected_args) # skip Google service arg
self.assertEqual(kwargs, expected_kwargs)
@patch_api
def test_stop_synchronization(self):
self.env.user.stop_microsoft_synchronization()
self.assertTrue(self.env.user.microsoft_synchronization_stopped, "The microsoft synchronization flag should be switched on")
self.assertFalse(self.env.user._sync_microsoft_calendar(self.microsoft_service), "The microsoft synchronization should be stopped")
year = date.today().year - 1
# If synchronization stopped, creating a new event should not call _google_insert.
self.env['calendar.event'].create({
'name': "Event",
'start': datetime(year, 1, 15, 8, 0),
'stop': datetime(year, 1, 15, 18, 0),
'privacy': 'private',
})
self.assertMicrosoftEventNotInserted()
@patch_api
def test_restart_synchronization(self):
# Test new event created after stopping synchronization are correctly patched when restarting sync.
self.maxDiff = None
microsoft_id = 'aaaaaaaaa'
year = date.today().year
partner = self.env['res.partner'].create({'name': 'Jean-Luc', 'email': 'jean-luc@opoo.com'})
user = self.env['res.users'].create({
'name': 'Test user Calendar',
'login': 'jean-luc@opoo.com',
'partner_id': partner.id,
})
user.stop_microsoft_synchronization()
# In case of full sync, limit to a range of 1y in past and 1y in the future by default
event = self.env['calendar.event'].with_user(user).create({
'microsoft_id': microsoft_id,
'name': "Event",
'start': datetime(year, 1, 15, 8, 0),
'stop': datetime(year, 1, 15, 18, 0),
'partner_ids': [(4, partner.id)],
})
user.with_user(user).restart_microsoft_synchronization()
event.with_user(user)._sync_odoo2microsoft(self.microsoft_service)
microsoft_guid = self.env['ir.config_parameter'].sudo().get_param('microsoft_calendar.microsoft_guid', False)
self.assertMicrosoftEventPatched(event.microsoft_id, {
'id': event.microsoft_id,
'start': {'dateTime': '%s-01-15T08:00:00+00:00' % year, 'timeZone': 'Europe/London'},
'end': {'dateTime': '%s-01-15T18:00:00+00:00' % year, 'timeZone': 'Europe/London'},
'subject': 'Event',
'body': {'content': '', 'contentType': 'html'},
'attendees': [],
'isAllDay': False,
'isOrganizer': True,
'isReminderOn': False,
'sensitivity': 'normal',
'showAs': 'busy',
'location': {'displayName': ''},
'organizer': {'emailAddress': {'address': 'jean-luc@opoo.com', 'name': 'Test user Calendar'}},
'reminderMinutesBeforeStart': 0,
'singleValueExtendedProperties': [{
'id': 'String {%s} Name odoo_id' % microsoft_guid,
'value': str(event.id),
}, {
'id': 'String {%s} Name owner_odoo_id' % microsoft_guid,
'value': str(user.id),
}
]
})