diff options
44 files changed, 2257 insertions, 44 deletions
diff --git a/addons/base_setup/models/res_config_settings.py b/addons/base_setup/models/res_config_settings.py index 2248f46bfb1..04b695c141f 100644 --- a/addons/base_setup/models/res_config_settings.py +++ b/addons/base_setup/models/res_config_settings.py @@ -19,6 +19,8 @@ class ResConfigSettings(models.TransientModel): module_base_import = fields.Boolean("Allow users to import data from CSV/XLS/XLSX/ODS files") module_google_calendar = fields.Boolean( string='Allow the users to synchronize their calendar with Google Calendar') + module_microsoft_calendar = fields.Boolean( + string='Allow the users to synchronize their calendar with Office 365 Calendar') module_google_drive = fields.Boolean("Attach Google documents to any record") module_google_spreadsheet = fields.Boolean("Google Spreadsheet") module_auth_oauth = fields.Boolean("Use external authentication providers (OAuth)") diff --git a/addons/base_setup/views/res_config_settings_views.xml b/addons/base_setup/views/res_config_settings_views.xml index 8bfd02bae52..bda9ab28a96 100644 --- a/addons/base_setup/views/res_config_settings_views.xml +++ b/addons/base_setup/views/res_config_settings_views.xml @@ -243,6 +243,20 @@ </div> <div class="col-12 col-lg-6 o_setting_box" id="sync_google_calendar_setting"> <div class="o_setting_left_pane"> + <field name="module_microsoft_calendar" /> + </div> + <div class="o_setting_right_pane"> + <label string="Office 365 Calendar" for="module_microsoft_calendar"/> + <div class="text-muted"> + Synchronize your calendar with Outlook + </div> + <div class="content-group" attrs="{'invisible': [('module_microsoft_calendar', '=', False)]}" id="msg_module_microsoft_calendar"> + <div class="text-warning mt16"><strong>Save</strong> this page and come back here to set up the feature.</div> + </div> + </div> + </div> + <div class="col-12 col-lg-6 o_setting_box" id="sync_google_calendar_setting"> + <div class="o_setting_left_pane"> <field name="module_google_calendar" /> </div> <div class="o_setting_right_pane"> diff --git a/addons/calendar/models/calendar_event.py b/addons/calendar/models/calendar_event.py index e23561ffa0b..874c09c99f0 100644 --- a/addons/calendar/models/calendar_event.py +++ b/addons/calendar/models/calendar_event.py @@ -244,6 +244,7 @@ class Meeting(models.Model): recurrency = fields.Boolean('Recurrent', help="Recurrent Event") recurrence_id = fields.Many2one( 'calendar.recurrence', string="Recurrence Rule", index=True) + follow_recurrence = fields.Boolean(default=False) # Indicates if an event follows the recurrence, i.e. is not an exception recurrence_update = fields.Selection([ ('self_only', "This event"), ('future_events', "This and following events"), @@ -559,7 +560,7 @@ class Meeting(models.Model): recurrence_vals += [dict(values, base_event_id=event.id, calendar_event_ids=[(4, event.id)])] elif future: to_update |= event.recurrence_id._split_from(event, values) - self.recurrency = True + self.write({'recurrency': True, 'follow_recurrence': True}) to_update |= self.env['calendar.recurrence'].create(recurrence_vals) return to_update._apply_recurrence() @@ -630,6 +631,10 @@ class Meeting(models.Model): if 'partner_ids' in values: values['attendee_ids'] = self._attendees_values(values['partner_ids']) + if (not recurrence_update_setting or recurrence_update_setting == 'self_only' and len(self) == 1) and 'follow_recurrence' not in values: + if any({field: values.get(field) for field in self.env['calendar.event']._get_time_fields() if field in values}): + values['follow_recurrence'] = False + previous_attendees = self.attendee_ids recurrence_values = {field: values.pop(field) for field in self._get_recurrent_fields() if field in values} @@ -702,9 +707,11 @@ class Meeting(models.Model): recurring_vals = [vals for vals in vals_list if vals.get('recurrency')] other_vals = [vals for vals in vals_list if not vals.get('recurrency')] events = super().create(other_vals) + for vals in recurring_vals: recurrence_values = {field: vals.pop(field) for field in recurrence_fields if field in vals} + vals['follow_recurrence'] = True event = super().create(vals) events |= event if vals.get('recurrency'): diff --git a/addons/calendar/models/calendar_recurrence.py b/addons/calendar/models/calendar_recurrence.py index 18c4a20b6f0..6c5edfe839c 100644 --- a/addons/calendar/models/calendar_recurrence.py +++ b/addons/calendar/models/calendar_recurrence.py @@ -71,6 +71,7 @@ BYDAY_SELECTION = [ ('1', 'First'), ('2', 'Second'), ('3', 'Third'), + ('4', 'Fourth'), ('-1', 'Last'), ] @@ -184,23 +185,35 @@ class RecurrenceRule(models.Model): ranges_to_create = (event_range for event_range in ranges if event_range not in existing_ranges) return synced_events, ranges_to_create - def _apply_recurrence(self): + def _apply_recurrence(self, specific_values_creation=None, no_send_edit=False): """Create missing events in the recurrence and detach events which no longer follow the recurrence rules. :return: detached events """ event_vals = [] keep = self.env['calendar.event'] + if specific_values_creation is None: + specific_values_creation = {} + for recurrence in self.filtered('base_event_id'): self.calendar_event_ids |= recurrence.base_event_id event = recurrence.base_event_id or recurrence._get_first_event(include_outliers=False) duration = event.stop - event.start - ranges = set(recurrence._get_ranges(event.start, duration)) + if specific_values_creation: + ranges = set([(x[1], x[2]) for x in specific_values_creation if x[0] == recurrence.id]) + else: + ranges = set(recurrence._get_ranges(event.start, duration)) events_to_keep, ranges = recurrence._reconcile_events(ranges) keep |= events_to_keep [base_values] = event.copy_data() - event_vals += [dict(base_values, start=start, stop=stop, recurrence_id=recurrence.id) for start, stop in ranges] + values = [] + for start, stop in ranges: + value = dict(base_values, start=start, stop=stop, recurrence_id=recurrence.id, follow_recurrence=True) + if (recurrence.id, start, stop) in specific_values_creation: + value.update(specific_values_creation[(recurrence.id, start, stop)]) + values += [value] + event_vals += values events = self.calendar_event_ids - keep detached_events = self._detach_events(events) diff --git a/addons/google_calendar/__manifest__.py b/addons/google_calendar/__manifest__.py index c84f9c57f53..a743f7528a2 100644 --- a/addons/google_calendar/__manifest__.py +++ b/addons/google_calendar/__manifest__.py @@ -11,7 +11,6 @@ 'data': [ 'data/google_calendar_data.xml', 'security/ir.model.access.csv', - 'views/calendar_event_views.xml', 'wizard/reset_account_views.xml', 'views/res_config_settings_views.xml', 'views/res_users_views.xml', diff --git a/addons/google_calendar/models/calendar_recurrence_rule.py b/addons/google_calendar/models/calendar_recurrence_rule.py index a8e33093a64..b3cd06db329 100644 --- a/addons/google_calendar/models/calendar_recurrence_rule.py +++ b/addons/google_calendar/models/calendar_recurrence_rule.py @@ -16,9 +16,9 @@ class RecurrenceRule(models.Model): # Don't sync by default. Sync only when the recurrence is applied need_sync = fields.Boolean(default=False) - def _apply_recurrence(self): + def _apply_recurrence(self, specific_values_creation=None, no_send_edit=False): events = self.calendar_event_ids - detached_events = super()._apply_recurrence() + detached_events = super()._apply_recurrence(specific_values_creation, no_send_edit) google_service = GoogleCalendarService(self.env['google.service']) diff --git a/addons/google_calendar/static/src/js/google_calendar.js b/addons/google_calendar/static/src/js/google_calendar.js index 4ba739e67ea..b19bafea384 100644 --- a/addons/google_calendar/static/src/js/google_calendar.js +++ b/addons/google_calendar/static/src/js/google_calendar.js @@ -12,7 +12,7 @@ const viewRegistry = require('web.view_registry'); var _t = core._t; -const GoogleCalendarModel = CalendarModel.extend({ +const GoogleCalendarModel = CalendarModel.include({ /** * @override @@ -23,7 +23,7 @@ const GoogleCalendarModel = CalendarModel.extend({ try { await Promise.race([ new Promise(resolve => setTimeout(resolve, 1000)), - this._syncCalendar(true) + this._syncGoogleCalendar(true) ]); } catch (error) { if (error.event) { @@ -34,7 +34,7 @@ const GoogleCalendarModel = CalendarModel.extend({ return _super(...arguments); }, - _syncCalendar(shadow = false) { + _syncGoogleCalendar(shadow = false) { var context = this.getSession().user_context; return this._rpc({ route: '/google_calendar/sync_data', @@ -47,9 +47,9 @@ const GoogleCalendarModel = CalendarModel.extend({ }, }) -const GoogleCalendarController = CalendarController.extend({ +const GoogleCalendarController = CalendarController.include({ custom_events: _.extend({}, CalendarController.prototype.custom_events, { - syncCalendar: '_onSyncCalendar', + syncGoogleCalendar: '_onGoogleSyncCalendar', }), @@ -65,10 +65,10 @@ const GoogleCalendarController = CalendarController.extend({ * @private * @returns {OdooEvent} event */ - _onSyncCalendar: function (event) { + _onGoogleSyncCalendar: function (event) { var self = this; - return this.model._syncCalendar().then(function (o) { + return this.model._syncGoogleCalendar().then(function (o) { if (o.status === "need_auth") { Dialog.alert(self, _t("You will be redirected to Google to authorize access to your calendar!"), { confirm_callback: function() { @@ -96,9 +96,9 @@ const GoogleCalendarController = CalendarController.extend({ } }); -const GoogleCalendarRenderer = CalendarRenderer.extend({ +const GoogleCalendarRenderer = CalendarRenderer.include({ events: _.extend({}, CalendarRenderer.prototype.events, { - 'click .o_google_sync_button': '_onSyncCalendar', + 'click .o_google_sync_button': '_onGoogleSyncCalendar', }), //-------------------------------------------------------------------------- @@ -133,11 +133,11 @@ const GoogleCalendarRenderer = CalendarRenderer.extend({ * * @private */ - _onSyncCalendar: function () { + _onGoogleSyncCalendar: function () { var self = this; var context = this.getSession().user_context; this.$googleButton.prop('disabled', true); - this.trigger_up('syncCalendar', { + this.trigger_up('syncGoogleCalendar', { on_always: function () { self.$googleButton.prop('disabled', false); }, @@ -145,18 +145,7 @@ const GoogleCalendarRenderer = CalendarRenderer.extend({ }, }); -var GoogleCalendarView = CalendarView.extend({ - config: _.extend({}, CalendarView.prototype.config, { - Controller: GoogleCalendarController, - Model: GoogleCalendarModel, - Renderer: GoogleCalendarRenderer, - }), -}); - -viewRegistry.add('google_sync_calendar', GoogleCalendarView); - return { - GoogleCalendarView, GoogleCalendarController, GoogleCalendarModel, GoogleCalendarRenderer, diff --git a/addons/google_calendar/static/tests/google_calendar_tests.js b/addons/google_calendar/static/tests/google_calendar_tests.js index 6ab0f8dacab..5ec1e475af5 100644 --- a/addons/google_calendar/static/tests/google_calendar_tests.js +++ b/addons/google_calendar/static/tests/google_calendar_tests.js @@ -1,7 +1,7 @@ odoo.define('google_calendar.calendar_tests', function (require) { "use strict"; -var { GoogleCalendarView } = require('google_calendar.CalendarView'); +var GoogleCalendarView = require('calendar.CalendarView'); var testUtils = require('web.test_utils'); var createCalendarView = testUtils.createCalendarView; @@ -78,7 +78,7 @@ QUnit.module('Google Calendar', { data: this.data, arch: '<calendar class="o_calendar_test" '+ - 'js_class="google_sync_calendar" '+ + 'js_class="attendee_calendar" '+ 'date_start="start" '+ 'date_stop="stop" '+ 'mode="month">'+ @@ -96,6 +96,8 @@ QUnit.module('Google Calendar', { return Promise.resolve({status: 'need_refresh'}); } else if (route === '/web/dataset/call_kw/calendar.event/search_read') { assert.step(route); + } else if (route === '/microsoft_calendar/sync_data') { + return Promise.resolve(); } return this._super.apply(this, arguments); }, diff --git a/addons/google_calendar/views/calendar_event_views.xml b/addons/google_calendar/views/calendar_event_views.xml deleted file mode 100644 index 0f787832525..00000000000 --- a/addons/google_calendar/views/calendar_event_views.xml +++ /dev/null @@ -1,13 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<odoo> - <record id="calendar_event_view_calendar" model="ir.ui.view"> - <field name="name">calendar.event.view.calendar.inherit</field> - <field name="model">calendar.event</field> - <field name="inherit_id" ref="calendar.view_calendar_event_calendar"/> - <field name="arch" type="xml"> - <xpath expr="//calendar" position="attributes"> - <attribute name="js_class">google_sync_calendar</attribute> - </xpath> - </field> - </record> -</odoo>
\ No newline at end of file diff --git a/addons/microsoft_account/__init__.py b/addons/microsoft_account/__init__.py new file mode 100644 index 00000000000..8c67f893b37 --- /dev/null +++ b/addons/microsoft_account/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models +from . import controllers diff --git a/addons/microsoft_account/__manifest__.py b/addons/microsoft_account/__manifest__.py new file mode 100644 index 00000000000..fcecd91b85c --- /dev/null +++ b/addons/microsoft_account/__manifest__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Microsoft Users', + 'category': 'Hidden/Tools', + 'description': """ +The module adds Microsoft user in res user. +=========================================== +""", + 'depends': ['base_setup'], + 'data': [ + 'data/microsoft_account_data.xml', + ], +} diff --git a/addons/microsoft_account/controllers/__init__.py b/addons/microsoft_account/controllers/__init__.py new file mode 100644 index 00000000000..5d4b25db9c0 --- /dev/null +++ b/addons/microsoft_account/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import main diff --git a/addons/microsoft_account/controllers/main.py b/addons/microsoft_account/controllers/main.py new file mode 100644 index 00000000000..5256f82dca7 --- /dev/null +++ b/addons/microsoft_account/controllers/main.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json +from werkzeug.utils import redirect + +from odoo import http, registry +from odoo.http import request + + +class MicrosoftAuth(http.Controller): + + @http.route('/microsoft_account/authentication', type='http', auth="public") + def oauth2callback(self, **kw): + """ This route/function is called by Microsoft when user Accept/Refuse the consent of Microsoft """ + state = json.loads(kw['state']) + dbname = state.get('d') + service = state.get('s') + url_return = state.get('f') + + with registry(dbname).cursor() as cr: + if kw.get('code'): + access_token, refresh_token, ttl = request.env['microsoft.service']._get_microsoft_tokens(kw['code'], service) + request.env.user._set_microsoft_auth_tokens(access_token, refresh_token, ttl) + return redirect(url_return) + elif kw.get('error'): + return redirect("%s%s%s" % (url_return, "?error=", kw['error'])) + else: + return redirect("%s%s" % (url_return, "?error=Unknown_error")) diff --git a/addons/microsoft_account/data/microsoft_account_data.xml b/addons/microsoft_account/data/microsoft_account_data.xml new file mode 100644 index 00000000000..da61e3da8ed --- /dev/null +++ b/addons/microsoft_account/data/microsoft_account_data.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data noupdate="1"> + <record id="config_microsoft_redirect_uri" model="ir.config_parameter"> + <field name="key">microsoft_redirect_uri</field> + <field name="value">urn:ietf:wg:oauth:2.0:oob</field> + </record> + </data> +</odoo> diff --git a/addons/microsoft_account/models/__init__.py b/addons/microsoft_account/models/__init__.py new file mode 100644 index 00000000000..efaff2dc4e7 --- /dev/null +++ b/addons/microsoft_account/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import res_users +from . import microsoft_service diff --git a/addons/microsoft_account/models/microsoft_service.py b/addons/microsoft_account/models/microsoft_service.py new file mode 100644 index 00000000000..6b6de96aefb --- /dev/null +++ b/addons/microsoft_account/models/microsoft_service.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime +import json +import logging + +import requests +from werkzeug import urls + +from odoo import api, fields, models, _ + +_logger = logging.getLogger(__name__) + +TIMEOUT = 20 + +MICROSOFT_AUTH_ENDPOINT = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize' +MICROSOFT_TOKEN_ENDPOINT = 'https://login.microsoftonline.com/common/oauth2/v2.0/token' + + +class MicrosoftService(models.AbstractModel): + _name = 'microsoft.service' + _description = 'Microsoft Service' + + def _get_calendar_scope(self): + return 'offline_access openid Calendars.ReadWrite' + + @api.model + def generate_refresh_token(self, service, authorization_code): + """ Call Microsoft API to refresh the token, with the given authorization code + :param service : the name of the microsoft service to actualize + :param authorization_code : the code to exchange against the new refresh token + :returns the new refresh token + """ + Parameters = self.env['ir.config_parameter'].sudo() + client_id = Parameters.get_param('microsoft_%s_client_id' % service) + client_secret = Parameters.get_param('microsoft_%s_client_secret' % service) + redirect_uri = Parameters.get_param('microsoft_redirect_uri') + + scope = self._get_calendar_scope() + + # Get the Refresh Token From Microsoft And store it in ir.config_parameter + headers = {"Content-type": "application/x-www-form-urlencoded"} + data = { + 'client_id': client_id, + 'redirect_uri': redirect_uri, + 'client_secret': client_secret, + 'scope': scope, + 'grant_type': "refresh_token" + } + try: + req = requests.post(MICROSOFT_TOKEN_ENDPOINT, data=data, headers=headers, timeout=TIMEOUT) + req.raise_for_status() + content = req.json() + except IOError: + error_msg = _("Something went wrong during your token generation. Maybe your Authorization Code is invalid or already expired") + raise self.env['res.config.settings'].get_config_warning(error_msg) + + return content.get('refresh_token') + + @api.model + def _get_authorize_uri(self, from_url, service, scope): + """ This method return the url needed to allow this instance of Odoo to access to the scope + of gmail specified as parameters + """ + state = { + 'd': self.env.cr.dbname, + 's': service, + 'f': from_url + } + + get_param = self.env['ir.config_parameter'].sudo().get_param + base_url = get_param('web.base.url', default='http://www.odoo.com?NoBaseUrl') + client_id = get_param('microsoft_%s_client_id' % (service,), default=False) + + encoded_params = urls.url_encode({ + 'response_type': 'code', + 'client_id': client_id, + 'state': json.dumps(state), + 'scope': scope, + 'redirect_uri': base_url + '/microsoft_account/authentication', + 'prompt': 'consent', + 'access_type': 'offline' + }) + return "%s?%s" % (MICROSOFT_AUTH_ENDPOINT, encoded_params) + + @api.model + def _get_microsoft_tokens(self, authorize_code, service): + """ Call Microsoft API to exchange authorization code against token, with POST request, to + not be redirected. + """ + get_param = self.env['ir.config_parameter'].sudo().get_param + base_url = get_param('web.base.url', default='http://www.odoo.com?NoBaseUrl') + client_id = get_param('microsoft_%s_client_id' % (service,), default=False) + client_secret = get_param('microsoft_%s_client_secret' % (service,), default=False) + scope = self._get_calendar_scope() + + headers = {"content-type": "application/x-www-form-urlencoded"} + data = { + 'code': authorize_code, + 'client_id': client_id, + 'client_secret': client_secret, + 'grant_type': 'authorization_code', + 'scope': scope, + 'redirect_uri': base_url + '/microsoft_account/authentication' + } + try: + dummy, response, dummy = self._do_request(MICROSOFT_TOKEN_ENDPOINT, params=data, headers=headers, method='POST', preuri='') + access_token = response.get('access_token') + refresh_token = response.get('refresh_token') + ttl = response.get('expires_in') + return access_token, refresh_token, ttl + except requests.HTTPError: + error_msg = _("Something went wrong during your token generation. Maybe your Authorization Code is invalid") + raise self.env['res.config.settings'].get_config_warning(error_msg) + + @api.model + def _do_request(self, uri, params=None, headers=None, method='POST', preuri="https://graph.microsoft.com", timeout=TIMEOUT): + """ Execute the request to Microsoft API. Return a tuple ('HTTP_CODE', 'HTTP_RESPONSE') + :param uri : the url to contact + :param params : dict or already encoded parameters for the request to make + :param headers : headers of request + :param method : the method to use to make the request + :param preuri : pre url to prepend to param uri. + """ + if params is None: + params = {} + if headers is None: + headers = {} + + _logger.debug("Uri: %s - Type : %s - Headers: %s - Params : %s !" % (uri, method, headers, params)) + + ask_time = fields.Datetime.now() + try: + if method.upper() in ('GET', 'DELETE'): + res = requests.request(method.lower(), preuri + uri, headers=headers, params=params, timeout=timeout) + elif method.upper() in ('POST', 'PATCH', 'PUT'): + res = requests.request(method.lower(), preuri + uri, data=params, headers=headers, timeout=timeout) + else: + raise Exception(_('Method not supported [%s] not in [GET, POST, PUT, PATCH or DELETE]!', method)) + res.raise_for_status() + status = res.status_code + + if int(status) in (204, 404): # Page not found, no response + response = False + else: + response = res.json() + + try: + ask_time = datetime.strptime(res.headers.get('date'), "%a, %d %b %Y %H:%M:%S %Z") + except: + pass + except requests.HTTPError as error: + if error.response.status_code in (204, 404): + status = error.response.status_code + response = "" + else: + _logger.exception("Bad microsoft request : %s !", error.response.content) + raise error + return (status, response, ask_time) diff --git a/addons/microsoft_account/models/res_users.py b/addons/microsoft_account/models/res_users.py new file mode 100644 index 00000000000..d6587ffeda9 --- /dev/null +++ b/addons/microsoft_account/models/res_users.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import timedelta + + +from odoo import api, fields, models, _ + + +class User(models.Model): + _inherit = 'res.users' + + microsoft_calendar_rtoken = fields.Char('Microsoft Refresh Token', copy=False, groups="base.group_system") + microsoft_calendar_token = fields.Char('Microsoft User token', copy=False, groups="base.group_system") + microsoft_calendar_token_validity = fields.Datetime('Microsoft Token Validity', copy=False) + + def _set_microsoft_auth_tokens(self, access_token, refresh_token, ttl): + self.write({ + 'microsoft_calendar_rtoken': refresh_token, + 'microsoft_calendar_token': access_token, + 'microsoft_calendar_token_validity': fields.Datetime.now() + timedelta(seconds=ttl) if ttl else False, + }) diff --git a/addons/microsoft_calendar/__init__.py b/addons/microsoft_calendar/__init__.py new file mode 100644 index 00000000000..ef909eb6a61 --- /dev/null +++ b/addons/microsoft_calendar/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controllers +from . import models +from . import utils +from . import wizard + +from odoo import api, SUPERUSER_ID +import uuid + + +def init_initiating_microsoft_uuid(cr, registry): + """ Sets the company name as the default value for the initiating + party name on all existing companies once the module is installed. """ + env = api.Environment(cr, SUPERUSER_ID, {}) + config_parameter = env['ir.config_parameter'].sudo() + microsoft_guid = config_parameter.get_param('microsoft_calendar.microsoft_guid', False) + if not microsoft_guid: + config_parameter.set_param('microsoft_calendar.microsoft_guid', str(uuid.uuid4())) diff --git a/addons/microsoft_calendar/__manifest__.py b/addons/microsoft_calendar/__manifest__.py new file mode 100644 index 00000000000..763a1088803 --- /dev/null +++ b/addons/microsoft_calendar/__manifest__.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Outlook Calendar', + 'version': '1.0', + 'category': 'Productivity', + 'description': "", + 'depends': ['microsoft_account', 'calendar'], + 'qweb': ['static/src/xml/*.xml'], + 'data': [ + 'security/ir.model.access.csv', + 'wizard/reset_account_views.xml', + 'views/res_config_settings_views.xml', + 'views/res_users_views.xml', + 'views/microsoft_calendar_templates.xml', + ], + 'demo': [], + 'installable': True, + 'auto_install': False, + 'post_init_hook': 'init_initiating_microsoft_uuid', +} diff --git a/addons/microsoft_calendar/controllers/__init__.py b/addons/microsoft_calendar/controllers/__init__.py new file mode 100644 index 00000000000..5d4b25db9c0 --- /dev/null +++ b/addons/microsoft_calendar/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import main diff --git a/addons/microsoft_calendar/controllers/main.py b/addons/microsoft_calendar/controllers/main.py new file mode 100644 index 00000000000..5e10e6a254e --- /dev/null +++ b/addons/microsoft_calendar/controllers/main.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import http +from odoo.http import request +from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService + + +class MicrosoftCalendarController(http.Controller): + + @http.route('/microsoft_calendar/sync_data', type='json', auth='user') + def sync_data(self, model, **kw): + """ This route/function is called when we want to synchronize Odoo + calendar with Microsoft Calendar. + Function return a dictionary with the status : need_config_from_admin, need_auth, + need_refresh, success if not calendar_event + The dictionary may contains an url, to allow Odoo Client to redirect user on + this URL for authorization for example + """ + if model == 'calendar.event': + MicrosoftCal = MicrosoftCalendarService(request.env['microsoft.service']) + + # Checking that admin have already configured Microsoft API for microsoft synchronization ! + client_id = request.env['ir.config_parameter'].sudo().get_param('microsoft_calendar_client_id') + + if not client_id or client_id == '': + action_id = '' + if MicrosoftCal._can_authorize_microsoft(request.env.user): + action_id = request.env.ref('base_setup.action_general_configuration').id + return { + "status": "need_config_from_admin", + "url": '', + "action": action_id + } + + # Checking that user have already accepted Odoo to access his calendar ! + if not MicrosoftCal.is_authorized(request.env.user): + url = MicrosoftCal._microsoft_authentication_url(from_url=kw.get('fromurl')) + return { + "status": "need_auth", + "url": url + } + # If App authorized, and user access accepted, We launch the synchronization + need_refresh = request.env.user.sudo()._sync_microsoft_calendar(MicrosoftCal) + return { + "status": "need_refresh" if need_refresh else "no_new_event_from_microsoft", + "url": '' + } + + return {"status": "success"} diff --git a/addons/microsoft_calendar/models/__init__.py b/addons/microsoft_calendar/models/__init__.py new file mode 100644 index 00000000000..8d933a1a60b --- /dev/null +++ b/addons/microsoft_calendar/models/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import res_config_settings +from . import microsoft_sync +from . import calendar +from . import calendar_recurrence_rule +from . import res_users diff --git a/addons/microsoft_calendar/models/calendar.py b/addons/microsoft_calendar/models/calendar.py new file mode 100644 index 00000000000..51e7e720cba --- /dev/null +++ b/addons/microsoft_calendar/models/calendar.py @@ -0,0 +1,386 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import pytz +from dateutil.parser import parse +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + +ATTENDEE_CONVERTER_O2M = { + 'needsAction': 'notresponded', + 'tentative': 'tentativelyaccepted', + 'declined': 'declined', + 'accepted': 'accepted' +} +ATTENDEE_CONVERTER_M2O = { + 'notResponded': 'needsAction', + 'tentativelyAccepted': 'tentative', + 'declined': 'declined', + 'accepted': 'accepted', + 'organizer': 'accepted', +} +MAX_RECURRENT_EVENT = 720 + +class Meeting(models.Model): + _name = 'calendar.event' + _inherit = ['calendar.event', 'microsoft.calendar.sync'] + + microsoft_id = fields.Char('Microsoft Calendar Event Id') + microsoft_recurrence_master_id = fields.Char('Microsoft Recurrence Master Id') + + @api.model + def _get_microsoft_synced_fields(self): + return {'name', 'description', 'allday', 'start', 'date_end', 'stop', + 'user_id', 'privacy', + 'attendee_ids', 'alarm_ids', 'location', 'show_as', 'active'} + + @api.model_create_multi + def create(self, vals_list): + return super().create([ + dict(vals, need_sync_m=False) if vals.get('recurrency') else vals + for vals in vals_list + ]) + + def write(self, values): + recurrence_update_setting = values.get('recurrence_update') + if recurrence_update_setting in ('all_events', 'future_events') and len(self) == 1: + values = dict(values, need_sync_m=False) + elif recurrence_update_setting == 'self_only' and 'start' in values: + previous_event_before_write = self.recurrence_id.calendar_event_ids.filtered(lambda e: e.start.date() < self.start.date() and e != self) + new_start = parse(values['start']).date() + previous_event_after_write = self.recurrence_id.calendar_event_ids.filtered(lambda e: e.start.date() < new_start and e != self) + if previous_event_before_write != previous_event_after_write: + # Outlook returns a 400 error if you try to synchronize an occurrence of this type. + raise UserError(_("Modified occurrence is crossing or overlapping adjacent occurrence.")) + return super().write(values) + + def _get_microsoft_sync_domain(self): + return [('partner_ids.user_ids', 'in', self.env.user.id)] + + @api.model + def _microsoft_to_odoo_values(self, microsoft_event, default_reminders=(), default_values={}): + if microsoft_event.is_cancelled(): + return {'active': False} + + sensitivity_o2m = { + 'normal': 'public', + 'private': 'private', + 'confidential': 'confidential', + } + + commands_attendee, commands_partner = self._odoo_attendee_commands_m(microsoft_event) + timeZone_start = pytz.timezone(microsoft_event.start.get('timeZone')) + timeZone_stop = pytz.timezone(microsoft_event.end.get('timeZone')) + start = parse(microsoft_event.start.get('dateTime')).astimezone(timeZone_start).replace(tzinfo=None) + if microsoft_event.isAllDay: + stop = parse(microsoft_event.end.get('dateTime')).astimezone(timeZone_stop).replace(tzinfo=None) - relativedelta(days=1) + else: + stop = parse(microsoft_event.end.get('dateTime')).astimezone(timeZone_stop).replace(tzinfo=None) + values = { + **default_values, + 'name': microsoft_event.subject or _("(No title)"), + 'description': microsoft_event.bodyPreview, + 'location': microsoft_event.location and microsoft_event.location.get('displayName') or False, + 'user_id': microsoft_event.owner(self.env).id, + 'privacy': sensitivity_o2m.get(microsoft_event.sensitivity, self.default_get(['privacy'])['privacy']), + 'attendee_ids': commands_attendee, + 'partner_ids': commands_partner, + 'allday': microsoft_event.isAllDay, + 'start': start, + 'stop': stop, + 'show_as': 'free' if microsoft_event.showAs == 'free' else 'busy', + 'recurrency': microsoft_event.is_recurrent() + } + + values['microsoft_id'] = microsoft_event.id + if microsoft_event.is_recurrent(): + values['microsoft_recurrence_master_id'] = microsoft_event.seriesMasterId + + alarm_commands = self._odoo_reminders_commands_m(microsoft_event) + if alarm_commands: + values['alarm_ids'] = alarm_commands + + return values + + @api.model + def _microsoft_to_odoo_recurrence_values(self, microsoft_event, default_reminders=(), values={}): + timeZone_start = pytz.timezone(microsoft_event.start.get('timeZone')) + timeZone_stop = pytz.timezone(microsoft_event.end.get('timeZone')) + start = parse(microsoft_event.start.get('dateTime')).astimezone(timeZone_start).replace(tzinfo=None) + if microsoft_event.isAllDay: + stop = parse(microsoft_event.end.get('dateTime')).astimezone(timeZone_stop).replace(tzinfo=None) - relativedelta(days=1) + else: + stop = parse(microsoft_event.end.get('dateTime')).astimezone(timeZone_stop).replace(tzinfo=None) + values['microsoft_id'] = microsoft_event.id + values['microsoft_recurrence_master_id'] = microsoft_event.seriesMasterId + values['start'] = start + values['stop'] = stop + return values + + @api.model + def _odoo_attendee_commands_m(self, microsoft_event): + commands_attendee = [] + commands_partner = [] + + microsoft_attendees = microsoft_event.attendees or [] + if microsoft_event.isOrganizer: + user = microsoft_event.owner(self.env) + microsoft_attendees += [{ + 'emailAddress': {'address': user.partner_id.email}, + 'status': {'response': 'organizer'} + }] + emails = [a.get('emailAddress').get('address') for a in microsoft_attendees] + existing_attendees = self.env['calendar.attendee'] + if microsoft_event.exists(self.env): + existing_attendees = self.env['calendar.attendee'].search([ + ('event_id', '=', microsoft_event.odoo_id(self.env)), + ('email', 'in', emails)]) + attendees_by_emails = {a.email: a for a in existing_attendees} + for attendee in microsoft_attendees: + email = attendee.get('emailAddress').get('address') + state = ATTENDEE_CONVERTER_M2O.get(attendee.get('status').get('response')) + + if email in attendees_by_emails: + # Update existing attendees + commands_attendee += [(1, attendees_by_emails[email].id, {'state': state})] + else: + # Create new attendees + partner = self.env['res.partner'].find_or_create(email) + commands_attendee += [(0, 0, {'state': state, 'partner_id': partner.id})] + commands_partner += [(4, partner.id)] + if attendee.get('emailAddress').get('name') and not partner.name: + partner.name = attendee.get('emailAddress').get('name') + for odoo_attendee in attendees_by_emails.values(): + # Remove old attendees + if odoo_attendee.email not in emails: + commands_attendee += [(2, odoo_attendee.id)] + commands_partner += [(3, odoo_attendee.partner_id.id)] + return commands_attendee, commands_partner + + @api.model + def _odoo_reminders_commands_m(self, microsoft_event): + reminders_commands = [] + if microsoft_event.isReminderOn: + event_id = self.browse(microsoft_event.odoo_id(self.env)) + alarm_type_label = _("Notification") + + minutes = microsoft_event.reminderMinutesBeforeStart or 0 + alarm = self.env['calendar.alarm'].search([ + ('alarm_type', '=', 'notification'), + ('duration_minutes', '=', minutes) + ], limit=1) + if alarm and alarm not in event_id.alarm_ids: + reminders_commands = [(4, alarm.id)] + elif not alarm: + if minutes == 0: + interval = 'minutes' + duration = minutes + name = _("%s - At time of event", alarm_type_label) + elif minutes % (60*24) == 0: + interval = 'days' + duration = minutes / 60 / 24 + name = _( + "%(reminder_type)s - %(duration)s Days", + reminder_type=alarm_type_label, + duration=duration, + ) + elif minutes % 60 == 0: + interval = 'hours' + duration = minutes / 60 + name = _( + "%(reminder_type)s - %(duration)s Hours", + reminder_type=alarm_type_label, + duration=duration, + ) + else: + interval = 'minutes' + duration = minutes + name = _( + "%(reminder_type)s - %(duration)s Minutes", + reminder_type=alarm_type_label, + duration=duration, + ) + reminders_commands = [(0, 0, {'duration': duration, 'interval': interval, 'name': name, 'alarm_type': 'notification'})] + + alarm_to_rm = event_id.alarm_ids.filtered(lambda a: a.alarm_type == 'notification' and a.id != alarm.id) + if alarm_to_rm: + reminders_commands += [(3, a.id) for a in alarm_to_rm] + + else: + event_id = self.browse(microsoft_event.odoo_id(self.env)) + alarm_to_rm = event_id.alarm_ids.filtered(lambda a: a.alarm_type == 'notification') + if alarm_to_rm: + reminders_commands = [(3, a.id) for a in alarm_to_rm] + return reminders_commands + + def _get_attendee_status_o2m(self, attendee): + if self.user_id and self.user_id == attendee.partner_id.user_id: + return 'organizer' + return ATTENDEE_CONVERTER_O2M.get(attendee.state, 'None') + + def _microsoft_values(self, fields_to_sync, initial_values={}): + values = dict(initial_values) + if not fields_to_sync: + return values + + values['id'] = self.microsoft_id + microsoft_guid = self.env['ir.config_parameter'].sudo().get_param('microsoft_calendar.microsoft_guid', False) + values['singleValueExtendedProperties'] = [{ + 'id': 'String {%s} Name odoo_id' % microsoft_guid, + 'value': str(self.id), + }, { + 'id': 'String {%s} Name owner_odoo_id' % microsoft_guid, + 'value': str(self.user_id.id), + }] + + if self.microsoft_recurrence_master_id and 'type' not in values: + values['seriesMasterId'] = self.microsoft_recurrence_master_id + values['type'] = 'exception' + + if 'name' in fields_to_sync: + values['subject'] = self.name or '' + + if 'description' in fields_to_sync: + values['body'] = { + 'content': self.description or '', + 'contentType': "text", + } + + if any(x in fields_to_sync for x in ['allday', 'start', 'date_end', 'stop']): + if self.allday: + start = {'dateTime': self.start_date.isoformat(), 'timeZone': 'Europe/London'} + end = {'dateTime': (self.stop_date + relativedelta(days=1)).isoformat(), 'timeZone': 'Europe/London'} + else: + start = {'dateTime': pytz.utc.localize(self.start).isoformat(), 'timeZone': 'Europe/London'} + end = {'dateTime': pytz.utc.localize(self.stop).isoformat(), 'timeZone': 'Europe/London'} + + values['start'] = start + values['end'] = end + values['isAllDay'] = self.allday + + if 'location' in fields_to_sync: + values['location'] = {'displayName': self.location or ''} + + if 'alarm_ids' in fields_to_sync: + alarm_id = self.alarm_ids.filtered(lambda a: a.alarm_type == 'notification')[:1] + values['isReminderOn'] = bool(alarm_id) + values['reminderMinutesBeforeStart'] = alarm_id.duration_minutes + + if 'user_id' in fields_to_sync: + values['organizer'] = {'emailAddress': {'address': self.user_id.email or '', 'name': self.user_id.display_name or ''}} + values['isOrganizer'] = self.user_id == self.env.user + + if 'attendee_ids' in fields_to_sync: + attendees = self.attendee_ids.filtered(lambda att: att.partner_id not in self.user_id.partner_id) + values['attendees'] = [ + { + 'emailAddress': {'address': attendee.email or '', 'name': attendee.display_name or ''}, + 'status': {'response': self._get_attendee_status_o2m(attendee)} + } for attendee in attendees] + + if 'privacy' in fields_to_sync or 'show_as' in fields_to_sync: + values['showAs'] = self.show_as + sensitivity_o2m = { + 'public': 'normal', + 'private': 'private', + 'confidential': 'confidential', + } + values['sensitivity'] = sensitivity_o2m.get(self.privacy) + + if 'active' in fields_to_sync and not self.active: + values['isCancelled'] = True + + if values.get('type') == 'seriesMaster': + recurrence = self.recurrence_id + pattern = { + 'interval': recurrence.interval + } + if recurrence.rrule_type in ['daily', 'weekly']: + pattern['type'] = recurrence.rrule_type + else: + prefix = 'absolute' if recurrence.month_by == 'date' else 'relative' + pattern['type'] = prefix + recurrence.rrule_type.capitalize() + + if recurrence.month_by == 'date': + pattern['dayOfMonth'] = recurrence.day + + if recurrence.month_by == 'day' or recurrence.rrule_type == 'weekly': + pattern['daysOfWeek'] = [ + weekday_name for weekday_name, weekday in { + 'monday': recurrence.mo, + 'tuesday': recurrence.tu, + 'wednesday': recurrence.we, + 'thursday': recurrence.th, + 'friday': recurrence.fr, + 'saturday': recurrence.sa, + 'sunday': recurrence.su, + }.items() if weekday] + pattern['firstDayOfWeek'] = 'sunday' + + if recurrence.rrule_type == 'monthly' and recurrence.month_by == 'day': + byday_selection = [ + ('1', 'first'), + ('2', 'second'), + ('3', 'third'), + ('4', 'fourth'), + ('-1', 'last') + ] + pattern['index'] = byday_selection[recurrence.byday] + + rule_range = { + 'startDate': (recurrence.dtstart.date()).isoformat() + } + + if recurrence.end_type == 'count': # e.g. stop after X occurence + rule_range['numberOfOccurrences'] = min(recurrence.count, MAX_RECURRENT_EVENT) + rule_range['type'] = 'numbered' + elif recurrence.end_type == 'forever': + rule_range['numberOfOccurrences'] = MAX_RECURRENT_EVENT + rule_range['type'] = 'numbered' + elif recurrence.end_type == 'end_date': # e.g. stop after 12/10/2020 + rule_range['endDate'] = recurrence.until.isoformat() + rule_range['type'] = 'endDate' + + values['recurrence'] = { + 'pattern': pattern, + 'range': rule_range + } + + return values + + def _microsoft_values_occurence(self, initial_values={}): + values = dict(initial_values) + values['id'] = self.microsoft_id + microsoft_guid = self.env['ir.config_parameter'].sudo().get_param('microsoft_calendar.microsoft_guid', False) + values['singleValueExtendedProperties'] = [{ + 'id': 'String {%s} Name odoo_id' % microsoft_guid, + 'value': str(self.id), + }, { + 'id': 'String {%s} Name owner_odoo_id' % microsoft_guid, + 'value': str(self.user_id.id), + }] + + values['type'] = 'occurrence' + + if self.allday: + start = {'dateTime': self.start_date.isoformat(), 'timeZone': 'Europe/London'} + end = {'dateTime': (self.stop_date + relativedelta(days=1)).isoformat(), 'timeZone': 'Europe/London'} + else: + start = {'dateTime': pytz.utc.localize(self.start).isoformat(), 'timeZone': 'Europe/London'} + end = {'dateTime': pytz.utc.localize(self.stop).isoformat(), 'timeZone': 'Europe/London'} + + values['start'] = start + values['end'] = end + values['isAllDay'] = self.allday + + return values + + def _cancel_microsoft(self): + # only owner can delete => others refuse the event + user = self.env.user + my_cancelled_records = self.filtered(lambda e: e.user_id == user) + super(Meeting, my_cancelled_records)._cancel_microsoft() + attendees = (self - my_cancelled_records).attendee_ids.filtered(lambda a: a.partner_id == user.partner_id) + attendees.state = 'declined' diff --git a/addons/microsoft_calendar/models/calendar_recurrence_rule.py b/addons/microsoft_calendar/models/calendar_recurrence_rule.py new file mode 100644 index 00000000000..0044e3d499b --- /dev/null +++ b/addons/microsoft_calendar/models/calendar_recurrence_rule.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import re + +from odoo import api, fields, models + +from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService + + +class RecurrenceRule(models.Model): + _name = 'calendar.recurrence' + _inherit = ['calendar.recurrence', 'microsoft.calendar.sync'] + + + # Don't sync by default. Sync only when the recurrence is applied + need_sync_m = fields.Boolean(default=False) + + microsoft_id = fields.Char('Microsoft Calendar Recurrence Id') + + def _apply_recurrence(self, specific_values_creation=None, no_send_edit=False): + events = self.calendar_event_ids + detached_events = super()._apply_recurrence(specific_values_creation, no_send_edit) + + microsoft_service = MicrosoftCalendarService(self.env['microsoft.service']) + + # If a synced event becomes a recurrence, the event needs to be deleted from + # Microsoft since it's now the recurrence which is synced. + # Those events are kept in the database and their microsoft_id is updated + # according to the recurrence microsoft_id, therefore we need to keep an inactive copy + # of those events with the original microsoft_id. The next sync will then correctly + # delete those events from Microsoft. + vals = [] + for event in events.filtered('microsoft_id'): + if event.active and event.microsoft_id and not event.recurrence_id.microsoft_id: + vals += [{ + 'name': event.name, + 'microsoft_id': event.microsoft_id, + 'start': event.start, + 'stop': event.stop, + 'active': False, + 'need_sync_m': True, + }] + event._microsoft_delete(microsoft_service, event.microsoft_id) + event.microsoft_id = False + self.env['calendar.event'].create(vals) + + if not no_send_edit: + for recurrence in self: + values = recurrence._microsoft_values(self._get_microsoft_synced_fields()) + if not values: + continue + values = values[0] + if not recurrence.microsoft_id: + recurrence._microsoft_insert(microsoft_service, values) + else: + recurrence._microsoft_patch(microsoft_service, recurrence.microsoft_id, values) + + self.calendar_event_ids.need_sync_m = False + return detached_events + + def _write_events(self, values, dtstart=None): + # If only some events are updated, sync those events. + # If all events are updated, sync the recurrence instead. + values['need_sync_m'] = bool(dtstart) + if not dtstart: + self.need_sync_m = True + return super()._write_events(values, dtstart=dtstart) + + def _get_microsoft_synced_fields(self): + return {'rrule'} | self.env['calendar.event']._get_microsoft_synced_fields() + + def _get_microsoft_sync_domain(self): + return [('calendar_event_ids.user_id', '=', self.env.user.id)] + + def _cancel_microsoft(self): + self.calendar_event_ids._cancel_microsoft() + self.microsoft_id = False # Set to False to avoid error with unlink from microsoft and avoid to synchronize. + self.unlink() + + @api.model + def _microsoft_to_odoo_values(self, microsoft_recurrence, default_reminders=(), default_values={}): + recurrence = microsoft_recurrence.get_recurrence() + + return { + **recurrence, + 'microsoft_id': microsoft_recurrence.id, + } + + def _microsoft_values(self, fields_to_sync): + events = self.calendar_event_ids.sorted('start') + events_outliers = self.calendar_event_ids.filtered(lambda e: not e.follow_recurrence) + normal_event = (events - events_outliers)[:1] or events[:1] + if not normal_event: + return {} + values = [normal_event._microsoft_values(fields_to_sync, initial_values={'type': 'seriesMaster'})] + + if self.microsoft_id: + values[0]['id'] = self.microsoft_id + for event in events_outliers: + event_value = event._microsoft_values(fields_to_sync) + values += [event_value] + + return values diff --git a/addons/microsoft_calendar/models/microsoft_sync.py b/addons/microsoft_calendar/models/microsoft_sync.py new file mode 100644 index 00000000000..312904779ec --- /dev/null +++ b/addons/microsoft_calendar/models/microsoft_sync.py @@ -0,0 +1,356 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +from contextlib import contextmanager +from functools import wraps +import requests +import pytz +from dateutil.parser import parse + +from odoo import api, fields, models, registry, _ +from odoo.tools import ormcache_context +from odoo.exceptions import UserError +from odoo.osv import expression + +from odoo.addons.microsoft_calendar.utils.microsoft_event import MicrosoftEvent +from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService +from odoo.addons.microsoft_account.models.microsoft_service import TIMEOUT + +_logger = logging.getLogger(__name__) + +MAX_RECURRENT_EVENT = 720 + + +# API requests are sent to Microsoft Calendar after the current transaction ends. +# This ensures changes are sent to Microsoft only if they really happened in the Odoo database. +# It is particularly important for event creation , otherwise the event might be created +# twice in Microsoft if the first creation crashed in Odoo. +def after_commit(func): + @wraps(func) + def wrapped(self, *args, **kwargs): + dbname = self.env.cr.dbname + context = self.env.context + uid = self.env.uid + def called_after(): + db_registry = registry(dbname) + with api.Environment.manage(), db_registry.cursor() as cr: + env = api.Environment(cr, uid, context) + try: + func(self.with_env(env), *args, **kwargs) + except Exception as e: + _logger.warning("Could not sync record now: %s" % self) + _logger.exception(e) + self.env.cr.after('commit', called_after) + return wrapped + +@contextmanager +def microsoft_calendar_token(user): + try: + yield user._get_microsoft_calendar_token() + except requests.HTTPError as e: + if e.response.status_code == 401: # Invalid token. + # The transaction should be rolledback, but the user's tokens + # should be reset. The user will be asked to authenticate again next time. + # Rollback manually first to avoid concurrent access errors/deadlocks. + user.env.cr.rollback() + with user.pool.cursor() as cr: + env = user.env(cr=cr) + user.with_env(env)._set_microsoft_auth_tokens(False, False, 0) + raise e + +class MicrosoftSync(models.AbstractModel): + _name = 'microsoft.calendar.sync' + _description = "Synchronize a record with Microsoft Calendar" + + microsoft_id = fields.Char('Microsoft Calendar Id', copy=False) + need_sync_m = fields.Boolean(default=True, copy=False) + active = fields.Boolean(default=True) + + def write(self, vals): + microsoft_service = MicrosoftCalendarService(self.env['microsoft.service']) + if 'microsoft_id' in vals: + self._from_microsoft_ids.clear_cache(self) + synced_fields = self._get_microsoft_synced_fields() + if 'need_sync_m' not in vals and vals.keys() & synced_fields: + fields_to_sync = [x for x in vals.keys() if x in synced_fields] + if fields_to_sync: + vals['need_sync_m'] = True + else: + fields_to_sync = [x for x in vals.keys() if x in synced_fields] + + result = super().write(vals) + for record in self.filtered('need_sync_m'): + if record.microsoft_id and fields_to_sync: + values = record._microsoft_values(fields_to_sync) + if not values: + continue + record._microsoft_patch(microsoft_service, record.microsoft_id, values, timeout=3) + + return result + + @api.model_create_multi + def create(self, vals_list): + if any(vals.get('microsoft_id') for vals in vals_list): + self._from_microsoft_ids.clear_cache(self) + records = super().create(vals_list) + + microsoft_service = MicrosoftCalendarService(self.env['microsoft.service']) + records_to_sync = records.filtered(lambda r: r.need_sync_m and r.active) + for record in records_to_sync: + record._microsoft_insert(microsoft_service, record._microsoft_values(self._get_microsoft_synced_fields()), timeout=3) + return records + + def unlink(self): + """We can't delete an event that is also in Microsoft Calendar. Otherwise we would + have no clue that the event must must deleted from Microsoft Calendar at the next sync. + """ + synced = self.filtered('microsoft_id') + if self.env.context.get('archive_on_error') and self._active_name: + synced.write({self._active_name: False}) + self = self - synced + elif synced: + raise UserError(_("You cannot delete a record synchronized with Microsoft Calendar, archive it instead.")) + return super().unlink() + + @api.model + @ormcache_context('microsoft_ids', keys=('active_test',)) + def _from_microsoft_ids(self, microsoft_ids): + if not microsoft_ids: + return self.browse() + return self.search([('microsoft_id', 'in', microsoft_ids)]) + + def _sync_odoo2microsoft(self, microsoft_service: MicrosoftCalendarService): + if not self: + return + if self._active_name: + records_to_sync = self.filtered(self._active_name) + else: + records_to_sync = self + cancelled_records = self - records_to_sync + + updated_records = records_to_sync.filtered('microsoft_id') + new_records = records_to_sync - updated_records + for record in cancelled_records.filtered('microsoft_id'): + record._microsoft_delete(microsoft_service, record.microsoft_id) + for record in new_records: + values = record._microsoft_values(self._get_microsoft_synced_fields()) + if isinstance(values, dict): + record._microsoft_insert(microsoft_service, values) + else: + for value in values: + record._microsoft_insert(microsoft_service, value) + for record in updated_records: + values = record._microsoft_values(self._get_microsoft_synced_fields()) + if not values: + continue + record._microsoft_patch(microsoft_service, record.microsoft_id, values) + + def _cancel_microsoft(self): + self.microsoft_id = False + self.unlink() + + def _sync_recurrence_microsoft2odoo(self, microsoft_events: MicrosoftEvent): + recurrent_masters = microsoft_events.filter(lambda e: e.is_recurrence()) + recurrents = microsoft_events.filter(lambda e: e.is_recurrent_not_master()) + default_values = {'need_sync_m': False} + + new_recurrence = self.env['calendar.recurrence'] + + for recurrent_master in recurrent_masters: + new_calendar_recurrence = dict(self.env['calendar.recurrence']._microsoft_to_odoo_values(recurrent_master, (), default_values), need_sync_m=False) + to_create = recurrents.filter(lambda e: e.seriesMasterId == new_calendar_recurrence['microsoft_id']) + recurrents -= to_create + base_values = dict(self.env['calendar.event']._microsoft_to_odoo_values(recurrent_master, (), default_values), need_sync_m=False) + to_create_values = [] + if new_calendar_recurrence.get('end_type', False) in ['count', 'forever']: + to_create = list(to_create)[:MAX_RECURRENT_EVENT] + for recurrent_event in to_create: + if recurrent_event.type == 'occurrence': + value = self.env['calendar.event']._microsoft_to_odoo_recurrence_values(recurrent_event, (), base_values) + else: + value = self.env['calendar.event']._microsoft_to_odoo_values(recurrent_event, (), default_values) + + to_create_values += [dict(value, need_sync_m=False)] + + new_calendar_recurrence['calendar_event_ids'] = [(0, 0, to_create_value) for to_create_value in to_create_values] + new_recurrence_odoo = self.env['calendar.recurrence'].create(new_calendar_recurrence) + new_recurrence_odoo.base_event_id = new_recurrence_odoo.calendar_event_ids[0] if new_recurrence_odoo.calendar_event_ids else False + new_recurrence |= new_recurrence_odoo + + for recurrent_master_id in set([x.seriesMasterId for x in recurrents]): + recurrence_id = self.env['calendar.recurrence'].search([('microsoft_id', '=', recurrent_master_id)]) + to_update = recurrents.filter(lambda e: e.seriesMasterId == recurrent_master_id) + for recurrent_event in to_update: + if recurrent_event.type == 'occurrence': + value = self.env['calendar.event']._microsoft_to_odoo_recurrence_values(recurrent_event, (), {'need_sync_m': False}) + else: + value = self.env['calendar.event']._microsoft_to_odoo_values(recurrent_event, (), default_values) + existing_event = recurrence_id.calendar_event_ids.filtered(lambda e: e._range() == (value['start'], value['stop'])) + if not existing_event: + continue + value.pop('start') + value.pop('stop') + existing_event.write(value) + new_recurrence |= recurrence_id + return new_recurrence + + def _update_microsoft_recurrence(self, recurrence_event, events): + vals = dict(self.base_event_id._microsoft_to_odoo_values(recurrence_event, ()), need_sync_m=False) + vals['microsoft_recurrence_master_id'] = vals.pop('microsoft_id') + self.base_event_id.write(vals) + values = {} + default_values = {} + + normal_events = [] + events_to_update = events.filter(lambda e: e.seriesMasterId == self.microsoft_id) + if self.end_type in ['count', 'forever']: + events_to_update = list(events_to_update)[:MAX_RECURRENT_EVENT] + + for recurrent_event in events_to_update: + if recurrent_event.type == 'occurrence': + value = self.env['calendar.event']._microsoft_to_odoo_recurrence_values(recurrent_event, (), default_values) + normal_events += [recurrent_event.odoo_id(self.env)] + else: + value = self.env['calendar.event']._microsoft_to_odoo_values(recurrent_event, (), default_values) + self.env['calendar.event'].browse(recurrent_event.odoo_id(self.env)).with_context(no_mail_to_attendees=True, mail_create_nolog=True).write(dict(value, need_sync_m=False)) + values[(self.id, value.get('start'), value.get('stop'))] = dict(value, need_sync_m=False) + + if (self.id, vals.get('start'), vals.get('stop')) in values: + base_event_vals = dict(vals) + base_event_vals.update(values[(self.id, vals.get('start'), vals.get('stop'))]) + self.base_event_id.write(base_event_vals) + + old_record = self._apply_recurrence(specific_values_creation=values, no_send_edit=True) + + vals.pop('microsoft_id', None) + vals.pop('start', None) + vals.pop('stop', None) + normal_events = [e for e in normal_events if e in self.calendar_event_ids.ids] + normal_event_ids = self.env['calendar.event'].browse(normal_events) - old_record + if normal_event_ids: + vals['follow_recurrence'] = True + (self.env['calendar.event'].browse(normal_events) - old_record).write(vals) + + old_record._cancel_microsoft() + if not self.base_event_id: + self.base_event_id = self._get_first_event(include_outliers=False) + + @api.model + def _sync_microsoft2odoo(self, microsoft_events: MicrosoftEvent, default_reminders=()): + """Synchronize Microsoft recurrences in Odoo. Creates new recurrences, updates + existing ones. + + :return: synchronized odoo + """ + existing = microsoft_events.exists(self.env) + new = microsoft_events - existing - microsoft_events.cancelled() + new_recurrent = new.filter(lambda e: e.is_recurrent()) + + default_values = {} + + odoo_values = [ + dict(self._microsoft_to_odoo_values(e, default_reminders, default_values), need_sync_m=False) + for e in (new - new_recurrent) + ] + new_odoo = self.create(odoo_values) + + synced_recurrent_records = self._sync_recurrence_microsoft2odoo(new_recurrent) + + cancelled = existing.cancelled() + cancelled_odoo = self.browse(cancelled.odoo_ids(self.env)) + cancelled_odoo._cancel_microsoft() + + recurrent_cancelled = self.env['calendar.recurrence'].search([ + ('microsoft_id', 'in', (microsoft_events.cancelled() - cancelled).microsoft_ids())]) + recurrent_cancelled._cancel_microsoft() + + synced_records = new_odoo + cancelled_odoo + synced_recurrent_records.calendar_event_ids + + for mevent in (existing - cancelled).filter(lambda e: e.lastModifiedDateTime and not e.seriesMasterId): + # Last updated wins. + # This could be dangerous if microsoft server time and odoo server time are different + if mevent.is_recurrence(): + odoo_record = self.env['calendar.recurrence'].browse(mevent.odoo_id(self.env)) + else: + odoo_record = self.browse(mevent.odoo_id(self.env)) + odoo_record_updated = pytz.utc.localize(odoo_record.write_date) + updated = parse(mevent.lastModifiedDateTime or str(odoo_record_updated)) + if updated >= odoo_record_updated: + vals = dict(odoo_record._microsoft_to_odoo_values(mevent, default_reminders), need_sync_m=False) + odoo_record.write(vals) + if odoo_record._name == 'calendar.recurrence': + odoo_record._update_microsoft_recurrence(mevent, microsoft_events) + synced_recurrent_records |= odoo_record + else: + synced_records |= odoo_record + + return synced_records, synced_recurrent_records + + @after_commit + def _microsoft_delete(self, microsoft_service: MicrosoftCalendarService, microsoft_id, timeout=TIMEOUT): + with microsoft_calendar_token(self.env.user.sudo()) as token: + if token: + microsoft_service.delete(microsoft_id, token=token, timeout=timeout) + + @after_commit + def _microsoft_patch(self, microsoft_service: MicrosoftCalendarService, microsoft_id, values, timeout=TIMEOUT): + with microsoft_calendar_token(self.env.user.sudo()) as token: + if token: + microsoft_service.patch(microsoft_id, values, token=token, timeout=timeout) + self.need_sync_m = False + + @after_commit + def _microsoft_insert(self, microsoft_service: MicrosoftCalendarService, values, timeout=TIMEOUT): + if not values: + return + with microsoft_calendar_token(self.env.user.sudo()) as token: + if token: + microsoft_id = microsoft_service.insert(values, token=token, timeout=timeout) + self.write({ + 'microsoft_id': microsoft_id, + 'need_sync_m': False, + }) + + def _get_microsoft_records_to_sync(self, full_sync=False): + """Return records that should be synced from Odoo to Microsoft + + :param full_sync: If True, all events attended by the user are returned + :return: events + """ + domain = self._get_microsoft_sync_domain() + if not full_sync: + is_active_clause = (self._active_name, '=', True) if self._active_name else expression.TRUE_LEAF + domain = expression.AND([domain, [ + '|', + '&', ('microsoft_id', '=', False), is_active_clause, + ('need_sync_m', '=', True), + ]]) + return self.with_context(active_test=False).search(domain) + + @api.model + def _microsoft_to_odoo_values(self, microsoft_event: MicrosoftEvent, default_reminders=()): + """Implements this method to return a dict of Odoo values corresponding + to the Microsoft event given as parameter + :return: dict of Odoo formatted values + """ + raise NotImplementedError() + + def _microsoft_values(self, fields_to_sync): + """Implements this method to return a dict with values formatted + according to the Microsoft Calendar API + :return: dict of Microsoft formatted values + """ + raise NotImplementedError() + + def _get_microsoft_sync_domain(self): + """Return a domain used to search records to synchronize. + e.g. return a domain to synchronize records owned by the current user. + """ + raise NotImplementedError() + + def _get_microsoft_synced_fields(self): + """Return a set of field names. Changing one of these fields + marks the record to be re-synchronized. + """ + raise NotImplementedError() diff --git a/addons/microsoft_calendar/models/res_config_settings.py b/addons/microsoft_calendar/models/res_config_settings.py new file mode 100644 index 00000000000..38ac9565fb1 --- /dev/null +++ b/addons/microsoft_calendar/models/res_config_settings.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + cal_microsoft_client_id = fields.Char("Microsoft Client_id", config_parameter='microsoft_calendar_client_id', default='') + cal_microsoft_client_secret = fields.Char("Microsoft Client_key", config_parameter='microsoft_calendar_client_secret', default='') diff --git a/addons/microsoft_calendar/models/res_users.py b/addons/microsoft_calendar/models/res_users.py new file mode 100644 index 00000000000..7354ea45f44 --- /dev/null +++ b/addons/microsoft_calendar/models/res_users.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import requests +from odoo.addons.microsoft_calendar.models.microsoft_sync import microsoft_calendar_token +from datetime import timedelta + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError +from odoo.loglevels import exception_to_unicode +from odoo.addons.microsoft_account.models.microsoft_service import MICROSOFT_TOKEN_ENDPOINT +from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService, InvalidSyncToken + +_logger = logging.getLogger(__name__) + + +class User(models.Model): + _inherit = 'res.users' + + microsoft_calendar_sync_token = fields.Char('Microsoft Next Sync Token', copy=False) + + def _microsoft_calendar_authenticated(self): + return bool(self.sudo().microsoft_calendar_rtoken) + + def _get_microsoft_calendar_token(self): + self.ensure_one() + if self._is_microsoft_calendar_valid(): + self._refresh_microsoft_calendar_token() + return self.microsoft_calendar_token + + def _is_microsoft_calendar_valid(self): + return self.microsoft_calendar_token_validity and self.microsoft_calendar_token_validity < (fields.Datetime.now() + timedelta(minutes=1)) + + def _refresh_microsoft_calendar_token(self): + self.ensure_one() + get_param = self.env['ir.config_parameter'].sudo().get_param + client_id = get_param('microsoft_calendar_client_id') + client_secret = get_param('microsoft_calendar_client_secret') + + if not client_id or not client_secret: + raise UserError(_("The account for the Microsoft Calendar service is not configured.")) + + headers = {"content-type": "application/x-www-form-urlencoded"} + data = { + 'refresh_token': self.microsoft_calendar_rtoken, + 'client_id': client_id, + 'client_secret': client_secret, + 'grant_type': 'refresh_token', + } + + try: + dummy, response, dummy = self.env['microsoft.service']._do_request(MICROSOFT_TOKEN_ENDPOINT, params=data, headers=headers, method='POST', preuri='') + ttl = response.get('expires_in') + self.write({ + 'microsoft_calendar_token': response.get('access_token'), + 'microsoft_calendar_token_validity': fields.Datetime.now() + timedelta(seconds=ttl), + }) + except requests.HTTPError as error: + if error.response.status_code == 400: # invalid grant + # Delete refresh token and make sure it's commited + with self.pool.cursor() as cr: + self.env.user.with_env(self.env(cr=cr)).write({'microsoft_calendar_rtoken': False}) + error_key = error.response.json().get("error", "nc") + error_msg = _("Something went wrong during your token generation. Maybe your Authorization Code is invalid or already expired [%s]", error_key) + raise UserError(error_msg) + + def _sync_microsoft_calendar(self, calendar_service: MicrosoftCalendarService): + self.ensure_one() + full_sync = not bool(self.microsoft_calendar_sync_token) + with microsoft_calendar_token(self) as token: + try: + events, next_sync_token, default_reminders = calendar_service.get_events(self.microsoft_calendar_sync_token, token=token) + except InvalidSyncToken: + events, next_sync_token, default_reminders = calendar_service.get_events(token=token) + full_sync = True + self.microsoft_calendar_sync_token = next_sync_token + + # Microsoft -> Odoo + recurrences = events.filter(lambda e: e.is_recurrent()) + synced_events, synced_recurrences = self.env['calendar.event']._sync_microsoft2odoo(events, default_reminders=default_reminders) if events else (self.env['calendar.event'], self.env['calendar.recurrence']) + + # Odoo -> Microsoft + recurrences = self.env['calendar.recurrence']._get_microsoft_records_to_sync(full_sync=full_sync) + recurrences -= synced_recurrences + recurrences._sync_odoo2microsoft(calendar_service) + synced_events |= recurrences.calendar_event_ids + + events = self.env['calendar.event']._get_microsoft_records_to_sync(full_sync=full_sync) + (events - synced_events)._sync_odoo2microsoft(calendar_service) + + return bool(events | synced_events) or bool(recurrences | synced_recurrences) + + @api.model + def _sync_all_microsoft_calendar(self): + """ Cron job """ + users = self.env['res.users'].search([('microsoft_calendar_rtoken', '!=', False)]) + microsoft = MicrosoftCalendarService(self.env['microsoft.service']) + for user in users: + _logger.info("Calendar Synchro - Starting synchronization for %s", user) + try: + user.with_user(user).sudo()._sync_microsoft_calendar(microsoft) + except Exception as e: + _logger.exception("[%s] Calendar Synchro - Exception : %s !", user, exception_to_unicode(e)) diff --git a/addons/microsoft_calendar/security/ir.model.access.csv b/addons/microsoft_calendar/security/ir.model.access.csv new file mode 100644 index 00000000000..478501e7e4a --- /dev/null +++ b/addons/microsoft_calendar/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+microsoft_calendar_account_reset,microsoft_calendar_account_reset_access_right,model_microsoft_calendar_account_reset,base.group_system,1,1,1,0
diff --git a/addons/microsoft_calendar/static/description/icon.png b/addons/microsoft_calendar/static/description/icon.png Binary files differnew file mode 100644 index 00000000000..b8734363b98 --- /dev/null +++ b/addons/microsoft_calendar/static/description/icon.png diff --git a/addons/microsoft_calendar/static/description/icon.svg b/addons/microsoft_calendar/static/description/icon.svg new file mode 100644 index 00000000000..6056dc85197 --- /dev/null +++ b/addons/microsoft_calendar/static/description/icon.svg @@ -0,0 +1,22 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70"> + <defs> + <path id="icon-a" d="M4,5.35309892e-14 C36.4160122,9.87060235e-15 58.0836068,-3.97961823e-14 65,5.07020818e-14 C69,6.733808e-14 70,1 70,5 C70,43.0488877 70,62.4235458 70,65 C70,69 69,70 65,70 C61,70 9,70 4,70 C1,70 7.10542736e-15,69 7.10542736e-15,65 C7.25721566e-15,62.4676575 3.83358709e-14,41.8005206 3.60818146e-14,5 C-1.13686838e-13,1 1,5.75716207e-14 4,5.35309892e-14 Z"/> + <linearGradient id="icon-c" x1="100%" x2="0%" y1="0%" y2="100%"> + <stop offset="0%" stop-color="#94B6C8"/> + <stop offset="100%" stop-color="#6A9EBA"/> + </linearGradient> + </defs> + <g fill="none" fill-rule="evenodd"> + <mask id="icon-b" fill="#fff"> + <use xlink:href="#icon-a"/> + </mask> + <g mask="url(#icon-b)"> + <rect width="70" height="70" fill="url(#icon-c)"/> + <path fill="#FFF" fill-opacity=".383" d="M4,1.8 L65,1.8 C67.6666667,1.8 69.3333333,1.13333333 70,-0.2 C70,2.46666667 70,3.46666667 70,2.8 L1.10547097e-14,2.8 C-1.65952376e-14,3.46666667 -2.9161925e-14,2.46666667 -2.66453526e-14,-0.2 C0.666666667,1.13333333 2,1.8 4,1.8 Z" transform="matrix(1 0 0 -1 0 2.8)"/> + <path fill="#393939" d="M4,53 C2,53 -7.10542736e-15,52.8543956 0,48.9230769 L2.12440903e-16,23.797368 L15,0 L54,0 L54,26.3118387 L57.7908529,26.3118387 L59,37.7115385 L49.1366936,53 L4,53 Z" opacity=".324" transform="translate(0 16)"/> + <path fill="#000" fill-opacity=".383" d="M4,4 L65,4 C67.6666667,4 69.3333333,3 70,1 C70,3.66666667 70,5 70,5 L1.77635684e-15,5 C1.77635684e-15,5 1.77635684e-15,3.66666667 1.77635684e-15,1 C0.666666667,3 2,4 4,4 Z" transform="translate(0 65)"/> + <path fill="#000" fill-rule="nonzero" d="M42.0955645,41.4315062 L42.0955645,28.7560864 L40.3833955,28.7560864 C40.2604632,29.4411054 40.0321745,30.0062307 39.6985283,30.4514643 C39.3648683,30.8967319 38.9521929,31.2477943 38.4604987,31.5046545 C37.986355,31.7615447 37.4507537,31.9413579 36.8536946,32.0440934 C36.2566271,32.1297334 35.6420039,32.1725451 35.0098218,32.1725307 L35.0098218,33.9192846 L39.8565751,33.9192846 L39.8565751,46.9685596 L41.0044957,46.9685596 C41.0029977,46.9790279 41.0014988,46.9895081 41,47 C41.6666667,47 42,47.3333333 42,48 C42,48.6666667 42,49 42,49 C40.6666667,49.6666667 40,50.3333333 40,51 C40,51.5412134 40.219684,52.3021107 40.6590519,53.282692 L15,53.282692 L15,24.0484615 L54.763358,24.0484615 L54.763358,37 C51.587786,37 49.6666667,37 49,37 C48.3333333,37 48,37.3333333 48,38 C48,38 48,38.3333333 48,39 C48,39.6666667 47.3333333,40 46,40 C44.6666667,40 43.6666667,40 43,40 C42.6985215,40 42.397043,40.4771687 42.0955645,41.4315062 Z M54.763358,23.0403846 L15,23.0403846 L15,18 L54.763358,18 L54.763358,23.0403846 Z M59.1120093,49.6163985 L60.7668484,50.585058 C60.9558409,50.6956709 61.043561,50.9244177 60.9788291,51.1357594 C60.5489654,52.5392703 59.8150519,53.807242 58.8551784,54.8603674 C58.7072273,55.022782 58.4680825,55.059636 58.279231,54.9491254 L56.6256345,53.9806234 C55.9298948,54.5851374 55.1300224,55.0541223 54.2662772,55.3639727 L54.2662772,57.3006617 C54.2662804,57.5219079 54.1150225,57.7135885 53.9021551,57.7620931 C52.5447646,58.0712105 51.0869171,58.0871979 49.661028,57.7623688 C49.4478436,57.7138157 49.2958963,57.5223205 49.2958963,57.3007404 L49.2958963,55.3639727 C48.4321428,55.0541404 47.6322673,54.5851537 46.936539,53.9806234 L45.2829425,54.9491254 C45.0940909,55.059636 44.8549462,55.022782 44.7069951,54.8603674 C43.7471604,53.807242 43.0132081,52.5392703 42.5833444,51.1357594 C42.5186125,50.9244571 42.6063326,50.6957102 42.795325,50.585058 L44.450203,49.6163985 C44.2839859,48.7026748 44.2839859,47.7658257 44.450203,46.852102 L42.7953639,45.8834424 C42.6063714,45.7728296 42.5186513,45.5440828 42.5833832,45.332741 C43.0132469,43.9292302 43.7471604,42.6612585 44.7070339,41.6081331 C44.8549851,41.4457185 45.0941298,41.4088644 45.2829813,41.5193751 L46.9365778,42.4878771 C47.6323176,41.8833631 48.4321899,41.4143782 49.2959351,41.1045278 L49.2959351,39.1677994 C49.2959319,38.9465532 49.4471898,38.7548726 49.6600572,38.706368 C51.0174478,38.3972506 52.4752952,38.3812631 53.9011843,38.7060923 C54.1143687,38.7546454 54.266316,38.9461406 54.266316,39.1677207 L54.266316,41.1044884 C55.1300695,41.4143207 55.929945,41.8833074 56.6256733,42.4878377 L58.2792698,41.5193357 C58.4681214,41.4088251 58.7072661,41.4456791 58.8552172,41.6080937 C59.8150519,42.6612191 60.5490043,43.9291908 60.9788679,45.3327017 C61.0435999,45.544004 60.9558797,45.7727509 60.7668873,45.8834031 L59.1120093,46.8520626 C59.2782268,47.7657995 59.2782268,48.7026616 59.1120093,49.6163985 Z M54.8876185,48.2342305 C54.8876185,46.497188 53.4940371,45.0839902 51.7811062,45.0839902 C50.0681752,45.0839902 48.6745938,46.497188 48.6745938,48.2342305 C48.6745938,49.9712731 50.0681752,51.3844709 51.7811062,51.3844709 C53.4940371,51.3844709 54.8876185,49.9712731 54.8876185,48.2342305 Z M25.3383523,20.6895722 C25.3383523,19.7564466 24.5910361,19 23.6691756,19 C22.7473152,19 22,19.7564466 22,20.6895722 C22,21.6226969 22.7473152,22.3791434 23.6691756,22.3791434 C24.5910361,22.3791434 25.3383523,21.6226969 25.3383523,20.6895722 Z M48.3383523,20.6895722 C48.3383523,19.7564466 47.5910361,19 46.6691756,19 C45.7473152,19 45,19.7564466 45,20.6895722 C45,21.6226969 45.7473152,22.3791434 46.6691756,22.3791434 C47.5910361,22.3791434 48.3383523,21.6226969 48.3383523,20.6895722 Z M26.4684862,38.4146042 C26.856901,38.3632372 27.2695974,38.3375505 27.7065763,38.3375419 C28.2082784,38.3375505 28.685711,38.4146128 29.1388753,38.56873 C29.5920244,38.705738 29.9804432,38.9283634 30.3041359,39.2366062 C30.6278103,39.5277389 30.8867565,39.8959263 31.0809766,40.3411707 C31.2751764,40.7693028 31.3722814,41.2659282 31.3722915,41.8310479 C31.3722814,42.3790542 31.2670842,42.8756796 31.0567001,43.3209262 C30.86248,43.749055 30.5954416,44.1172436 30.2555839,44.4254907 C29.9157069,44.716619 29.5191949,44.947806 29.0660468,45.1190547 C28.6128825,45.2731815 28.1354499,45.3502438 27.6337468,45.3502428 C26.4522968,45.3502438 25.5540767,44.9820564 24.9390823,44.2456783 C24.3240808,43.4921803 24.0003972,42.5246163 23.9680316,41.3429851 L21.9045489,41.3429851 C21.8883645,42.2848667 22.009746,43.1239918 22.2686932,43.8603645 C22.5438228,44.5967437 22.9322426,45.221807 23.4339538,45.7355554 C23.9356609,46.2321829 24.5425662,46.6174956 25.2546737,46.8914955 C25.966772,47.1483714 26.7597961,47.2768082 27.6337468,47.2768093 C28.4429491,47.2768082 29.2036045,47.156934 29.915716,46.9171833 C30.6439946,46.6774327 31.2751764,46.3263705 31.8092643,45.8639933 C32.343331,45.4016194 32.7641185,44.8279318 33.0716299,44.1429281 C33.3953013,43.4408047 33.5571425,42.635929 33.5571547,41.7282978 C33.5571425,40.6323023 33.2981963,39.6818635 32.780315,38.8769803 C32.2785937,38.0721121 31.501755,37.5497989 30.4497938,37.3100397 L30.4497938,37.2586641 C31.1133351,36.9332983 31.6716884,36.4537981 32.1248557,35.8201615 C32.5941851,35.1694222 32.8288558,34.4416088 32.828867,33.6367202 C32.8288558,32.797607 32.69129,32.0697936 32.4161706,31.4532779 C32.1572132,30.8367933 31.7930699,30.3316054 31.3237385,29.937713 C30.8543879,29.5438538 30.2960346,29.2527287 29.6486766,29.0643354 C29.0174867,28.8588534 28.3296598,28.7561033 27.5851948,28.7560851 C26.7274274,28.7561033 25.966772,28.9016664 25.3032256,29.1927733 C24.6558555,29.4839167 24.1136865,29.8863545 23.6767167,30.4000879 C23.2397419,30.9138557 22.899874,31.5303563 22.6571131,32.2495921 C22.4305335,32.9688579 22.3010609,33.765171 22.2686932,34.6385346 L24.332176,34.6385346 C24.3321719,34.1076707 24.3969093,33.6024827 24.5263859,33.1229686 C24.6558555,32.6434824 24.8500654,32.2239193 25.1090157,31.8642784 C25.3841422,31.5046685 25.7240101,31.222106 26.1286194,31.0165897 C26.5332175,30.7939804 27.0187423,30.6826676 27.5851948,30.6826515 C28.4591324,30.6826676 29.1874202,30.9309809 29.7700581,31.4275901 C30.3526797,31.9242316 30.6439946,32.652045 30.6440038,33.6110324 C30.6439946,34.1076707 30.5549808,34.5443589 30.3769643,34.9210972 C30.1989296,35.2807349 29.9561677,35.5889853 29.6486766,35.8458493 C29.3573536,36.0856106 29.0093946,36.2739857 28.6047975,36.4109754 C28.2163705,36.5308604 27.8036742,36.5907986 27.3667084,36.5907879 C27.2210444,36.5907986 27.0753874,36.5907986 26.9297356,36.5907879 C26.7840726,36.5907986 26.6303224,36.582236 26.4684862,36.5651001 L26.4684862,38.4146042 Z M13.808,57.508 L12.524,57.508 L12.524,56.716 L16.034,56.716 L16.034,57.508 L14.75,57.508 L14.75,61 L13.808,61 L13.808,57.508 Z M16.526,56.716 L17.852,56.716 L18.854,59.662 L18.866,59.662 L19.814,56.716 L21.14,56.716 L21.14,61 L20.258,61 L20.258,57.964 L20.246,57.964 L19.196,61 L18.47,61 L17.42,57.994 L17.408,57.994 L17.408,61 L16.526,61 L16.526,56.716 Z" opacity=".3"/> + <path fill="#FFF" fill-rule="nonzero" d="M42.0955645,39.4315062 L42.0955645,26.7560864 L40.3833955,26.7560864 C40.2604632,27.4411054 40.0321745,28.0062307 39.6985283,28.4514643 C39.3648683,28.8967319 38.9521929,29.2477943 38.4604987,29.5046545 C37.986355,29.7615447 37.4507537,29.9413579 36.8536946,30.0440934 C36.2566271,30.1297334 35.6420039,30.1725451 35.0098218,30.1725307 L35.0098218,31.9192846 L39.8565751,31.9192846 L39.8565751,44.9685596 L41.0044957,44.9685596 C41.0029977,44.9790279 41.0014988,44.9895081 41,45 C41.6666667,45 42,45.3333333 42,46 C42,46.6666667 42,47 42,47 C40.6666667,47.6666667 40,48.3333333 40,49 C40,49.5412134 40.219684,50.3021107 40.6590519,51.282692 L15,51.282692 L15,22.0484615 L54.763358,22.0484615 L54.763358,35 C51.587786,35 49.6666667,35 49,35 C48.3333333,35 48,35.3333333 48,36 C48,36 48,36.3333333 48,37 C48,37.6666667 47.3333333,38 46,38 C44.6666667,38 43.6666667,38 43,38 C42.6985215,38 42.397043,38.4771687 42.0955645,39.4315062 Z M54.763358,21.0403846 L15,21.0403846 L15,16 L54.763358,16 L54.763358,21.0403846 Z M59.1120093,47.6163985 L60.7668484,48.585058 C60.9558409,48.6956709 61.043561,48.9244177 60.9788291,49.1357594 C60.5489654,50.5392703 59.8150519,51.807242 58.8551784,52.8603674 C58.7072273,53.022782 58.4680825,53.059636 58.279231,52.9491254 L56.6256345,51.9806234 C55.9298948,52.5851374 55.1300224,53.0541223 54.2662772,53.3639727 L54.2662772,55.3006617 C54.2662804,55.5219079 54.1150225,55.7135885 53.9021551,55.7620931 C52.5447646,56.0712105 51.0869171,56.0871979 49.661028,55.7623688 C49.4478436,55.7138157 49.2958963,55.5223205 49.2958963,55.3007404 L49.2958963,53.3639727 C48.4321428,53.0541404 47.6322673,52.5851537 46.936539,51.9806234 L45.2829425,52.9491254 C45.0940909,53.059636 44.8549462,53.022782 44.7069951,52.8603674 C43.7471604,51.807242 43.0132081,50.5392703 42.5833444,49.1357594 C42.5186125,48.9244571 42.6063326,48.6957102 42.795325,48.585058 L44.450203,47.6163985 C44.2839859,46.7026748 44.2839859,45.7658257 44.450203,44.852102 L42.7953639,43.8834424 C42.6063714,43.7728296 42.5186513,43.5440828 42.5833832,43.332741 C43.0132469,41.9292302 43.7471604,40.6612585 44.7070339,39.6081331 C44.8549851,39.4457185 45.0941298,39.4088644 45.2829813,39.5193751 L46.9365778,40.4878771 C47.6323176,39.8833631 48.4321899,39.4143782 49.2959351,39.1045278 L49.2959351,37.1677994 C49.2959319,36.9465532 49.4471898,36.7548726 49.6600572,36.706368 C51.0174478,36.3972506 52.4752952,36.3812631 53.9011843,36.7060923 C54.1143687,36.7546454 54.266316,36.9461406 54.266316,37.1677207 L54.266316,39.1044884 C55.1300695,39.4143207 55.929945,39.8833074 56.6256733,40.4878377 L58.2792698,39.5193357 C58.4681214,39.4088251 58.7072661,39.4456791 58.8552172,39.6080937 C59.8150519,40.6612191 60.5490043,41.9291908 60.9788679,43.3327017 C61.0435999,43.544004 60.9558797,43.7727509 60.7668873,43.8834031 L59.1120093,44.8520626 C59.2782268,45.7657995 59.2782268,46.7026616 59.1120093,47.6163985 Z M54.8876185,46.2342305 C54.8876185,44.497188 53.4940371,43.0839902 51.7811062,43.0839902 C50.0681752,43.0839902 48.6745938,44.497188 48.6745938,46.2342305 C48.6745938,47.9712731 50.0681752,49.3844709 51.7811062,49.3844709 C53.4940371,49.3844709 54.8876185,47.9712731 54.8876185,46.2342305 Z M25.3383523,18.6895722 C25.3383523,17.7564466 24.5910361,17 23.6691756,17 C22.7473152,17 22,17.7564466 22,18.6895722 C22,19.6226969 22.7473152,20.3791434 23.6691756,20.3791434 C24.5910361,20.3791434 25.3383523,19.6226969 25.3383523,18.6895722 Z M48.3383523,18.6895722 C48.3383523,17.7564466 47.5910361,17 46.6691756,17 C45.7473152,17 45,17.7564466 45,18.6895722 C45,19.6226969 45.7473152,20.3791434 46.6691756,20.3791434 C47.5910361,20.3791434 48.3383523,19.6226969 48.3383523,18.6895722 Z M26.4684862,36.4146042 C26.856901,36.3632372 27.2695974,36.3375505 27.7065763,36.3375419 C28.2082784,36.3375505 28.685711,36.4146128 29.1388753,36.56873 C29.5920244,36.705738 29.9804432,36.9283634 30.3041359,37.2366062 C30.6278103,37.5277389 30.8867565,37.8959263 31.0809766,38.3411707 C31.2751764,38.7693028 31.3722814,39.2659282 31.3722915,39.8310479 C31.3722814,40.3790542 31.2670842,40.8756796 31.0567001,41.3209262 C30.86248,41.749055 30.5954416,42.1172436 30.2555839,42.4254907 C29.9157069,42.716619 29.5191949,42.947806 29.0660468,43.1190547 C28.6128825,43.2731815 28.1354499,43.3502438 27.6337468,43.3502428 C26.4522968,43.3502438 25.5540767,42.9820564 24.9390823,42.2456783 C24.3240808,41.4921803 24.0003972,40.5246163 23.9680316,39.3429851 L21.9045489,39.3429851 C21.8883645,40.2848667 22.009746,41.1239918 22.2686932,41.8603645 C22.5438228,42.5967437 22.9322426,43.221807 23.4339538,43.7355554 C23.9356609,44.2321829 24.5425662,44.6174956 25.2546737,44.8914955 C25.966772,45.1483714 26.7597961,45.2768082 27.6337468,45.2768093 C28.4429491,45.2768082 29.2036045,45.156934 29.915716,44.9171833 C30.6439946,44.6774327 31.2751764,44.3263705 31.8092643,43.8639933 C32.343331,43.4016194 32.7641185,42.8279318 33.0716299,42.1429281 C33.3953013,41.4408047 33.5571425,40.635929 33.5571547,39.7282978 C33.5571425,38.6323023 33.2981963,37.6818635 32.780315,36.8769803 C32.2785937,36.0721121 31.501755,35.5497989 30.4497938,35.3100397 L30.4497938,35.2586641 C31.1133351,34.9332983 31.6716884,34.4537981 32.1248557,33.8201615 C32.5941851,33.1694222 32.8288558,32.4416088 32.828867,31.6367202 C32.8288558,30.797607 32.69129,30.0697936 32.4161706,29.4532779 C32.1572132,28.8367933 31.7930699,28.3316054 31.3237385,27.937713 C30.8543879,27.5438538 30.2960346,27.2527287 29.6486766,27.0643354 C29.0174867,26.8588534 28.3296598,26.7561033 27.5851948,26.7560851 C26.7274274,26.7561033 25.966772,26.9016664 25.3032256,27.1927733 C24.6558555,27.4839167 24.1136865,27.8863545 23.6767167,28.4000879 C23.2397419,28.9138557 22.899874,29.5303563 22.6571131,30.2495921 C22.4305335,30.9688579 22.3010609,31.765171 22.2686932,32.6385346 L24.332176,32.6385346 C24.3321719,32.1076707 24.3969093,31.6024827 24.5263859,31.1229686 C24.6558555,30.6434824 24.8500654,30.2239193 25.1090157,29.8642784 C25.3841422,29.5046685 25.7240101,29.222106 26.1286194,29.0165897 C26.5332175,28.7939804 27.0187423,28.6826676 27.5851948,28.6826515 C28.4591324,28.6826676 29.1874202,28.9309809 29.7700581,29.4275901 C30.3526797,29.9242316 30.6439946,30.652045 30.6440038,31.6110324 C30.6439946,32.1076707 30.5549808,32.5443589 30.3769643,32.9210972 C30.1989296,33.2807349 29.9561677,33.5889853 29.6486766,33.8458493 C29.3573536,34.0856106 29.0093946,34.2739857 28.6047975,34.4109754 C28.2163705,34.5308604 27.8036742,34.5907986 27.3667084,34.5907879 C27.2210444,34.5907986 27.0753874,34.5907986 26.9297356,34.5907879 C26.7840726,34.5907986 26.6303224,34.582236 26.4684862,34.5651001 L26.4684862,36.4146042 Z M13.808,55.508 L12.524,55.508 L12.524,54.716 L16.034,54.716 L16.034,55.508 L14.75,55.508 L14.75,59 L13.808,59 L13.808,55.508 Z M16.526,54.716 L17.852,54.716 L18.854,57.662 L18.866,57.662 L19.814,54.716 L21.14,54.716 L21.14,59 L20.258,59 L20.258,55.964 L20.246,55.964 L19.196,59 L18.47,59 L17.42,55.994 L17.408,55.994 L17.408,59 L16.526,59 L16.526,54.716 Z"/> + </g> + </g> +</svg> diff --git a/addons/microsoft_calendar/static/src/img/calendar_outlook_32.png b/addons/microsoft_calendar/static/src/img/calendar_outlook_32.png Binary files differnew file mode 100644 index 00000000000..78553f07f7e --- /dev/null +++ b/addons/microsoft_calendar/static/src/img/calendar_outlook_32.png diff --git a/addons/microsoft_calendar/static/src/js/microsoft_calendar.js b/addons/microsoft_calendar/static/src/js/microsoft_calendar.js new file mode 100644 index 00000000000..41ad9e23675 --- /dev/null +++ b/addons/microsoft_calendar/static/src/js/microsoft_calendar.js @@ -0,0 +1,152 @@ +odoo.define('microsoft_calendar.CalendarView', function (require) { +"use strict"; + +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var framework = require('web.framework'); +const CalendarView = require('calendar.CalendarView'); +const CalendarRenderer = require('calendar.CalendarRenderer'); +const CalendarController = require('calendar.CalendarController'); +const CalendarModel = require('calendar.CalendarModel'); +const viewRegistry = require('web.view_registry'); + +var _t = core._t; + +const MicrosoftCalendarModel = CalendarModel.include({ + + /** + * @override + * @returns {Promise} + */ + async _loadCalendar() { + const _super = this._super.bind(this); + try { + await Promise.race([ + new Promise(resolve => setTimeout(resolve, 1000)), + this._syncMicrosoftCalendar(true) + ]); + } catch (error) { + if (error.event) { + error.event.preventDefault(); + } + console.error("Could not synchronize Microsoft events now.", error); + } + return _super(...arguments); + }, + + _syncMicrosoftCalendar(shadow = false) { + return this._rpc({ + route: '/microsoft_calendar/sync_data', + params: { + model: this.modelName, + fromurl: window.location.href, + } + }, {shadow}); + }, +}); + +const MicrosoftCalendarController = CalendarController.include({ + custom_events: _.extend({}, CalendarController.prototype.custom_events, { + syncMicrosoftCalendar: '_onSyncMicrosoftCalendar', + }), + + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Try to sync the calendar with Microsoft Calendar. According to the result + * from Microsoft API, this function may require an action of the user by the + * mean of a dialog. + * + * @private + * @returns {OdooEvent} event + */ + _onSyncMicrosoftCalendar: function (event) { + var self = this; + + return this.model._syncMicrosoftCalendar().then(function (o) { + if (o.status === "need_auth") { + Dialog.alert(self, _t("You will be redirected to Microsoft to authorize the access to your calendar."), { + confirm_callback: function() { + framework.redirect(o.url); + }, + title: _t('Redirection'), + }); + } else if (o.status === "need_config_from_admin") { + if (!_.isUndefined(o.action) && parseInt(o.action)) { + Dialog.confirm(self, _t("The Microsoft Synchronization needs to be configured before you can use it, do you want to do it now?"), { + confirm_callback: function() { + self.do_action(o.action); + }, + title: _t('Configuration'), + }); + } else { + Dialog.alert(self, _t("An administrator needs to configure Microsoft Synchronization before you can use it!"), { + title: _t('Configuration'), + }); + } + } else if (o.status === "need_refresh") { + self.reload(); + } + }).then(event.data.on_always, event.data.on_always); + } +}); + +const MicrosoftCalendarRenderer = CalendarRenderer.include({ + events: _.extend({}, CalendarRenderer.prototype.events, { + 'click .o_microsoft_sync_button': '_onSyncMicrosoftCalendar', + }), + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Adds the Sync with Microsoft button in the sidebar + * + * @private + */ + _initSidebar: function () { + var self = this; + this._super.apply(this, arguments); + this.$microsoftButton = $(); + if (this.model === "calendar.event") { + this.$microsoftButton = $('<button/>', {type: 'button', html: _t("Sync with <b>Microsoft</b>")}) + .addClass('o_microsoft_sync_button oe_button btn btn-secondary') + .prepend($('<img/>', { + src: "/microsoft_calendar/static/src/img/calendar_outlook_32.png", + })) + .appendTo(self.$sidebar); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Requests to sync the calendar with Microsoft Calendar + * + * @private + */ + _onSyncMicrosoftCalendar: function () { + var self = this; + var context = this.getSession().user_context; + this.$microsoftButton.prop('disabled', true); + this.trigger_up('syncMicrosoftCalendar', { + on_always: function () { + self.$microsoftButton.prop('disabled', false); + }, + }); + }, +}); + +return { + MicrosoftCalendarController, + MicrosoftCalendarModel, + MicrosoftCalendarRenderer, +}; + +}); diff --git a/addons/microsoft_calendar/static/src/scss/microsoft_calendar.scss b/addons/microsoft_calendar/static/src/scss/microsoft_calendar.scss new file mode 100644 index 00000000000..b223bcdf965 --- /dev/null +++ b/addons/microsoft_calendar/static/src/scss/microsoft_calendar.scss @@ -0,0 +1,12 @@ +.o_microsoft_sync_button { + display: block; + margin: auto; + > img { + margin-right: 10px; + } +} + +img.calendar_img_tuto { + margin: 0 0 10px 50px; + border: 2px solid $o-brand-secondary; +} diff --git a/addons/microsoft_calendar/tests/__init__.py b/addons/microsoft_calendar/tests/__init__.py new file mode 100644 index 00000000000..fb1d7b300ae --- /dev/null +++ b/addons/microsoft_calendar/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_sync_microsoft2odoo diff --git a/addons/microsoft_calendar/tests/test_sync_microsoft2odoo.py b/addons/microsoft_calendar/tests/test_sync_microsoft2odoo.py new file mode 100644 index 00000000000..13b6f5a155f --- /dev/null +++ b/addons/microsoft_calendar/tests/test_sync_microsoft2odoo.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftEvent +import pytz +from datetime import datetime, date +from odoo.tests.common import SavepointCase +from dateutil.relativedelta import relativedelta + + +class TestSyncMicrosoft2Odoo(SavepointCase): + + @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.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") diff --git a/addons/microsoft_calendar/utils/__init__.py b/addons/microsoft_calendar/utils/__init__.py new file mode 100644 index 00000000000..15a47c3f516 --- /dev/null +++ b/addons/microsoft_calendar/utils/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import microsoft_calendar +from . import microsoft_event diff --git a/addons/microsoft_calendar/utils/microsoft_calendar.py b/addons/microsoft_calendar/utils/microsoft_calendar.py new file mode 100644 index 00000000000..04029468248 --- /dev/null +++ b/addons/microsoft_calendar/utils/microsoft_calendar.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import requests +import json +import logging + +from werkzeug import urls + +from odoo import api, _ +from odoo.addons.microsoft_calendar.utils.microsoft_event import MicrosoftEvent +from odoo.addons.microsoft_account.models.microsoft_service import TIMEOUT + + +_logger = logging.getLogger(__name__) + +def requires_auth_token(func): + def wrapped(self, *args, **kwargs): + if not kwargs.get('token'): + raise AttributeError("An authentication token is required") + return func(self, *args, **kwargs) + return wrapped + +class InvalidSyncToken(Exception): + pass + +class MicrosoftCalendarService(): + + def __init__(self, microsoft_service): + self.microsoft_service = microsoft_service + + @requires_auth_token + def get_events(self, sync_token=None, token=None, timeout=TIMEOUT): + url = "/v1.0/me/calendarView/delta" + headers = {'Content-type': 'application/json', 'Authorization': 'Bearer %s' % token} + params = {} + if sync_token: + params['$deltatoken'] = sync_token + else: + params['startDateTime'] = '2016-12-01T00:00:00Z' + params['endDateTime'] = '2030-1-01T00:00:00Z' + try: + status, data, time = self.microsoft_service._do_request(url, params, headers, method='GET', timeout=timeout) + except requests.HTTPError as e: + if e.response.status_code == 410 and 'fullSyncRequired' in str(e.response.content): + raise InvalidSyncToken("Invalid sync token. Full sync required") + raise e + + events = data.get('value', []) + next_page_token = data.get('@odata.nextLink') + while next_page_token: + status, data, time = self.microsoft_service._do_request(next_page_token, {}, headers, preuri='', method='GET', timeout=timeout) + next_page_token = data.get('@odata.nextLink') + events += data.get('value', []) + + next_sync_token_url = data.get('@odata.deltaLink') + next_sync_token = urls.url_parse(next_sync_token_url).decode_query().get('$deltatoken', False) + + default_reminders = data.get('defaultReminders') + + return MicrosoftEvent(events), next_sync_token, default_reminders + + @requires_auth_token + def insert(self, values, token=None, timeout=TIMEOUT): + url = "/v1.0/me/calendar/events" + headers = {'Content-type': 'application/json', 'Authorization': 'Bearer %s' % token} + if not values.get('id'): + values.pop('id', None) + status, data, time = self.microsoft_service._do_request(url, json.dumps(values), headers, method='POST', timeout=timeout) + return data['id'] + + @requires_auth_token + def patch(self, event_id, values, token=None, timeout=TIMEOUT): + url = "/v1.0/me/calendar/events/%s" % event_id + headers = {'Content-type': 'application/json', 'Authorization': 'Bearer %s' % token} + self.microsoft_service._do_request(url, json.dumps(values), headers, method='PATCH', timeout=timeout) + + @requires_auth_token + def delete(self, event_id, token=None, timeout=TIMEOUT): + url = "/v1.0/me/calendar/events/%s" % event_id + headers = {'Authorization': 'Bearer %s' % token} + params = {} + try: + self.microsoft_service._do_request(url, params, headers=headers, method='DELETE', timeout=timeout) + except requests.HTTPError as e: + # For some unknown reason Microsoft can also return a 403 response when the event is already cancelled. + if e.response.status_code not in (410, 403): + raise e + _logger.info("Microsoft event %s was already deleted" % event_id) + + ##################################### + ## MANAGE CONNEXION TO MICROSOFT ## + ##################################### + + def is_authorized(self, user): + return bool(user.sudo().microsoft_calendar_rtoken) + + def _get_calendar_scope(self): + return 'offline_access openid Calendars.ReadWrite' + + def _microsoft_authentication_url(self, from_url='http://www.odoo.com'): + return self.microsoft_service._get_authorize_uri(from_url, service='calendar', scope=self._get_calendar_scope()) + + def _can_authorize_microsoft(self, user): + return user.has_group('base.group_erp_manager') diff --git a/addons/microsoft_calendar/utils/microsoft_event.py b/addons/microsoft_calendar/utils/microsoft_event.py new file mode 100644 index 00000000000..cd7ee255daa --- /dev/null +++ b/addons/microsoft_calendar/utils/microsoft_event.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.api import model +from typing import Iterator, Mapping +from collections import abc + + +class MicrosoftEvent(abc.Set): + """This helper class holds the values of a Microsoft event. + Inspired by Odoo recordset, one instance can be a single Microsoft event or a + (immutable) set of Microsoft events. + All usual set operations are supported (union, intersection, etc). + + :param iterable: iterable of MicrosoftCalendar instances or iterable of dictionnaries + + """ + + def __init__(self, iterable=()): + self._events = {} + for item in iterable: + if isinstance(item, self.__class__): + self._events[item.id] = item._events[item.id] + elif isinstance(item, Mapping): + self._events[item.get('id')] = item + else: + raise ValueError("Only %s or iterable of dict are supported" % self.__class__.__name__) + + def __iter__(self) -> Iterator['MicrosoftEvent']: + return iter(MicrosoftEvent([vals]) for vals in self._events.values()) + + def __contains__(self, microsoft_event): + return microsoft_event.id in self._events + + def __len__(self): + return len(self._events) + + def __bool__(self): + return bool(self._events) + + def __getattr__(self, name): + # ensure_one + try: + event, = self._events.keys() + except ValueError: + raise ValueError("Expected singleton: %s" % self) + event_id = list(self._events.keys())[0] + return self._events[event_id].get(name) + + def __repr__(self): + return '%s%s' % (self.__class__.__name__, self.ids) + + @property + def ids(self): + return tuple(e.id for e in self) + + def microsoft_ids(self): + return tuple(e.id for e in self) + + def odoo_id(self, env): + self.odoo_ids(env) # load ids + return self._odoo_id + + def _meta_odoo_id(self, microsoft_guid): + """Returns the Odoo id stored in the Microsoft Event metadata. + This id might not actually exists in the database. + """ + if self.singleValueExtendedProperties: + o_id = [prop['value'] for prop in self.singleValueExtendedProperties if prop['id'] == 'String {%s} Name odoo_id' % microsoft_guid][0] + return int(o_id) + + def odoo_ids(self, env): + ids = tuple(e._odoo_id for e in self if e._odoo_id) + if len(ids) == len(self): + return ids + found = self._load_odoo_ids_from_db(env) + unsure = self - found + if unsure: + unsure._load_odoo_ids_from_metadata(env) + + return tuple(e._odoo_id for e in self) + + def _load_odoo_ids_from_metadata(self, env): + model_env = self._get_model(env) + microsoft_guid = env['ir.config_parameter'].sudo().get_param('microsoft_calendar.microsoft_guid', False) + unsure_odoo_ids = tuple(e._meta_odoo_id(microsoft_guid) for e in self) + odoo_events = model_env.browse(_id for _id in unsure_odoo_ids if _id) + + # Extended properties are copied when splitting a recurrence Microsoft side. + # Hence, we may have two Microsoft recurrences linked to the same Odoo id. + # Therefore, we only consider Odoo records without microsoft id when trying + # to match events. + o_ids = odoo_events.exists().filtered(lambda e: not e.microsoft_id).ids + for e in self: + odoo_id = e._meta_odoo_id(microsoft_guid) + if odoo_id in o_ids: + e._events[e.id]['_odoo_id'] = odoo_id + + def _load_odoo_ids_from_db(self, env): + model_env = self._get_model(env) + odoo_events = model_env.with_context(active_test=False)._from_microsoft_ids(self.ids).with_env(env) + mapping = {e.microsoft_id: e.id for e in odoo_events} + existing_microsoft_ids = odoo_events.mapped('microsoft_id') + for e in self: + odoo_id = mapping.get(e.id) + if odoo_id: + e._events[e.id]['_odoo_id'] = odoo_id + return self.filter(lambda e: e.id in existing_microsoft_ids) + + def owner(self, env): + # Owner/organizer could be desynchronised between Microsoft and Odoo. + # Let userA, userB be two new users (never synced to Microsoft before). + # UserA creates an event in Odoo (he is the owner) but userB syncs first. + # There is no way to insert the event into userA's calendar since we don't have + # any authentication access. The event is therefore inserted into userB's calendar + # (he is the orginizer in Microsoft). The "real" owner (in Odoo) is stored as an + # extended property. There is currently no support to "transfert" ownership when + # userA syncs his calendar the first time. + if self.singleValueExtendedProperties: + microsoft_guid = env['ir.config_parameter'].sudo().get_param('microsoft_calendar.microsoft_guid', False) + real_owner_id = [prop['value'] for prop in self.singleValueExtendedProperties if prop['id'] == 'String {%s} Name owner_odoo_id' % microsoft_guid][0] + real_owner = real_owner_id and env['res.users'].browse(int(real_owner_id)) + else: + real_owner_id = False + + if real_owner_id and real_owner.exists(): + return real_owner + elif self.isOrganizer: + return env.user + elif self.organizer and self.organizer.get('emailAddress') and self.organizer.get('emailAddress').get('address'): + # In Microsoft: 1 email = 1 user; but in Odoo several users might have the same email + return env['res.users'].search([('email', '=', self.organizer.get('emailAddress').get('address'))], limit=1) + else: + return env['res.users'] + + def filter(self, func) -> 'MicrosoftEvent': + return MicrosoftEvent(e for e in self if func(e)) + + def is_recurrence(self): + return self.type == 'seriesMaster' + + def is_recurrent(self): + return bool(self.seriesMasterId or self.is_recurrence()) + + def is_recurrent_not_master(self): + return bool(self.seriesMasterId) + + def get_recurrence(self): + if not self.recurrence: + return {} + pattern = self.recurrence['pattern'] + range = self.recurrence['range'] + end_type_dict = { + 'endDate': 'end_date', + 'noEnd': 'forever', + 'numbered': 'count', + } + type_dict = { + 'absoluteMonthly': 'monthly', + 'relativeMonthly': 'monthly', + 'absoluteYearly': 'yearly', + 'relativeYearly': 'yearly', + } + index_dict = { + 'first': '1', + 'second': '2', + 'third': '3', + 'fourth': '4', + 'last': '-1', + } + rrule_type = type_dict.get(pattern['type'], pattern['type']) + interval = pattern['interval'] + if rrule_type == 'yearly': + interval *= 12 + result = { + 'rrule_type': rrule_type, + 'end_type': end_type_dict.get(range['type'], False), + 'interval': interval, + 'count': range['numberOfOccurrences'], + 'day': pattern['dayOfMonth'], + 'byday': index_dict.get(pattern['index'], False), + 'until': range['endDate'], + } + + month_by_dict = { + 'absoluteMonthly': 'date', + 'relativeMonthly': 'day', + 'absoluteYearly': 'date', + 'relativeYearly': 'day', + } + month_by = month_by_dict.get(pattern['type'], False) + if month_by: + result['month_by'] = month_by + + week_days = [x[:2] for x in pattern.get('daysOfWeek', [])] + for week_day in ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su']: + result[week_day] = week_day in week_days + if week_days: + result['weekday'] = week_days[0].upper() + return result + + def is_cancelled(self): + return bool(self.isCancelled or (self.__getattr__('@removed') and self.__getattr__('@removed').get('reason') == 'deleted')) + + def is_recurrence_outlier(self): + return bool(self.originalStartTime) + + def cancelled(self): + return self.filter(lambda e: e.is_cancelled()) + + def exists(self, env) -> 'MicrosoftEvent': + recurrences = self.filter(MicrosoftEvent.is_recurrence) + events = self - recurrences + recurrences.odoo_ids(env) + events.odoo_ids(env) + + return self.filter(lambda e: e._odoo_id) + + def _get_model(self, env): + if all([e.is_recurrence() for e in self]): + return env['calendar.recurrence'] + if all([not e.is_recurrence() for e in self]): + return env['calendar.event'] + raise TypeError("Mixing Microsoft events and Microsoft recurrences") diff --git a/addons/microsoft_calendar/views/microsoft_calendar_templates.xml b/addons/microsoft_calendar/views/microsoft_calendar_templates.xml new file mode 100644 index 00000000000..596d90ae8dc --- /dev/null +++ b/addons/microsoft_calendar/views/microsoft_calendar_templates.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <template id="assets_backend" name="microsoft_calendar assets" inherit_id="web.assets_backend"> + <xpath expr="." position="inside"> + <link rel="stylesheet" type="text/scss" href="/microsoft_calendar/static/src/scss/microsoft_calendar.scss"/> + <script type="text/javascript" src="/microsoft_calendar/static/src/js/microsoft_calendar.js"></script> + </xpath> + </template> +</odoo> diff --git a/addons/microsoft_calendar/views/res_config_settings_views.xml b/addons/microsoft_calendar/views/res_config_settings_views.xml new file mode 100644 index 00000000000..4156b7a353f --- /dev/null +++ b/addons/microsoft_calendar/views/res_config_settings_views.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="res_config_settings_view_form" model="ir.ui.view"> + <field name="name">res.config.settings.view.form.inherit.microsoft.calendar</field> + <field name="model">res.config.settings</field> + <field name="inherit_id" ref="base_setup.res_config_settings_view_form"/> + <field name="arch" type="xml"> + <div id="msg_module_microsoft_calendar" position="replace"> + <div class="content-group" attrs="{'invisible': [('module_microsoft_calendar','=',False)]}"> + <div class="mt16 row"> + <label for="cal_microsoft_client_id" string="Client ID" class="col-3 col-lg-3 o_light_label"/> + <field name="cal_microsoft_client_id" nolabel="1"/> + <label for="cal_microsoft_client_secret" string="Client Secret" class="col-3 col-lg-3 o_light_label"/> + <field name="cal_microsoft_client_secret" password="True" nolabel="1"/> + </div> + </div> + </div> + </field> + </record> +</odoo> diff --git a/addons/microsoft_calendar/views/res_users_views.xml b/addons/microsoft_calendar/views/res_users_views.xml new file mode 100644 index 00000000000..9b404dc5bfe --- /dev/null +++ b/addons/microsoft_calendar/views/res_users_views.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + + <record id="view_users_form" model="ir.ui.view"> + <field name="name">res.users.form</field> + <field name="model">res.users</field> + <field name="inherit_id" ref="base.view_users_form"/> + <field name="arch" type="xml"> + <notebook colspan="4" position="inside"> + <page string="Calendar" name="calendar" groups="base.group_system"> + <group> + <field name="microsoft_calendar_rtoken" readonly="1"/> + <field name="microsoft_calendar_token" readonly="1"/> + <field name="microsoft_calendar_token_validity" readonly="1"/> + <field name="microsoft_calendar_sync_token" readonly="1"/> + </group> + <button + string="Reset Account" class="btn btn-secondary" + type="action" name="%(microsoft_calendar_reset_account_action)d" + context="{'default_user_id': active_id}"/> + </page> + </notebook> + </field> + </record> + +</odoo> diff --git a/addons/microsoft_calendar/wizard/__init__.py b/addons/microsoft_calendar/wizard/__init__.py new file mode 100644 index 00000000000..6e2f3e5df4e --- /dev/null +++ b/addons/microsoft_calendar/wizard/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import reset_account diff --git a/addons/microsoft_calendar/wizard/reset_account.py b/addons/microsoft_calendar/wizard/reset_account.py new file mode 100644 index 00000000000..1a21a29250d --- /dev/null +++ b/addons/microsoft_calendar/wizard/reset_account.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + +from odoo.addons.microsoft_calendar.models.microsoft_sync import microsoft_calendar_token +from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService + + +class ResetMicrosoftAccount(models.TransientModel): + _name = 'microsoft.calendar.account.reset' + _description = 'Microsoft Calendar Account Reset' + + user_id = fields.Many2one('res.users', required=True) + delete_policy = fields.Selection( + [('dont_delete', "Leave them untouched"), + ('delete_microsoft', "Delete from the current Microsoft Calendar account"), + ('delete_odoo', "Delete from Odoo"), + ('delete_both', "Delete from both"), + ], string="User's Existing Events", required=True, default='dont_delete', + help="This will only affect events for which the user is the owner") + sync_policy = fields.Selection([ + ('new', "Synchronize only new events"), + ('all', "Synchronize all existing events"), + ], string="Next Synchronization", required=True, default='new') + + def reset_account(self): + microsoft = MicrosoftCalendarService(self.env['microsoft.service']) + + events = self.env['calendar.event'].search([ + ('user_id', '=', self.user_id.id), + ('microsoft_id', '!=', False)]) + if self.delete_policy in ('delete_microsoft', 'delete_both'): + with microsoft_calendar_token(self.user_id) as token: + for event in events: + microsoft.delete(event.microsoft_id, token=token) + + if self.delete_policy in ('delete_odoo', 'delete_both'): + events.microsoft_id = False + events.unlink() + + if self.sync_policy == 'all': + events.write({ + 'microsoft_id': False, + 'need_sync_m': True, + }) + + self.user_id._set_microsoft_auth_tokens(False, False, 0) + self.user_id.write({ + 'microsoft_calendar_sync_token': False, + }) diff --git a/addons/microsoft_calendar/wizard/reset_account_views.xml b/addons/microsoft_calendar/wizard/reset_account_views.xml new file mode 100644 index 00000000000..0fb789fe701 --- /dev/null +++ b/addons/microsoft_calendar/wizard/reset_account_views.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="microsoft_calendar_reset_account_view_form" model="ir.ui.view"> + <field name="name">microsoft.calendar.account.reset.form</field> + <field name="model">microsoft.calendar.account.reset</field> + <field name="arch" type="xml"> + <form> + <h1>Reset Microsoft Calendar Account</h1> + <group> + <field name="delete_policy" widget="radio"/> + <field name="sync_policy" widget="radio"/> + </group> + + <footer> + <button name="reset_account" string="Confirm" type="object" class="btn-primary"/> + <button string="Cancel" class="btn-secondary" special="cancel" /> + </footer> + </form> + </field> + </record> + + <record id="microsoft_calendar_reset_account_action" model="ir.actions.act_window"> + <field name="name"></field> + <field name="type">ir.actions.act_window</field> + <field name="res_model">microsoft.calendar.account.reset</field> + <field name="view_mode">form</field> + <field name="target">new</field> + </record> +</odoo> |
