[ADD] calendar_microsoft: Synchronize Microsoft calendar with Odoo

TaskID 2223219

closes odoo/odoo#54936

Related: odoo/upgrade#1557
Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
jbm-odoo
2020-08-05 13:47:42 +00:00
parent 78ef2ec813
commit 9dc3057e8e
44 changed files with 2257 additions and 44 deletions
@@ -19,6 +19,8 @@ class ResConfigSettings(models.TransientModel):
module_base_import = fields.Boolean("Allow users to import data from CSV/XLS/XLSX/ODS files")
module_google_calendar = fields.Boolean(
string='Allow the users to synchronize their calendar with Google Calendar')
module_microsoft_calendar = fields.Boolean(
string='Allow the users to synchronize their calendar with Office 365 Calendar')
module_google_drive = fields.Boolean("Attach Google documents to any record")
module_google_spreadsheet = fields.Boolean("Google Spreadsheet")
module_auth_oauth = fields.Boolean("Use external authentication providers (OAuth)")
@@ -241,6 +241,20 @@
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" id="sync_google_calendar_setting">
<div class="o_setting_left_pane">
<field name="module_microsoft_calendar" />
</div>
<div class="o_setting_right_pane">
<label string="Office 365 Calendar" for="module_microsoft_calendar"/>
<div class="text-muted">
Synchronize your calendar with Outlook
</div>
<div class="content-group" attrs="{'invisible': [('module_microsoft_calendar', '=', False)]}" id="msg_module_microsoft_calendar">
<div class="text-warning mt16"><strong>Save</strong> this page and come back here to set up the feature.</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" id="sync_google_calendar_setting">
<div class="o_setting_left_pane">
<field name="module_google_calendar" />
+8 -1
View File
@@ -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'):
+16 -3
View File
@@ -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)
-1
View File
@@ -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',
@@ -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'])
@@ -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,
@@ -1,7 +1,7 @@
odoo.define('google_calendar.calendar_tests', function (require) {
"use strict";
var { GoogleCalendarView } = require('google_calendar.CalendarView');
var GoogleCalendarView = require('calendar.CalendarView');
var testUtils = require('web.test_utils');
var createCalendarView = testUtils.createCalendarView;
@@ -78,7 +78,7 @@ QUnit.module('Google Calendar', {
data: this.data,
arch:
'<calendar class="o_calendar_test" '+
'js_class="google_sync_calendar" '+
'js_class="attendee_calendar" '+
'date_start="start" '+
'date_stop="stop" '+
'mode="month">'+
@@ -96,6 +96,8 @@ QUnit.module('Google Calendar', {
return Promise.resolve({status: 'need_refresh'});
} else if (route === '/web/dataset/call_kw/calendar.event/search_read') {
assert.step(route);
} else if (route === '/microsoft_calendar/sync_data') {
return Promise.resolve();
}
return this._super.apply(this, arguments);
},
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="calendar_event_view_calendar" model="ir.ui.view">
<field name="name">calendar.event.view.calendar.inherit</field>
<field name="model">calendar.event</field>
<field name="inherit_id" ref="calendar.view_calendar_event_calendar"/>
<field name="arch" type="xml">
<xpath expr="//calendar" position="attributes">
<attribute name="js_class">google_sync_calendar</attribute>
</xpath>
</field>
</record>
</odoo>
+5
View File
@@ -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
+15
View File
@@ -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',
],
}
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
@@ -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"))
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="config_microsoft_redirect_uri" model="ir.config_parameter">
<field name="key">microsoft_redirect_uri</field>
<field name="value">urn:ietf:wg:oauth:2.0:oob</field>
</record>
</data>
</odoo>
@@ -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
@@ -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)
@@ -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,
})
+20
View File
@@ -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()))
+22
View File
@@ -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',
}
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
@@ -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"}
@@ -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
@@ -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'
@@ -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
@@ -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()
@@ -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='')
@@ -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))
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 microsoft_calendar_account_reset microsoft_calendar_account_reset_access_right model_microsoft_calendar_account_reset base.group_system 1 1 1 0
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

@@ -0,0 +1,152 @@
odoo.define('microsoft_calendar.CalendarView', function (require) {
"use strict";
var core = require('web.core');
var Dialog = require('web.Dialog');
var framework = require('web.framework');
const CalendarView = require('calendar.CalendarView');
const CalendarRenderer = require('calendar.CalendarRenderer');
const CalendarController = require('calendar.CalendarController');
const CalendarModel = require('calendar.CalendarModel');
const viewRegistry = require('web.view_registry');
var _t = core._t;
const MicrosoftCalendarModel = CalendarModel.include({
/**
* @override
* @returns {Promise}
*/
async _loadCalendar() {
const _super = this._super.bind(this);
try {
await Promise.race([
new Promise(resolve => setTimeout(resolve, 1000)),
this._syncMicrosoftCalendar(true)
]);
} catch (error) {
if (error.event) {
error.event.preventDefault();
}
console.error("Could not synchronize Microsoft events now.", error);
}
return _super(...arguments);
},
_syncMicrosoftCalendar(shadow = false) {
return this._rpc({
route: '/microsoft_calendar/sync_data',
params: {
model: this.modelName,
fromurl: window.location.href,
}
}, {shadow});
},
});
const MicrosoftCalendarController = CalendarController.include({
custom_events: _.extend({}, CalendarController.prototype.custom_events, {
syncMicrosoftCalendar: '_onSyncMicrosoftCalendar',
}),
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Try to sync the calendar with Microsoft Calendar. According to the result
* from Microsoft API, this function may require an action of the user by the
* mean of a dialog.
*
* @private
* @returns {OdooEvent} event
*/
_onSyncMicrosoftCalendar: function (event) {
var self = this;
return this.model._syncMicrosoftCalendar().then(function (o) {
if (o.status === "need_auth") {
Dialog.alert(self, _t("You will be redirected to Microsoft to authorize the access to your calendar."), {
confirm_callback: function() {
framework.redirect(o.url);
},
title: _t('Redirection'),
});
} else if (o.status === "need_config_from_admin") {
if (!_.isUndefined(o.action) && parseInt(o.action)) {
Dialog.confirm(self, _t("The Microsoft Synchronization needs to be configured before you can use it, do you want to do it now?"), {
confirm_callback: function() {
self.do_action(o.action);
},
title: _t('Configuration'),
});
} else {
Dialog.alert(self, _t("An administrator needs to configure Microsoft Synchronization before you can use it!"), {
title: _t('Configuration'),
});
}
} else if (o.status === "need_refresh") {
self.reload();
}
}).then(event.data.on_always, event.data.on_always);
}
});
const MicrosoftCalendarRenderer = CalendarRenderer.include({
events: _.extend({}, CalendarRenderer.prototype.events, {
'click .o_microsoft_sync_button': '_onSyncMicrosoftCalendar',
}),
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Adds the Sync with Microsoft button in the sidebar
*
* @private
*/
_initSidebar: function () {
var self = this;
this._super.apply(this, arguments);
this.$microsoftButton = $();
if (this.model === "calendar.event") {
this.$microsoftButton = $('<button/>', {type: 'button', html: _t("Sync with <b>Microsoft</b>")})
.addClass('o_microsoft_sync_button oe_button btn btn-secondary')
.prepend($('<img/>', {
src: "/microsoft_calendar/static/src/img/calendar_outlook_32.png",
}))
.appendTo(self.$sidebar);
}
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Requests to sync the calendar with Microsoft Calendar
*
* @private
*/
_onSyncMicrosoftCalendar: function () {
var self = this;
var context = this.getSession().user_context;
this.$microsoftButton.prop('disabled', true);
this.trigger_up('syncMicrosoftCalendar', {
on_always: function () {
self.$microsoftButton.prop('disabled', false);
},
});
},
});
return {
MicrosoftCalendarController,
MicrosoftCalendarModel,
MicrosoftCalendarRenderer,
};
});
@@ -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;
}
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_sync_microsoft2odoo
@@ -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")
@@ -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
@@ -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')
@@ -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")
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="assets_backend" name="microsoft_calendar assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<link rel="stylesheet" type="text/scss" href="/microsoft_calendar/static/src/scss/microsoft_calendar.scss"/>
<script type="text/javascript" src="/microsoft_calendar/static/src/js/microsoft_calendar.js"></script>
</xpath>
</template>
</odoo>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.microsoft.calendar</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base_setup.res_config_settings_view_form"/>
<field name="arch" type="xml">
<div id="msg_module_microsoft_calendar" position="replace">
<div class="content-group" attrs="{'invisible': [('module_microsoft_calendar','=',False)]}">
<div class="mt16 row">
<label for="cal_microsoft_client_id" string="Client ID" class="col-3 col-lg-3 o_light_label"/>
<field name="cal_microsoft_client_id" nolabel="1"/>
<label for="cal_microsoft_client_secret" string="Client Secret" class="col-3 col-lg-3 o_light_label"/>
<field name="cal_microsoft_client_secret" password="True" nolabel="1"/>
</div>
</div>
</div>
</field>
</record>
</odoo>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_users_form" model="ir.ui.view">
<field name="name">res.users.form</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form"/>
<field name="arch" type="xml">
<notebook colspan="4" position="inside">
<page string="Calendar" name="calendar" groups="base.group_system">
<group>
<field name="microsoft_calendar_rtoken" readonly="1"/>
<field name="microsoft_calendar_token" readonly="1"/>
<field name="microsoft_calendar_token_validity" readonly="1"/>
<field name="microsoft_calendar_sync_token" readonly="1"/>
</group>
<button
string="Reset Account" class="btn btn-secondary"
type="action" name="%(microsoft_calendar_reset_account_action)d"
context="{'default_user_id': active_id}"/>
</page>
</notebook>
</field>
</record>
</odoo>
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import reset_account
@@ -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,
})
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="microsoft_calendar_reset_account_view_form" model="ir.ui.view">
<field name="name">microsoft.calendar.account.reset.form</field>
<field name="model">microsoft.calendar.account.reset</field>
<field name="arch" type="xml">
<form>
<h1>Reset Microsoft Calendar Account</h1>
<group>
<field name="delete_policy" widget="radio"/>
<field name="sync_policy" widget="radio"/>
</group>
<footer>
<button name="reset_account" string="Confirm" type="object" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel" />
</footer>
</form>
</field>
</record>
<record id="microsoft_calendar_reset_account_action" model="ir.actions.act_window">
<field name="name"></field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">microsoft.calendar.account.reset</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>