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:
'
'+
@@ -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 @@
-
-
-
- calendar.event.view.calendar.inherit
- calendar.event
-
-
-
- google_sync_calendar
-
-
-
-
\ 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 @@
+
+
+
+
+ microsoft_redirect_uri
+ urn:ietf:wg:oauth:2.0:oob
+
+
+
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
new file mode 100644
index 00000000000..b8734363b98
Binary files /dev/null and b/addons/microsoft_calendar/static/description/icon.png differ
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 @@
+
diff --git a/addons/microsoft_calendar/static/src/img/calendar_outlook_32.png b/addons/microsoft_calendar/static/src/img/calendar_outlook_32.png
new file mode 100644
index 00000000000..78553f07f7e
Binary files /dev/null and b/addons/microsoft_calendar/static/src/img/calendar_outlook_32.png differ
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 = $('', {type: 'button', html: _t("Sync with Microsoft")})
+ .addClass('o_microsoft_sync_button oe_button btn btn-secondary')
+ .prepend($('
', {
+ 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 @@
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+ res.config.settings.view.form.inherit.microsoft.calendar
+ res.config.settings
+
+
+
+
+
+
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 @@
+
+
+
+
+ res.users.form
+ res.users
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+ microsoft.calendar.account.reset.form
+ microsoft.calendar.account.reset
+
+
+
+
+
+
+
+ ir.actions.act_window
+ microsoft.calendar.account.reset
+ form
+ new
+
+