[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:
@@ -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" />
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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()))
|
||||
@@ -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
|
||||
|
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>
|
||||
Reference in New Issue
Block a user