import pytz from datetime import datetime, timedelta from markupsafe import Markup from unittest.mock import patch, MagicMock 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() self.env.user.unpause_microsoft_synchronization() # 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('
%s
' % self.recurrent_event_values["description"]), 'contentType': "html", }, 'start': { 'dateTime': self.start_date.strftime("%Y-%m-%dT%H:%M:%S+00:00"), 'timeZone': 'Europe/London' }, 'end': { 'dateTime': self.end_date.strftime("%Y-%m-%dT%H:%M:%S+00:00"), 'timeZone': 'Europe/London' }, 'isAllDay': False, 'isOrganizer': True, 'isReminderOn': False, 'reminderMinutesBeforeStart': 0, 'sensitivity': 'normal', 'showAs': 'busy', 'type': 'seriesMaster', "attendees": [ { 'emailAddress': { 'address': self.attendee_user.email, 'name': self.attendee_user.display_name }, 'status': {'response': "notresponded"} } ], 'location': {'displayName': ''}, 'organizer': { 'emailAddress': { 'address': self.organizer_user.email, 'name': self.organizer_user.display_name, }, }, 'recurrence': { 'pattern': {'dayOfMonth': 22, 'interval': self.recurrent_event_interval, 'type': 'daily'}, 'range': { 'numberOfOccurrences': self.recurrent_events_count, 'startDate': self.start_date.strftime("%Y-%m-%d"), 'type': 'numbered' }, }, } # ----------------------------------------------------------------------------------------- # Events coming from Outlook (so from the API) # ----------------------------------------------------------------------------------------- self.simple_event_from_outlook_organizer = { 'type': 'singleInstance', 'seriesMasterId': None, 'id': '123', 'iCalUId': '456', 'subject': 'simple_event', 'body': { 'content': "my simple event", 'contentType': "text", }, 'start': {'dateTime': self.start_date.strftime("%Y-%m-%dT%H:%M:%S.0000000"), 'timeZone': 'UTC'}, 'end': {'dateTime': self.end_date.strftime("%Y-%m-%dT%H:%M:%S.0000000"), 'timeZone': 'UTC'}, 'attendees': [{ 'type': 'required', 'status': {'response': 'none', 'time': '0001-01-01T00:00:00Z'}, 'emailAddress': {'name': self.attendee_user.display_name, 'address': self.attendee_user.email} }], 'isAllDay': False, 'isCancelled': False, 'sensitivity': 'normal', 'showAs': 'busy', 'isOnlineMeeting': False, 'onlineMeetingUrl': None, 'isOrganizer': True, 'isReminderOn': True, 'location': {'displayName': ''}, 'organizer': { 'emailAddress': {'address': self.organizer_user.email, 'name': self.organizer_user.display_name}, }, 'reminderMinutesBeforeStart': 15, 'responseRequested': True, 'responseStatus': { 'response': 'organizer', 'time': '0001-01-01T00:00:00Z', }, } self.simple_event_from_outlook_attendee = self.simple_event_from_outlook_organizer self.simple_event_from_outlook_attendee.update(isOrganizer=False) # ----------------------------------------------------------------------------------------- # Expected values for Outlook events converted to Odoo events # ----------------------------------------------------------------------------------------- self.expected_odoo_event_from_outlook = { "name": "simple_event", "description": Markup('my simple event
'), "active": True, "start": self.start_date, "stop": self.end_date, "user_id": self.organizer_user, "microsoft_id": combine_ids("123", "456"), "partner_ids": [self.organizer_user.partner_id.id, self.attendee_user.partner_id.id], } self.expected_odoo_recurrency_from_outlook = { 'active': True, 'byday': '1', 'count': 0, 'day': 0, 'display_name': "Every %s Days until %s" % ( self.recurrent_event_interval, self.recurrence_end_date.strftime("%Y-%m-%d") ), 'dtstart': self.start_date, 'end_type': 'end_date', 'event_tz': False, 'fri': False, 'interval': self.recurrent_event_interval, 'month_by': 'date', 'microsoft_id': combine_ids('REC123', 'REC456'), 'name': "Every %s Days until %s" % ( self.recurrent_event_interval, self.recurrence_end_date.strftime("%Y-%m-%d") ), 'need_sync_m': False, 'rrule': 'DTSTART:%s\nRRULE:FREQ=DAILY;INTERVAL=%s;UNTIL=%s' % ( self.start_date.strftime("%Y%m%dT%H%M%S"), self.recurrent_event_interval, self.recurrence_end_date.strftime("%Y%m%dT235959"), ), 'rrule_type': 'daily', 'until': self.recurrence_end_date.date(), 'weekday': False, } self.recurrent_event_from_outlook_organizer = [{ 'attendees': [{ 'emailAddress': {'address': self.attendee_user.email, 'name': self.attendee_user.display_name}, 'status': {'response': 'none', 'time': '0001-01-01T00:00:00Z'}, 'type': 'required' }], 'body': { 'content': "my recurrent event", 'contentType': "text", }, 'start': {'dateTime': self.start_date.strftime("%Y-%m-%dT%H:%M:%S.0000000"), 'timeZone': 'UTC'}, 'end': {'dateTime': self.end_date.strftime("%Y-%m-%dT%H:%M:%S.0000000"), 'timeZone': 'UTC'}, 'id': 'REC123', 'iCalUId': 'REC456', 'isAllDay': False, 'isCancelled': False, 'isOnlineMeeting': False, 'isOrganizer': True, 'isReminderOn': True, 'location': {'displayName': ''}, 'organizer': {'emailAddress': { 'address': self.organizer_user.email, 'name': self.organizer_user.display_name} }, 'recurrence': { 'pattern': { 'dayOfMonth': 0, 'firstDayOfWeek': 'sunday', 'index': 'first', 'interval': self.recurrent_event_interval, 'month': 0, 'type': 'daily' }, 'range': { 'startDate': self.start_date.strftime("%Y-%m-%d"), 'endDate': self.recurrence_end_date.strftime("%Y-%m-%d"), 'numberOfOccurrences': 0, 'recurrenceTimeZone': 'Romance Standard Time', 'type': 'endDate' } }, 'reminderMinutesBeforeStart': 15, 'responseRequested': True, 'responseStatus': {'response': 'organizer', 'time': '0001-01-01T00:00:00Z'}, 'sensitivity': 'normal', 'seriesMasterId': None, 'showAs': 'busy', 'subject': "recurrent event", 'type': 'seriesMaster', }] self.recurrent_event_from_outlook_organizer += [ { 'attendees': [{ 'emailAddress': {'address': self.attendee_user.email, 'name': self.attendee_user.display_name}, 'status': {'response': 'none', 'time': '0001-01-01T00:00:00Z'}, 'type': 'required' }], 'body': { 'content': "my recurrent event", 'contentType': "text", }, 'start': { 'dateTime': ( self.start_date + timedelta(days=i * self.recurrent_event_interval) ).strftime("%Y-%m-%dT%H:%M:%S.0000000"), 'timeZone': 'UTC' }, 'end': { 'dateTime': ( self.end_date + timedelta(days=i * self.recurrent_event_interval) ).strftime("%Y-%m-%dT%H:%M:%S.0000000"), 'timeZone': 'UTC' }, 'id': f'REC123_EVENT_{i+1}', 'iCalUId': f'REC456_EVENT_{i+1}', 'seriesMasterId': 'REC123', 'isAllDay': False, 'isCancelled': False, 'isOnlineMeeting': False, 'isOrganizer': True, 'isReminderOn': True, 'location': {'displayName': ''}, 'organizer': { 'emailAddress': {'address': self.organizer_user.email, 'name': self.organizer_user.display_name} }, 'recurrence': None, 'reminderMinutesBeforeStart': 15, 'responseRequested': True, 'responseStatus': {'response': 'organizer', 'time': '0001-01-01T00:00:00Z'}, 'sensitivity': 'normal', 'showAs': 'busy', 'subject': "recurrent event", 'type': 'occurrence', } for i in range(self.recurrent_events_count) ] self.recurrent_event_from_outlook_attendee = [ dict( d, isOrganizer=False, attendees=[ { 'emailAddress': {'address': self.organizer_user.email, 'name': self.organizer_user.display_name}, 'status': {'response': 'none', 'time': '0001-01-01T00:00:00Z'}, 'type': 'required' }, { 'emailAddress': {'address': self.attendee_user.email, 'name': self.attendee_user.display_name}, 'status': {'response': 'none', 'time': '0001-01-01T00:00:00Z'}, 'type': 'required' }, ] ) for d in self.recurrent_event_from_outlook_organizer ] self.expected_odoo_recurrency_events_from_outlook = [ { "name": "recurrent event", "user_id": self.organizer_user, "partner_ids": [self.organizer_user.partner_id.id, self.attendee_user.partner_id.id], "start": self.start_date + timedelta(days=i * self.recurrent_event_interval), "stop": self.end_date + timedelta(days=i * self.recurrent_event_interval), "until": self.recurrence_end_date.date(), "microsoft_recurrence_master_id": "REC123", 'microsoft_id': combine_ids(f"REC123_EVENT_{i+1}", f"REC456_EVENT_{i+1}"), "recurrency": True, "follow_recurrence": True, "active": True, } for i in range(self.recurrent_events_count) ] self.env.cr.postcommit.clear() def 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()