diff --git a/addons/base_setup/models/res_config_settings.py b/addons/base_setup/models/res_config_settings.py index 2248f46bfb1..04b695c141f 100644 --- a/addons/base_setup/models/res_config_settings.py +++ b/addons/base_setup/models/res_config_settings.py @@ -19,6 +19,8 @@ class ResConfigSettings(models.TransientModel): module_base_import = fields.Boolean("Allow users to import data from CSV/XLS/XLSX/ODS files") module_google_calendar = fields.Boolean( string='Allow the users to synchronize their calendar with Google Calendar') + module_microsoft_calendar = fields.Boolean( + string='Allow the users to synchronize their calendar with Office 365 Calendar') module_google_drive = fields.Boolean("Attach Google documents to any record") module_google_spreadsheet = fields.Boolean("Google Spreadsheet") module_auth_oauth = fields.Boolean("Use external authentication providers (OAuth)") diff --git a/addons/base_setup/views/res_config_settings_views.xml b/addons/base_setup/views/res_config_settings_views.xml index 8bfd02bae52..bda9ab28a96 100644 --- a/addons/base_setup/views/res_config_settings_views.xml +++ b/addons/base_setup/views/res_config_settings_views.xml @@ -241,6 +241,20 @@ +
+
+ +
+
+
+
diff --git a/addons/calendar/models/calendar_event.py b/addons/calendar/models/calendar_event.py index e23561ffa0b..874c09c99f0 100644 --- a/addons/calendar/models/calendar_event.py +++ b/addons/calendar/models/calendar_event.py @@ -244,6 +244,7 @@ class Meeting(models.Model): recurrency = fields.Boolean('Recurrent', help="Recurrent Event") recurrence_id = fields.Many2one( 'calendar.recurrence', string="Recurrence Rule", index=True) + follow_recurrence = fields.Boolean(default=False) # Indicates if an event follows the recurrence, i.e. is not an exception recurrence_update = fields.Selection([ ('self_only', "This event"), ('future_events', "This and following events"), @@ -559,7 +560,7 @@ class Meeting(models.Model): recurrence_vals += [dict(values, base_event_id=event.id, calendar_event_ids=[(4, event.id)])] elif future: to_update |= event.recurrence_id._split_from(event, values) - self.recurrency = True + self.write({'recurrency': True, 'follow_recurrence': True}) to_update |= self.env['calendar.recurrence'].create(recurrence_vals) return to_update._apply_recurrence() @@ -630,6 +631,10 @@ class Meeting(models.Model): if 'partner_ids' in values: values['attendee_ids'] = self._attendees_values(values['partner_ids']) + if (not recurrence_update_setting or recurrence_update_setting == 'self_only' and len(self) == 1) and 'follow_recurrence' not in values: + if any({field: values.get(field) for field in self.env['calendar.event']._get_time_fields() if field in values}): + values['follow_recurrence'] = False + previous_attendees = self.attendee_ids recurrence_values = {field: values.pop(field) for field in self._get_recurrent_fields() if field in values} @@ -702,9 +707,11 @@ class Meeting(models.Model): recurring_vals = [vals for vals in vals_list if vals.get('recurrency')] other_vals = [vals for vals in vals_list if not vals.get('recurrency')] events = super().create(other_vals) + for vals in recurring_vals: recurrence_values = {field: vals.pop(field) for field in recurrence_fields if field in vals} + vals['follow_recurrence'] = True event = super().create(vals) events |= event if vals.get('recurrency'): diff --git a/addons/calendar/models/calendar_recurrence.py b/addons/calendar/models/calendar_recurrence.py index 18c4a20b6f0..6c5edfe839c 100644 --- a/addons/calendar/models/calendar_recurrence.py +++ b/addons/calendar/models/calendar_recurrence.py @@ -71,6 +71,7 @@ BYDAY_SELECTION = [ ('1', 'First'), ('2', 'Second'), ('3', 'Third'), + ('4', 'Fourth'), ('-1', 'Last'), ] @@ -184,23 +185,35 @@ class RecurrenceRule(models.Model): ranges_to_create = (event_range for event_range in ranges if event_range not in existing_ranges) return synced_events, ranges_to_create - def _apply_recurrence(self): + def _apply_recurrence(self, specific_values_creation=None, no_send_edit=False): """Create missing events in the recurrence and detach events which no longer follow the recurrence rules. :return: detached events """ event_vals = [] keep = self.env['calendar.event'] + if specific_values_creation is None: + specific_values_creation = {} + for recurrence in self.filtered('base_event_id'): self.calendar_event_ids |= recurrence.base_event_id event = recurrence.base_event_id or recurrence._get_first_event(include_outliers=False) duration = event.stop - event.start - ranges = set(recurrence._get_ranges(event.start, duration)) + if specific_values_creation: + ranges = set([(x[1], x[2]) for x in specific_values_creation if x[0] == recurrence.id]) + else: + ranges = set(recurrence._get_ranges(event.start, duration)) events_to_keep, ranges = recurrence._reconcile_events(ranges) keep |= events_to_keep [base_values] = event.copy_data() - event_vals += [dict(base_values, start=start, stop=stop, recurrence_id=recurrence.id) for start, stop in ranges] + values = [] + for start, stop in ranges: + value = dict(base_values, start=start, stop=stop, recurrence_id=recurrence.id, follow_recurrence=True) + if (recurrence.id, start, stop) in specific_values_creation: + value.update(specific_values_creation[(recurrence.id, start, stop)]) + values += [value] + event_vals += values events = self.calendar_event_ids - keep detached_events = self._detach_events(events) diff --git a/addons/google_calendar/__manifest__.py b/addons/google_calendar/__manifest__.py index c84f9c57f53..a743f7528a2 100644 --- a/addons/google_calendar/__manifest__.py +++ b/addons/google_calendar/__manifest__.py @@ -11,7 +11,6 @@ 'data': [ 'data/google_calendar_data.xml', 'security/ir.model.access.csv', - 'views/calendar_event_views.xml', 'wizard/reset_account_views.xml', 'views/res_config_settings_views.xml', 'views/res_users_views.xml', diff --git a/addons/google_calendar/models/calendar_recurrence_rule.py b/addons/google_calendar/models/calendar_recurrence_rule.py index a8e33093a64..b3cd06db329 100644 --- a/addons/google_calendar/models/calendar_recurrence_rule.py +++ b/addons/google_calendar/models/calendar_recurrence_rule.py @@ -16,9 +16,9 @@ class RecurrenceRule(models.Model): # Don't sync by default. Sync only when the recurrence is applied need_sync = fields.Boolean(default=False) - def _apply_recurrence(self): + def _apply_recurrence(self, specific_values_creation=None, no_send_edit=False): events = self.calendar_event_ids - detached_events = super()._apply_recurrence() + detached_events = super()._apply_recurrence(specific_values_creation, no_send_edit) google_service = GoogleCalendarService(self.env['google.service']) diff --git a/addons/google_calendar/static/src/js/google_calendar.js b/addons/google_calendar/static/src/js/google_calendar.js index 4ba739e67ea..b19bafea384 100644 --- a/addons/google_calendar/static/src/js/google_calendar.js +++ b/addons/google_calendar/static/src/js/google_calendar.js @@ -12,7 +12,7 @@ const viewRegistry = require('web.view_registry'); var _t = core._t; -const GoogleCalendarModel = CalendarModel.extend({ +const GoogleCalendarModel = CalendarModel.include({ /** * @override @@ -23,7 +23,7 @@ const GoogleCalendarModel = CalendarModel.extend({ try { await Promise.race([ new Promise(resolve => setTimeout(resolve, 1000)), - this._syncCalendar(true) + this._syncGoogleCalendar(true) ]); } catch (error) { if (error.event) { @@ -34,7 +34,7 @@ const GoogleCalendarModel = CalendarModel.extend({ return _super(...arguments); }, - _syncCalendar(shadow = false) { + _syncGoogleCalendar(shadow = false) { var context = this.getSession().user_context; return this._rpc({ route: '/google_calendar/sync_data', @@ -47,9 +47,9 @@ const GoogleCalendarModel = CalendarModel.extend({ }, }) -const GoogleCalendarController = CalendarController.extend({ +const GoogleCalendarController = CalendarController.include({ custom_events: _.extend({}, CalendarController.prototype.custom_events, { - syncCalendar: '_onSyncCalendar', + syncGoogleCalendar: '_onGoogleSyncCalendar', }), @@ -65,10 +65,10 @@ const GoogleCalendarController = CalendarController.extend({ * @private * @returns {OdooEvent} event */ - _onSyncCalendar: function (event) { + _onGoogleSyncCalendar: function (event) { var self = this; - return this.model._syncCalendar().then(function (o) { + return this.model._syncGoogleCalendar().then(function (o) { if (o.status === "need_auth") { Dialog.alert(self, _t("You will be redirected to Google to authorize access to your calendar!"), { confirm_callback: function() { @@ -96,9 +96,9 @@ const GoogleCalendarController = CalendarController.extend({ } }); -const GoogleCalendarRenderer = CalendarRenderer.extend({ +const GoogleCalendarRenderer = CalendarRenderer.include({ events: _.extend({}, CalendarRenderer.prototype.events, { - 'click .o_google_sync_button': '_onSyncCalendar', + 'click .o_google_sync_button': '_onGoogleSyncCalendar', }), //-------------------------------------------------------------------------- @@ -133,11 +133,11 @@ const GoogleCalendarRenderer = CalendarRenderer.extend({ * * @private */ - _onSyncCalendar: function () { + _onGoogleSyncCalendar: function () { var self = this; var context = this.getSession().user_context; this.$googleButton.prop('disabled', true); - this.trigger_up('syncCalendar', { + this.trigger_up('syncGoogleCalendar', { on_always: function () { self.$googleButton.prop('disabled', false); }, @@ -145,18 +145,7 @@ const GoogleCalendarRenderer = CalendarRenderer.extend({ }, }); -var GoogleCalendarView = CalendarView.extend({ - config: _.extend({}, CalendarView.prototype.config, { - Controller: GoogleCalendarController, - Model: GoogleCalendarModel, - Renderer: GoogleCalendarRenderer, - }), -}); - -viewRegistry.add('google_sync_calendar', GoogleCalendarView); - return { - GoogleCalendarView, GoogleCalendarController, GoogleCalendarModel, GoogleCalendarRenderer, diff --git a/addons/google_calendar/static/tests/google_calendar_tests.js b/addons/google_calendar/static/tests/google_calendar_tests.js index 6ab0f8dacab..5ec1e475af5 100644 --- a/addons/google_calendar/static/tests/google_calendar_tests.js +++ b/addons/google_calendar/static/tests/google_calendar_tests.js @@ -1,7 +1,7 @@ odoo.define('google_calendar.calendar_tests', function (require) { "use strict"; -var { GoogleCalendarView } = require('google_calendar.CalendarView'); +var GoogleCalendarView = require('calendar.CalendarView'); var testUtils = require('web.test_utils'); var createCalendarView = testUtils.createCalendarView; @@ -78,7 +78,7 @@ QUnit.module('Google Calendar', { data: this.data, arch: ''+ @@ -96,6 +96,8 @@ QUnit.module('Google Calendar', { return Promise.resolve({status: 'need_refresh'}); } else if (route === '/web/dataset/call_kw/calendar.event/search_read') { assert.step(route); + } else if (route === '/microsoft_calendar/sync_data') { + return Promise.resolve(); } return this._super.apply(this, arguments); }, diff --git a/addons/google_calendar/views/calendar_event_views.xml b/addons/google_calendar/views/calendar_event_views.xml deleted file mode 100644 index 0f787832525..00000000000 --- a/addons/google_calendar/views/calendar_event_views.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - calendar.event.view.calendar.inherit - calendar.event - - - - google_sync_calendar - - - - \ No newline at end of file diff --git a/addons/microsoft_account/__init__.py b/addons/microsoft_account/__init__.py new file mode 100644 index 00000000000..8c67f893b37 --- /dev/null +++ b/addons/microsoft_account/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models +from . import controllers diff --git a/addons/microsoft_account/__manifest__.py b/addons/microsoft_account/__manifest__.py new file mode 100644 index 00000000000..fcecd91b85c --- /dev/null +++ b/addons/microsoft_account/__manifest__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Microsoft Users', + 'category': 'Hidden/Tools', + 'description': """ +The module adds Microsoft user in res user. +=========================================== +""", + 'depends': ['base_setup'], + 'data': [ + 'data/microsoft_account_data.xml', + ], +} diff --git a/addons/microsoft_account/controllers/__init__.py b/addons/microsoft_account/controllers/__init__.py new file mode 100644 index 00000000000..5d4b25db9c0 --- /dev/null +++ b/addons/microsoft_account/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import main diff --git a/addons/microsoft_account/controllers/main.py b/addons/microsoft_account/controllers/main.py new file mode 100644 index 00000000000..5256f82dca7 --- /dev/null +++ b/addons/microsoft_account/controllers/main.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json +from werkzeug.utils import redirect + +from odoo import http, registry +from odoo.http import request + + +class MicrosoftAuth(http.Controller): + + @http.route('/microsoft_account/authentication', type='http', auth="public") + def oauth2callback(self, **kw): + """ This route/function is called by Microsoft when user Accept/Refuse the consent of Microsoft """ + state = json.loads(kw['state']) + dbname = state.get('d') + service = state.get('s') + url_return = state.get('f') + + with registry(dbname).cursor() as cr: + if kw.get('code'): + access_token, refresh_token, ttl = request.env['microsoft.service']._get_microsoft_tokens(kw['code'], service) + request.env.user._set_microsoft_auth_tokens(access_token, refresh_token, ttl) + return redirect(url_return) + elif kw.get('error'): + return redirect("%s%s%s" % (url_return, "?error=", kw['error'])) + else: + return redirect("%s%s" % (url_return, "?error=Unknown_error")) diff --git a/addons/microsoft_account/data/microsoft_account_data.xml b/addons/microsoft_account/data/microsoft_account_data.xml new file mode 100644 index 00000000000..da61e3da8ed --- /dev/null +++ b/addons/microsoft_account/data/microsoft_account_data.xml @@ -0,0 +1,9 @@ + + + + + microsoft_redirect_uri + urn:ietf:wg:oauth:2.0:oob + + + diff --git a/addons/microsoft_account/models/__init__.py b/addons/microsoft_account/models/__init__.py new file mode 100644 index 00000000000..efaff2dc4e7 --- /dev/null +++ b/addons/microsoft_account/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import res_users +from . import microsoft_service diff --git a/addons/microsoft_account/models/microsoft_service.py b/addons/microsoft_account/models/microsoft_service.py new file mode 100644 index 00000000000..6b6de96aefb --- /dev/null +++ b/addons/microsoft_account/models/microsoft_service.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime +import json +import logging + +import requests +from werkzeug import urls + +from odoo import api, fields, models, _ + +_logger = logging.getLogger(__name__) + +TIMEOUT = 20 + +MICROSOFT_AUTH_ENDPOINT = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize' +MICROSOFT_TOKEN_ENDPOINT = 'https://login.microsoftonline.com/common/oauth2/v2.0/token' + + +class MicrosoftService(models.AbstractModel): + _name = 'microsoft.service' + _description = 'Microsoft Service' + + def _get_calendar_scope(self): + return 'offline_access openid Calendars.ReadWrite' + + @api.model + def generate_refresh_token(self, service, authorization_code): + """ Call Microsoft API to refresh the token, with the given authorization code + :param service : the name of the microsoft service to actualize + :param authorization_code : the code to exchange against the new refresh token + :returns the new refresh token + """ + Parameters = self.env['ir.config_parameter'].sudo() + client_id = Parameters.get_param('microsoft_%s_client_id' % service) + client_secret = Parameters.get_param('microsoft_%s_client_secret' % service) + redirect_uri = Parameters.get_param('microsoft_redirect_uri') + + scope = self._get_calendar_scope() + + # Get the Refresh Token From Microsoft And store it in ir.config_parameter + headers = {"Content-type": "application/x-www-form-urlencoded"} + data = { + 'client_id': client_id, + 'redirect_uri': redirect_uri, + 'client_secret': client_secret, + 'scope': scope, + 'grant_type': "refresh_token" + } + try: + req = requests.post(MICROSOFT_TOKEN_ENDPOINT, data=data, headers=headers, timeout=TIMEOUT) + req.raise_for_status() + content = req.json() + except IOError: + error_msg = _("Something went wrong during your token generation. Maybe your Authorization Code is invalid or already expired") + raise self.env['res.config.settings'].get_config_warning(error_msg) + + return content.get('refresh_token') + + @api.model + def _get_authorize_uri(self, from_url, service, scope): + """ This method return the url needed to allow this instance of Odoo to access to the scope + of gmail specified as parameters + """ + state = { + 'd': self.env.cr.dbname, + 's': service, + 'f': from_url + } + + get_param = self.env['ir.config_parameter'].sudo().get_param + base_url = get_param('web.base.url', default='http://www.odoo.com?NoBaseUrl') + client_id = get_param('microsoft_%s_client_id' % (service,), default=False) + + encoded_params = urls.url_encode({ + 'response_type': 'code', + 'client_id': client_id, + 'state': json.dumps(state), + 'scope': scope, + 'redirect_uri': base_url + '/microsoft_account/authentication', + 'prompt': 'consent', + 'access_type': 'offline' + }) + return "%s?%s" % (MICROSOFT_AUTH_ENDPOINT, encoded_params) + + @api.model + def _get_microsoft_tokens(self, authorize_code, service): + """ Call Microsoft API to exchange authorization code against token, with POST request, to + not be redirected. + """ + get_param = self.env['ir.config_parameter'].sudo().get_param + base_url = get_param('web.base.url', default='http://www.odoo.com?NoBaseUrl') + client_id = get_param('microsoft_%s_client_id' % (service,), default=False) + client_secret = get_param('microsoft_%s_client_secret' % (service,), default=False) + scope = self._get_calendar_scope() + + headers = {"content-type": "application/x-www-form-urlencoded"} + data = { + 'code': authorize_code, + 'client_id': client_id, + 'client_secret': client_secret, + 'grant_type': 'authorization_code', + 'scope': scope, + 'redirect_uri': base_url + '/microsoft_account/authentication' + } + try: + dummy, response, dummy = self._do_request(MICROSOFT_TOKEN_ENDPOINT, params=data, headers=headers, method='POST', preuri='') + access_token = response.get('access_token') + refresh_token = response.get('refresh_token') + ttl = response.get('expires_in') + return access_token, refresh_token, ttl + except requests.HTTPError: + error_msg = _("Something went wrong during your token generation. Maybe your Authorization Code is invalid") + raise self.env['res.config.settings'].get_config_warning(error_msg) + + @api.model + def _do_request(self, uri, params=None, headers=None, method='POST', preuri="https://graph.microsoft.com", timeout=TIMEOUT): + """ Execute the request to Microsoft API. Return a tuple ('HTTP_CODE', 'HTTP_RESPONSE') + :param uri : the url to contact + :param params : dict or already encoded parameters for the request to make + :param headers : headers of request + :param method : the method to use to make the request + :param preuri : pre url to prepend to param uri. + """ + if params is None: + params = {} + if headers is None: + headers = {} + + _logger.debug("Uri: %s - Type : %s - Headers: %s - Params : %s !" % (uri, method, headers, params)) + + ask_time = fields.Datetime.now() + try: + if method.upper() in ('GET', 'DELETE'): + res = requests.request(method.lower(), preuri + uri, headers=headers, params=params, timeout=timeout) + elif method.upper() in ('POST', 'PATCH', 'PUT'): + res = requests.request(method.lower(), preuri + uri, data=params, headers=headers, timeout=timeout) + else: + raise Exception(_('Method not supported [%s] not in [GET, POST, PUT, PATCH or DELETE]!', method)) + res.raise_for_status() + status = res.status_code + + if int(status) in (204, 404): # Page not found, no response + response = False + else: + response = res.json() + + try: + ask_time = datetime.strptime(res.headers.get('date'), "%a, %d %b %Y %H:%M:%S %Z") + except: + pass + except requests.HTTPError as error: + if error.response.status_code in (204, 404): + status = error.response.status_code + response = "" + else: + _logger.exception("Bad microsoft request : %s !", error.response.content) + raise error + return (status, response, ask_time) diff --git a/addons/microsoft_account/models/res_users.py b/addons/microsoft_account/models/res_users.py new file mode 100644 index 00000000000..d6587ffeda9 --- /dev/null +++ b/addons/microsoft_account/models/res_users.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import timedelta + + +from odoo import api, fields, models, _ + + +class User(models.Model): + _inherit = 'res.users' + + microsoft_calendar_rtoken = fields.Char('Microsoft Refresh Token', copy=False, groups="base.group_system") + microsoft_calendar_token = fields.Char('Microsoft User token', copy=False, groups="base.group_system") + microsoft_calendar_token_validity = fields.Datetime('Microsoft Token Validity', copy=False) + + def _set_microsoft_auth_tokens(self, access_token, refresh_token, ttl): + self.write({ + 'microsoft_calendar_rtoken': refresh_token, + 'microsoft_calendar_token': access_token, + 'microsoft_calendar_token_validity': fields.Datetime.now() + timedelta(seconds=ttl) if ttl else False, + }) diff --git a/addons/microsoft_calendar/__init__.py b/addons/microsoft_calendar/__init__.py new file mode 100644 index 00000000000..ef909eb6a61 --- /dev/null +++ b/addons/microsoft_calendar/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controllers +from . import models +from . import utils +from . import wizard + +from odoo import api, SUPERUSER_ID +import uuid + + +def init_initiating_microsoft_uuid(cr, registry): + """ Sets the company name as the default value for the initiating + party name on all existing companies once the module is installed. """ + env = api.Environment(cr, SUPERUSER_ID, {}) + config_parameter = env['ir.config_parameter'].sudo() + microsoft_guid = config_parameter.get_param('microsoft_calendar.microsoft_guid', False) + if not microsoft_guid: + config_parameter.set_param('microsoft_calendar.microsoft_guid', str(uuid.uuid4())) diff --git a/addons/microsoft_calendar/__manifest__.py b/addons/microsoft_calendar/__manifest__.py new file mode 100644 index 00000000000..763a1088803 --- /dev/null +++ b/addons/microsoft_calendar/__manifest__.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Outlook Calendar', + 'version': '1.0', + 'category': 'Productivity', + 'description': "", + 'depends': ['microsoft_account', 'calendar'], + 'qweb': ['static/src/xml/*.xml'], + 'data': [ + 'security/ir.model.access.csv', + 'wizard/reset_account_views.xml', + 'views/res_config_settings_views.xml', + 'views/res_users_views.xml', + 'views/microsoft_calendar_templates.xml', + ], + 'demo': [], + 'installable': True, + 'auto_install': False, + 'post_init_hook': 'init_initiating_microsoft_uuid', +} diff --git a/addons/microsoft_calendar/controllers/__init__.py b/addons/microsoft_calendar/controllers/__init__.py new file mode 100644 index 00000000000..5d4b25db9c0 --- /dev/null +++ b/addons/microsoft_calendar/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import main diff --git a/addons/microsoft_calendar/controllers/main.py b/addons/microsoft_calendar/controllers/main.py new file mode 100644 index 00000000000..5e10e6a254e --- /dev/null +++ b/addons/microsoft_calendar/controllers/main.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import http +from odoo.http import request +from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService + + +class MicrosoftCalendarController(http.Controller): + + @http.route('/microsoft_calendar/sync_data', type='json', auth='user') + def sync_data(self, model, **kw): + """ This route/function is called when we want to synchronize Odoo + calendar with Microsoft Calendar. + Function return a dictionary with the status : need_config_from_admin, need_auth, + need_refresh, success if not calendar_event + The dictionary may contains an url, to allow Odoo Client to redirect user on + this URL for authorization for example + """ + if model == 'calendar.event': + MicrosoftCal = MicrosoftCalendarService(request.env['microsoft.service']) + + # Checking that admin have already configured Microsoft API for microsoft synchronization ! + client_id = request.env['ir.config_parameter'].sudo().get_param('microsoft_calendar_client_id') + + if not client_id or client_id == '': + action_id = '' + if MicrosoftCal._can_authorize_microsoft(request.env.user): + action_id = request.env.ref('base_setup.action_general_configuration').id + return { + "status": "need_config_from_admin", + "url": '', + "action": action_id + } + + # Checking that user have already accepted Odoo to access his calendar ! + if not MicrosoftCal.is_authorized(request.env.user): + url = MicrosoftCal._microsoft_authentication_url(from_url=kw.get('fromurl')) + return { + "status": "need_auth", + "url": url + } + # If App authorized, and user access accepted, We launch the synchronization + need_refresh = request.env.user.sudo()._sync_microsoft_calendar(MicrosoftCal) + return { + "status": "need_refresh" if need_refresh else "no_new_event_from_microsoft", + "url": '' + } + + return {"status": "success"} diff --git a/addons/microsoft_calendar/models/__init__.py b/addons/microsoft_calendar/models/__init__.py new file mode 100644 index 00000000000..8d933a1a60b --- /dev/null +++ b/addons/microsoft_calendar/models/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import res_config_settings +from . import microsoft_sync +from . import calendar +from . import calendar_recurrence_rule +from . import res_users diff --git a/addons/microsoft_calendar/models/calendar.py b/addons/microsoft_calendar/models/calendar.py new file mode 100644 index 00000000000..51e7e720cba --- /dev/null +++ b/addons/microsoft_calendar/models/calendar.py @@ -0,0 +1,386 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import pytz +from dateutil.parser import parse +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + +ATTENDEE_CONVERTER_O2M = { + 'needsAction': 'notresponded', + 'tentative': 'tentativelyaccepted', + 'declined': 'declined', + 'accepted': 'accepted' +} +ATTENDEE_CONVERTER_M2O = { + 'notResponded': 'needsAction', + 'tentativelyAccepted': 'tentative', + 'declined': 'declined', + 'accepted': 'accepted', + 'organizer': 'accepted', +} +MAX_RECURRENT_EVENT = 720 + +class Meeting(models.Model): + _name = 'calendar.event' + _inherit = ['calendar.event', 'microsoft.calendar.sync'] + + microsoft_id = fields.Char('Microsoft Calendar Event Id') + microsoft_recurrence_master_id = fields.Char('Microsoft Recurrence Master Id') + + @api.model + def _get_microsoft_synced_fields(self): + return {'name', 'description', 'allday', 'start', 'date_end', 'stop', + 'user_id', 'privacy', + 'attendee_ids', 'alarm_ids', 'location', 'show_as', 'active'} + + @api.model_create_multi + def create(self, vals_list): + return super().create([ + dict(vals, need_sync_m=False) if vals.get('recurrency') else vals + for vals in vals_list + ]) + + def write(self, values): + recurrence_update_setting = values.get('recurrence_update') + if recurrence_update_setting in ('all_events', 'future_events') and len(self) == 1: + values = dict(values, need_sync_m=False) + elif recurrence_update_setting == 'self_only' and 'start' in values: + previous_event_before_write = self.recurrence_id.calendar_event_ids.filtered(lambda e: e.start.date() < self.start.date() and e != self) + new_start = parse(values['start']).date() + previous_event_after_write = self.recurrence_id.calendar_event_ids.filtered(lambda e: e.start.date() < new_start and e != self) + if previous_event_before_write != previous_event_after_write: + # Outlook returns a 400 error if you try to synchronize an occurrence of this type. + raise UserError(_("Modified occurrence is crossing or overlapping adjacent occurrence.")) + return super().write(values) + + def _get_microsoft_sync_domain(self): + return [('partner_ids.user_ids', 'in', self.env.user.id)] + + @api.model + def _microsoft_to_odoo_values(self, microsoft_event, default_reminders=(), default_values={}): + if microsoft_event.is_cancelled(): + return {'active': False} + + sensitivity_o2m = { + 'normal': 'public', + 'private': 'private', + 'confidential': 'confidential', + } + + commands_attendee, commands_partner = self._odoo_attendee_commands_m(microsoft_event) + timeZone_start = pytz.timezone(microsoft_event.start.get('timeZone')) + timeZone_stop = pytz.timezone(microsoft_event.end.get('timeZone')) + start = parse(microsoft_event.start.get('dateTime')).astimezone(timeZone_start).replace(tzinfo=None) + if microsoft_event.isAllDay: + stop = parse(microsoft_event.end.get('dateTime')).astimezone(timeZone_stop).replace(tzinfo=None) - relativedelta(days=1) + else: + stop = parse(microsoft_event.end.get('dateTime')).astimezone(timeZone_stop).replace(tzinfo=None) + values = { + **default_values, + 'name': microsoft_event.subject or _("(No title)"), + 'description': microsoft_event.bodyPreview, + 'location': microsoft_event.location and microsoft_event.location.get('displayName') or False, + 'user_id': microsoft_event.owner(self.env).id, + 'privacy': sensitivity_o2m.get(microsoft_event.sensitivity, self.default_get(['privacy'])['privacy']), + 'attendee_ids': commands_attendee, + 'partner_ids': commands_partner, + 'allday': microsoft_event.isAllDay, + 'start': start, + 'stop': stop, + 'show_as': 'free' if microsoft_event.showAs == 'free' else 'busy', + 'recurrency': microsoft_event.is_recurrent() + } + + values['microsoft_id'] = microsoft_event.id + if microsoft_event.is_recurrent(): + values['microsoft_recurrence_master_id'] = microsoft_event.seriesMasterId + + alarm_commands = self._odoo_reminders_commands_m(microsoft_event) + if alarm_commands: + values['alarm_ids'] = alarm_commands + + return values + + @api.model + def _microsoft_to_odoo_recurrence_values(self, microsoft_event, default_reminders=(), values={}): + timeZone_start = pytz.timezone(microsoft_event.start.get('timeZone')) + timeZone_stop = pytz.timezone(microsoft_event.end.get('timeZone')) + start = parse(microsoft_event.start.get('dateTime')).astimezone(timeZone_start).replace(tzinfo=None) + if microsoft_event.isAllDay: + stop = parse(microsoft_event.end.get('dateTime')).astimezone(timeZone_stop).replace(tzinfo=None) - relativedelta(days=1) + else: + stop = parse(microsoft_event.end.get('dateTime')).astimezone(timeZone_stop).replace(tzinfo=None) + values['microsoft_id'] = microsoft_event.id + values['microsoft_recurrence_master_id'] = microsoft_event.seriesMasterId + values['start'] = start + values['stop'] = stop + return values + + @api.model + def _odoo_attendee_commands_m(self, microsoft_event): + commands_attendee = [] + commands_partner = [] + + microsoft_attendees = microsoft_event.attendees or [] + if microsoft_event.isOrganizer: + user = microsoft_event.owner(self.env) + microsoft_attendees += [{ + 'emailAddress': {'address': user.partner_id.email}, + 'status': {'response': 'organizer'} + }] + emails = [a.get('emailAddress').get('address') for a in microsoft_attendees] + existing_attendees = self.env['calendar.attendee'] + if microsoft_event.exists(self.env): + existing_attendees = self.env['calendar.attendee'].search([ + ('event_id', '=', microsoft_event.odoo_id(self.env)), + ('email', 'in', emails)]) + attendees_by_emails = {a.email: a for a in existing_attendees} + for attendee in microsoft_attendees: + email = attendee.get('emailAddress').get('address') + state = ATTENDEE_CONVERTER_M2O.get(attendee.get('status').get('response')) + + if email in attendees_by_emails: + # Update existing attendees + commands_attendee += [(1, attendees_by_emails[email].id, {'state': state})] + else: + # Create new attendees + partner = self.env['res.partner'].find_or_create(email) + commands_attendee += [(0, 0, {'state': state, 'partner_id': partner.id})] + commands_partner += [(4, partner.id)] + if attendee.get('emailAddress').get('name') and not partner.name: + partner.name = attendee.get('emailAddress').get('name') + for odoo_attendee in attendees_by_emails.values(): + # Remove old attendees + if odoo_attendee.email not in emails: + commands_attendee += [(2, odoo_attendee.id)] + commands_partner += [(3, odoo_attendee.partner_id.id)] + return commands_attendee, commands_partner + + @api.model + def _odoo_reminders_commands_m(self, microsoft_event): + reminders_commands = [] + if microsoft_event.isReminderOn: + event_id = self.browse(microsoft_event.odoo_id(self.env)) + alarm_type_label = _("Notification") + + minutes = microsoft_event.reminderMinutesBeforeStart or 0 + alarm = self.env['calendar.alarm'].search([ + ('alarm_type', '=', 'notification'), + ('duration_minutes', '=', minutes) + ], limit=1) + if alarm and alarm not in event_id.alarm_ids: + reminders_commands = [(4, alarm.id)] + elif not alarm: + if minutes == 0: + interval = 'minutes' + duration = minutes + name = _("%s - At time of event", alarm_type_label) + elif minutes % (60*24) == 0: + interval = 'days' + duration = minutes / 60 / 24 + name = _( + "%(reminder_type)s - %(duration)s Days", + reminder_type=alarm_type_label, + duration=duration, + ) + elif minutes % 60 == 0: + interval = 'hours' + duration = minutes / 60 + name = _( + "%(reminder_type)s - %(duration)s Hours", + reminder_type=alarm_type_label, + duration=duration, + ) + else: + interval = 'minutes' + duration = minutes + name = _( + "%(reminder_type)s - %(duration)s Minutes", + reminder_type=alarm_type_label, + duration=duration, + ) + reminders_commands = [(0, 0, {'duration': duration, 'interval': interval, 'name': name, 'alarm_type': 'notification'})] + + alarm_to_rm = event_id.alarm_ids.filtered(lambda a: a.alarm_type == 'notification' and a.id != alarm.id) + if alarm_to_rm: + reminders_commands += [(3, a.id) for a in alarm_to_rm] + + else: + event_id = self.browse(microsoft_event.odoo_id(self.env)) + alarm_to_rm = event_id.alarm_ids.filtered(lambda a: a.alarm_type == 'notification') + if alarm_to_rm: + reminders_commands = [(3, a.id) for a in alarm_to_rm] + return reminders_commands + + def _get_attendee_status_o2m(self, attendee): + if self.user_id and self.user_id == attendee.partner_id.user_id: + return 'organizer' + return ATTENDEE_CONVERTER_O2M.get(attendee.state, 'None') + + def _microsoft_values(self, fields_to_sync, initial_values={}): + values = dict(initial_values) + if not fields_to_sync: + return values + + values['id'] = self.microsoft_id + microsoft_guid = self.env['ir.config_parameter'].sudo().get_param('microsoft_calendar.microsoft_guid', False) + values['singleValueExtendedProperties'] = [{ + 'id': 'String {%s} Name odoo_id' % microsoft_guid, + 'value': str(self.id), + }, { + 'id': 'String {%s} Name owner_odoo_id' % microsoft_guid, + 'value': str(self.user_id.id), + }] + + if self.microsoft_recurrence_master_id and 'type' not in values: + values['seriesMasterId'] = self.microsoft_recurrence_master_id + values['type'] = 'exception' + + if 'name' in fields_to_sync: + values['subject'] = self.name or '' + + if 'description' in fields_to_sync: + values['body'] = { + 'content': self.description or '', + 'contentType': "text", + } + + if any(x in fields_to_sync for x in ['allday', 'start', 'date_end', 'stop']): + if self.allday: + start = {'dateTime': self.start_date.isoformat(), 'timeZone': 'Europe/London'} + end = {'dateTime': (self.stop_date + relativedelta(days=1)).isoformat(), 'timeZone': 'Europe/London'} + else: + start = {'dateTime': pytz.utc.localize(self.start).isoformat(), 'timeZone': 'Europe/London'} + end = {'dateTime': pytz.utc.localize(self.stop).isoformat(), 'timeZone': 'Europe/London'} + + values['start'] = start + values['end'] = end + values['isAllDay'] = self.allday + + if 'location' in fields_to_sync: + values['location'] = {'displayName': self.location or ''} + + if 'alarm_ids' in fields_to_sync: + alarm_id = self.alarm_ids.filtered(lambda a: a.alarm_type == 'notification')[:1] + values['isReminderOn'] = bool(alarm_id) + values['reminderMinutesBeforeStart'] = alarm_id.duration_minutes + + if 'user_id' in fields_to_sync: + values['organizer'] = {'emailAddress': {'address': self.user_id.email or '', 'name': self.user_id.display_name or ''}} + values['isOrganizer'] = self.user_id == self.env.user + + if 'attendee_ids' in fields_to_sync: + attendees = self.attendee_ids.filtered(lambda att: att.partner_id not in self.user_id.partner_id) + values['attendees'] = [ + { + 'emailAddress': {'address': attendee.email or '', 'name': attendee.display_name or ''}, + 'status': {'response': self._get_attendee_status_o2m(attendee)} + } for attendee in attendees] + + if 'privacy' in fields_to_sync or 'show_as' in fields_to_sync: + values['showAs'] = self.show_as + sensitivity_o2m = { + 'public': 'normal', + 'private': 'private', + 'confidential': 'confidential', + } + values['sensitivity'] = sensitivity_o2m.get(self.privacy) + + if 'active' in fields_to_sync and not self.active: + values['isCancelled'] = True + + if values.get('type') == 'seriesMaster': + recurrence = self.recurrence_id + pattern = { + 'interval': recurrence.interval + } + if recurrence.rrule_type in ['daily', 'weekly']: + pattern['type'] = recurrence.rrule_type + else: + prefix = 'absolute' if recurrence.month_by == 'date' else 'relative' + pattern['type'] = prefix + recurrence.rrule_type.capitalize() + + if recurrence.month_by == 'date': + pattern['dayOfMonth'] = recurrence.day + + if recurrence.month_by == 'day' or recurrence.rrule_type == 'weekly': + pattern['daysOfWeek'] = [ + weekday_name for weekday_name, weekday in { + 'monday': recurrence.mo, + 'tuesday': recurrence.tu, + 'wednesday': recurrence.we, + 'thursday': recurrence.th, + 'friday': recurrence.fr, + 'saturday': recurrence.sa, + 'sunday': recurrence.su, + }.items() if weekday] + pattern['firstDayOfWeek'] = 'sunday' + + if recurrence.rrule_type == 'monthly' and recurrence.month_by == 'day': + byday_selection = [ + ('1', 'first'), + ('2', 'second'), + ('3', 'third'), + ('4', 'fourth'), + ('-1', 'last') + ] + pattern['index'] = byday_selection[recurrence.byday] + + rule_range = { + 'startDate': (recurrence.dtstart.date()).isoformat() + } + + if recurrence.end_type == 'count': # e.g. stop after X occurence + rule_range['numberOfOccurrences'] = min(recurrence.count, MAX_RECURRENT_EVENT) + rule_range['type'] = 'numbered' + elif recurrence.end_type == 'forever': + rule_range['numberOfOccurrences'] = MAX_RECURRENT_EVENT + rule_range['type'] = 'numbered' + elif recurrence.end_type == 'end_date': # e.g. stop after 12/10/2020 + rule_range['endDate'] = recurrence.until.isoformat() + rule_range['type'] = 'endDate' + + values['recurrence'] = { + 'pattern': pattern, + 'range': rule_range + } + + return values + + def _microsoft_values_occurence(self, initial_values={}): + values = dict(initial_values) + values['id'] = self.microsoft_id + microsoft_guid = self.env['ir.config_parameter'].sudo().get_param('microsoft_calendar.microsoft_guid', False) + values['singleValueExtendedProperties'] = [{ + 'id': 'String {%s} Name odoo_id' % microsoft_guid, + 'value': str(self.id), + }, { + 'id': 'String {%s} Name owner_odoo_id' % microsoft_guid, + 'value': str(self.user_id.id), + }] + + values['type'] = 'occurrence' + + if self.allday: + start = {'dateTime': self.start_date.isoformat(), 'timeZone': 'Europe/London'} + end = {'dateTime': (self.stop_date + relativedelta(days=1)).isoformat(), 'timeZone': 'Europe/London'} + else: + start = {'dateTime': pytz.utc.localize(self.start).isoformat(), 'timeZone': 'Europe/London'} + end = {'dateTime': pytz.utc.localize(self.stop).isoformat(), 'timeZone': 'Europe/London'} + + values['start'] = start + values['end'] = end + values['isAllDay'] = self.allday + + return values + + def _cancel_microsoft(self): + # only owner can delete => others refuse the event + user = self.env.user + my_cancelled_records = self.filtered(lambda e: e.user_id == user) + super(Meeting, my_cancelled_records)._cancel_microsoft() + attendees = (self - my_cancelled_records).attendee_ids.filtered(lambda a: a.partner_id == user.partner_id) + attendees.state = 'declined' diff --git a/addons/microsoft_calendar/models/calendar_recurrence_rule.py b/addons/microsoft_calendar/models/calendar_recurrence_rule.py new file mode 100644 index 00000000000..0044e3d499b --- /dev/null +++ b/addons/microsoft_calendar/models/calendar_recurrence_rule.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import re + +from odoo import api, fields, models + +from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService + + +class RecurrenceRule(models.Model): + _name = 'calendar.recurrence' + _inherit = ['calendar.recurrence', 'microsoft.calendar.sync'] + + + # Don't sync by default. Sync only when the recurrence is applied + need_sync_m = fields.Boolean(default=False) + + microsoft_id = fields.Char('Microsoft Calendar Recurrence Id') + + def _apply_recurrence(self, specific_values_creation=None, no_send_edit=False): + events = self.calendar_event_ids + detached_events = super()._apply_recurrence(specific_values_creation, no_send_edit) + + microsoft_service = MicrosoftCalendarService(self.env['microsoft.service']) + + # If a synced event becomes a recurrence, the event needs to be deleted from + # Microsoft since it's now the recurrence which is synced. + # Those events are kept in the database and their microsoft_id is updated + # according to the recurrence microsoft_id, therefore we need to keep an inactive copy + # of those events with the original microsoft_id. The next sync will then correctly + # delete those events from Microsoft. + vals = [] + for event in events.filtered('microsoft_id'): + if event.active and event.microsoft_id and not event.recurrence_id.microsoft_id: + vals += [{ + 'name': event.name, + 'microsoft_id': event.microsoft_id, + 'start': event.start, + 'stop': event.stop, + 'active': False, + 'need_sync_m': True, + }] + event._microsoft_delete(microsoft_service, event.microsoft_id) + event.microsoft_id = False + self.env['calendar.event'].create(vals) + + if not no_send_edit: + for recurrence in self: + values = recurrence._microsoft_values(self._get_microsoft_synced_fields()) + if not values: + continue + values = values[0] + if not recurrence.microsoft_id: + recurrence._microsoft_insert(microsoft_service, values) + else: + recurrence._microsoft_patch(microsoft_service, recurrence.microsoft_id, values) + + self.calendar_event_ids.need_sync_m = False + return detached_events + + def _write_events(self, values, dtstart=None): + # If only some events are updated, sync those events. + # If all events are updated, sync the recurrence instead. + values['need_sync_m'] = bool(dtstart) + if not dtstart: + self.need_sync_m = True + return super()._write_events(values, dtstart=dtstart) + + def _get_microsoft_synced_fields(self): + return {'rrule'} | self.env['calendar.event']._get_microsoft_synced_fields() + + def _get_microsoft_sync_domain(self): + return [('calendar_event_ids.user_id', '=', self.env.user.id)] + + def _cancel_microsoft(self): + self.calendar_event_ids._cancel_microsoft() + self.microsoft_id = False # Set to False to avoid error with unlink from microsoft and avoid to synchronize. + self.unlink() + + @api.model + def _microsoft_to_odoo_values(self, microsoft_recurrence, default_reminders=(), default_values={}): + recurrence = microsoft_recurrence.get_recurrence() + + return { + **recurrence, + 'microsoft_id': microsoft_recurrence.id, + } + + def _microsoft_values(self, fields_to_sync): + events = self.calendar_event_ids.sorted('start') + events_outliers = self.calendar_event_ids.filtered(lambda e: not e.follow_recurrence) + normal_event = (events - events_outliers)[:1] or events[:1] + if not normal_event: + return {} + values = [normal_event._microsoft_values(fields_to_sync, initial_values={'type': 'seriesMaster'})] + + if self.microsoft_id: + values[0]['id'] = self.microsoft_id + for event in events_outliers: + event_value = event._microsoft_values(fields_to_sync) + values += [event_value] + + return values diff --git a/addons/microsoft_calendar/models/microsoft_sync.py b/addons/microsoft_calendar/models/microsoft_sync.py new file mode 100644 index 00000000000..312904779ec --- /dev/null +++ b/addons/microsoft_calendar/models/microsoft_sync.py @@ -0,0 +1,356 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +from contextlib import contextmanager +from functools import wraps +import requests +import pytz +from dateutil.parser import parse + +from odoo import api, fields, models, registry, _ +from odoo.tools import ormcache_context +from odoo.exceptions import UserError +from odoo.osv import expression + +from odoo.addons.microsoft_calendar.utils.microsoft_event import MicrosoftEvent +from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService +from odoo.addons.microsoft_account.models.microsoft_service import TIMEOUT + +_logger = logging.getLogger(__name__) + +MAX_RECURRENT_EVENT = 720 + + +# API requests are sent to Microsoft Calendar after the current transaction ends. +# This ensures changes are sent to Microsoft only if they really happened in the Odoo database. +# It is particularly important for event creation , otherwise the event might be created +# twice in Microsoft if the first creation crashed in Odoo. +def after_commit(func): + @wraps(func) + def wrapped(self, *args, **kwargs): + dbname = self.env.cr.dbname + context = self.env.context + uid = self.env.uid + def called_after(): + db_registry = registry(dbname) + with api.Environment.manage(), db_registry.cursor() as cr: + env = api.Environment(cr, uid, context) + try: + func(self.with_env(env), *args, **kwargs) + except Exception as e: + _logger.warning("Could not sync record now: %s" % self) + _logger.exception(e) + self.env.cr.after('commit', called_after) + return wrapped + +@contextmanager +def microsoft_calendar_token(user): + try: + yield user._get_microsoft_calendar_token() + except requests.HTTPError as e: + if e.response.status_code == 401: # Invalid token. + # The transaction should be rolledback, but the user's tokens + # should be reset. The user will be asked to authenticate again next time. + # Rollback manually first to avoid concurrent access errors/deadlocks. + user.env.cr.rollback() + with user.pool.cursor() as cr: + env = user.env(cr=cr) + user.with_env(env)._set_microsoft_auth_tokens(False, False, 0) + raise e + +class MicrosoftSync(models.AbstractModel): + _name = 'microsoft.calendar.sync' + _description = "Synchronize a record with Microsoft Calendar" + + microsoft_id = fields.Char('Microsoft Calendar Id', copy=False) + need_sync_m = fields.Boolean(default=True, copy=False) + active = fields.Boolean(default=True) + + def write(self, vals): + microsoft_service = MicrosoftCalendarService(self.env['microsoft.service']) + if 'microsoft_id' in vals: + self._from_microsoft_ids.clear_cache(self) + synced_fields = self._get_microsoft_synced_fields() + if 'need_sync_m' not in vals and vals.keys() & synced_fields: + fields_to_sync = [x for x in vals.keys() if x in synced_fields] + if fields_to_sync: + vals['need_sync_m'] = True + else: + fields_to_sync = [x for x in vals.keys() if x in synced_fields] + + result = super().write(vals) + for record in self.filtered('need_sync_m'): + if record.microsoft_id and fields_to_sync: + values = record._microsoft_values(fields_to_sync) + if not values: + continue + record._microsoft_patch(microsoft_service, record.microsoft_id, values, timeout=3) + + return result + + @api.model_create_multi + def create(self, vals_list): + if any(vals.get('microsoft_id') for vals in vals_list): + self._from_microsoft_ids.clear_cache(self) + records = super().create(vals_list) + + microsoft_service = MicrosoftCalendarService(self.env['microsoft.service']) + records_to_sync = records.filtered(lambda r: r.need_sync_m and r.active) + for record in records_to_sync: + record._microsoft_insert(microsoft_service, record._microsoft_values(self._get_microsoft_synced_fields()), timeout=3) + return records + + def unlink(self): + """We can't delete an event that is also in Microsoft Calendar. Otherwise we would + have no clue that the event must must deleted from Microsoft Calendar at the next sync. + """ + synced = self.filtered('microsoft_id') + if self.env.context.get('archive_on_error') and self._active_name: + synced.write({self._active_name: False}) + self = self - synced + elif synced: + raise UserError(_("You cannot delete a record synchronized with Microsoft Calendar, archive it instead.")) + return super().unlink() + + @api.model + @ormcache_context('microsoft_ids', keys=('active_test',)) + def _from_microsoft_ids(self, microsoft_ids): + if not microsoft_ids: + return self.browse() + return self.search([('microsoft_id', 'in', microsoft_ids)]) + + def _sync_odoo2microsoft(self, microsoft_service: MicrosoftCalendarService): + if not self: + return + if self._active_name: + records_to_sync = self.filtered(self._active_name) + else: + records_to_sync = self + cancelled_records = self - records_to_sync + + updated_records = records_to_sync.filtered('microsoft_id') + new_records = records_to_sync - updated_records + for record in cancelled_records.filtered('microsoft_id'): + record._microsoft_delete(microsoft_service, record.microsoft_id) + for record in new_records: + values = record._microsoft_values(self._get_microsoft_synced_fields()) + if isinstance(values, dict): + record._microsoft_insert(microsoft_service, values) + else: + for value in values: + record._microsoft_insert(microsoft_service, value) + for record in updated_records: + values = record._microsoft_values(self._get_microsoft_synced_fields()) + if not values: + continue + record._microsoft_patch(microsoft_service, record.microsoft_id, values) + + def _cancel_microsoft(self): + self.microsoft_id = False + self.unlink() + + def _sync_recurrence_microsoft2odoo(self, microsoft_events: MicrosoftEvent): + recurrent_masters = microsoft_events.filter(lambda e: e.is_recurrence()) + recurrents = microsoft_events.filter(lambda e: e.is_recurrent_not_master()) + default_values = {'need_sync_m': False} + + new_recurrence = self.env['calendar.recurrence'] + + for recurrent_master in recurrent_masters: + new_calendar_recurrence = dict(self.env['calendar.recurrence']._microsoft_to_odoo_values(recurrent_master, (), default_values), need_sync_m=False) + to_create = recurrents.filter(lambda e: e.seriesMasterId == new_calendar_recurrence['microsoft_id']) + recurrents -= to_create + base_values = dict(self.env['calendar.event']._microsoft_to_odoo_values(recurrent_master, (), default_values), need_sync_m=False) + to_create_values = [] + if new_calendar_recurrence.get('end_type', False) in ['count', 'forever']: + to_create = list(to_create)[:MAX_RECURRENT_EVENT] + for recurrent_event in to_create: + if recurrent_event.type == 'occurrence': + value = self.env['calendar.event']._microsoft_to_odoo_recurrence_values(recurrent_event, (), base_values) + else: + value = self.env['calendar.event']._microsoft_to_odoo_values(recurrent_event, (), default_values) + + to_create_values += [dict(value, need_sync_m=False)] + + new_calendar_recurrence['calendar_event_ids'] = [(0, 0, to_create_value) for to_create_value in to_create_values] + new_recurrence_odoo = self.env['calendar.recurrence'].create(new_calendar_recurrence) + new_recurrence_odoo.base_event_id = new_recurrence_odoo.calendar_event_ids[0] if new_recurrence_odoo.calendar_event_ids else False + new_recurrence |= new_recurrence_odoo + + for recurrent_master_id in set([x.seriesMasterId for x in recurrents]): + recurrence_id = self.env['calendar.recurrence'].search([('microsoft_id', '=', recurrent_master_id)]) + to_update = recurrents.filter(lambda e: e.seriesMasterId == recurrent_master_id) + for recurrent_event in to_update: + if recurrent_event.type == 'occurrence': + value = self.env['calendar.event']._microsoft_to_odoo_recurrence_values(recurrent_event, (), {'need_sync_m': False}) + else: + value = self.env['calendar.event']._microsoft_to_odoo_values(recurrent_event, (), default_values) + existing_event = recurrence_id.calendar_event_ids.filtered(lambda e: e._range() == (value['start'], value['stop'])) + if not existing_event: + continue + value.pop('start') + value.pop('stop') + existing_event.write(value) + new_recurrence |= recurrence_id + return new_recurrence + + def _update_microsoft_recurrence(self, recurrence_event, events): + vals = dict(self.base_event_id._microsoft_to_odoo_values(recurrence_event, ()), need_sync_m=False) + vals['microsoft_recurrence_master_id'] = vals.pop('microsoft_id') + self.base_event_id.write(vals) + values = {} + default_values = {} + + normal_events = [] + events_to_update = events.filter(lambda e: e.seriesMasterId == self.microsoft_id) + if self.end_type in ['count', 'forever']: + events_to_update = list(events_to_update)[:MAX_RECURRENT_EVENT] + + for recurrent_event in events_to_update: + if recurrent_event.type == 'occurrence': + value = self.env['calendar.event']._microsoft_to_odoo_recurrence_values(recurrent_event, (), default_values) + normal_events += [recurrent_event.odoo_id(self.env)] + else: + value = self.env['calendar.event']._microsoft_to_odoo_values(recurrent_event, (), default_values) + self.env['calendar.event'].browse(recurrent_event.odoo_id(self.env)).with_context(no_mail_to_attendees=True, mail_create_nolog=True).write(dict(value, need_sync_m=False)) + values[(self.id, value.get('start'), value.get('stop'))] = dict(value, need_sync_m=False) + + if (self.id, vals.get('start'), vals.get('stop')) in values: + base_event_vals = dict(vals) + base_event_vals.update(values[(self.id, vals.get('start'), vals.get('stop'))]) + self.base_event_id.write(base_event_vals) + + old_record = self._apply_recurrence(specific_values_creation=values, no_send_edit=True) + + vals.pop('microsoft_id', None) + vals.pop('start', None) + vals.pop('stop', None) + normal_events = [e for e in normal_events if e in self.calendar_event_ids.ids] + normal_event_ids = self.env['calendar.event'].browse(normal_events) - old_record + if normal_event_ids: + vals['follow_recurrence'] = True + (self.env['calendar.event'].browse(normal_events) - old_record).write(vals) + + old_record._cancel_microsoft() + if not self.base_event_id: + self.base_event_id = self._get_first_event(include_outliers=False) + + @api.model + def _sync_microsoft2odoo(self, microsoft_events: MicrosoftEvent, default_reminders=()): + """Synchronize Microsoft recurrences in Odoo. Creates new recurrences, updates + existing ones. + + :return: synchronized odoo + """ + existing = microsoft_events.exists(self.env) + new = microsoft_events - existing - microsoft_events.cancelled() + new_recurrent = new.filter(lambda e: e.is_recurrent()) + + default_values = {} + + odoo_values = [ + dict(self._microsoft_to_odoo_values(e, default_reminders, default_values), need_sync_m=False) + for e in (new - new_recurrent) + ] + new_odoo = self.create(odoo_values) + + synced_recurrent_records = self._sync_recurrence_microsoft2odoo(new_recurrent) + + cancelled = existing.cancelled() + cancelled_odoo = self.browse(cancelled.odoo_ids(self.env)) + cancelled_odoo._cancel_microsoft() + + recurrent_cancelled = self.env['calendar.recurrence'].search([ + ('microsoft_id', 'in', (microsoft_events.cancelled() - cancelled).microsoft_ids())]) + recurrent_cancelled._cancel_microsoft() + + synced_records = new_odoo + cancelled_odoo + synced_recurrent_records.calendar_event_ids + + for mevent in (existing - cancelled).filter(lambda e: e.lastModifiedDateTime and not e.seriesMasterId): + # Last updated wins. + # This could be dangerous if microsoft server time and odoo server time are different + if mevent.is_recurrence(): + odoo_record = self.env['calendar.recurrence'].browse(mevent.odoo_id(self.env)) + else: + odoo_record = self.browse(mevent.odoo_id(self.env)) + odoo_record_updated = pytz.utc.localize(odoo_record.write_date) + updated = parse(mevent.lastModifiedDateTime or str(odoo_record_updated)) + if updated >= odoo_record_updated: + vals = dict(odoo_record._microsoft_to_odoo_values(mevent, default_reminders), need_sync_m=False) + odoo_record.write(vals) + if odoo_record._name == 'calendar.recurrence': + odoo_record._update_microsoft_recurrence(mevent, microsoft_events) + synced_recurrent_records |= odoo_record + else: + synced_records |= odoo_record + + return synced_records, synced_recurrent_records + + @after_commit + def _microsoft_delete(self, microsoft_service: MicrosoftCalendarService, microsoft_id, timeout=TIMEOUT): + with microsoft_calendar_token(self.env.user.sudo()) as token: + if token: + microsoft_service.delete(microsoft_id, token=token, timeout=timeout) + + @after_commit + def _microsoft_patch(self, microsoft_service: MicrosoftCalendarService, microsoft_id, values, timeout=TIMEOUT): + with microsoft_calendar_token(self.env.user.sudo()) as token: + if token: + microsoft_service.patch(microsoft_id, values, token=token, timeout=timeout) + self.need_sync_m = False + + @after_commit + def _microsoft_insert(self, microsoft_service: MicrosoftCalendarService, values, timeout=TIMEOUT): + if not values: + return + with microsoft_calendar_token(self.env.user.sudo()) as token: + if token: + microsoft_id = microsoft_service.insert(values, token=token, timeout=timeout) + self.write({ + 'microsoft_id': microsoft_id, + 'need_sync_m': False, + }) + + def _get_microsoft_records_to_sync(self, full_sync=False): + """Return records that should be synced from Odoo to Microsoft + + :param full_sync: If True, all events attended by the user are returned + :return: events + """ + domain = self._get_microsoft_sync_domain() + if not full_sync: + is_active_clause = (self._active_name, '=', True) if self._active_name else expression.TRUE_LEAF + domain = expression.AND([domain, [ + '|', + '&', ('microsoft_id', '=', False), is_active_clause, + ('need_sync_m', '=', True), + ]]) + return self.with_context(active_test=False).search(domain) + + @api.model + def _microsoft_to_odoo_values(self, microsoft_event: MicrosoftEvent, default_reminders=()): + """Implements this method to return a dict of Odoo values corresponding + to the Microsoft event given as parameter + :return: dict of Odoo formatted values + """ + raise NotImplementedError() + + def _microsoft_values(self, fields_to_sync): + """Implements this method to return a dict with values formatted + according to the Microsoft Calendar API + :return: dict of Microsoft formatted values + """ + raise NotImplementedError() + + def _get_microsoft_sync_domain(self): + """Return a domain used to search records to synchronize. + e.g. return a domain to synchronize records owned by the current user. + """ + raise NotImplementedError() + + def _get_microsoft_synced_fields(self): + """Return a set of field names. Changing one of these fields + marks the record to be re-synchronized. + """ + raise NotImplementedError() diff --git a/addons/microsoft_calendar/models/res_config_settings.py b/addons/microsoft_calendar/models/res_config_settings.py new file mode 100644 index 00000000000..38ac9565fb1 --- /dev/null +++ b/addons/microsoft_calendar/models/res_config_settings.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + cal_microsoft_client_id = fields.Char("Microsoft Client_id", config_parameter='microsoft_calendar_client_id', default='') + cal_microsoft_client_secret = fields.Char("Microsoft Client_key", config_parameter='microsoft_calendar_client_secret', default='') diff --git a/addons/microsoft_calendar/models/res_users.py b/addons/microsoft_calendar/models/res_users.py new file mode 100644 index 00000000000..7354ea45f44 --- /dev/null +++ b/addons/microsoft_calendar/models/res_users.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import requests +from odoo.addons.microsoft_calendar.models.microsoft_sync import microsoft_calendar_token +from datetime import timedelta + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError +from odoo.loglevels import exception_to_unicode +from odoo.addons.microsoft_account.models.microsoft_service import MICROSOFT_TOKEN_ENDPOINT +from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService, InvalidSyncToken + +_logger = logging.getLogger(__name__) + + +class User(models.Model): + _inherit = 'res.users' + + microsoft_calendar_sync_token = fields.Char('Microsoft Next Sync Token', copy=False) + + def _microsoft_calendar_authenticated(self): + return bool(self.sudo().microsoft_calendar_rtoken) + + def _get_microsoft_calendar_token(self): + self.ensure_one() + if self._is_microsoft_calendar_valid(): + self._refresh_microsoft_calendar_token() + return self.microsoft_calendar_token + + def _is_microsoft_calendar_valid(self): + return self.microsoft_calendar_token_validity and self.microsoft_calendar_token_validity < (fields.Datetime.now() + timedelta(minutes=1)) + + def _refresh_microsoft_calendar_token(self): + self.ensure_one() + get_param = self.env['ir.config_parameter'].sudo().get_param + client_id = get_param('microsoft_calendar_client_id') + client_secret = get_param('microsoft_calendar_client_secret') + + if not client_id or not client_secret: + raise UserError(_("The account for the Microsoft Calendar service is not configured.")) + + headers = {"content-type": "application/x-www-form-urlencoded"} + data = { + 'refresh_token': self.microsoft_calendar_rtoken, + 'client_id': client_id, + 'client_secret': client_secret, + 'grant_type': 'refresh_token', + } + + try: + dummy, response, dummy = self.env['microsoft.service']._do_request(MICROSOFT_TOKEN_ENDPOINT, params=data, headers=headers, method='POST', preuri='') + ttl = response.get('expires_in') + self.write({ + 'microsoft_calendar_token': response.get('access_token'), + 'microsoft_calendar_token_validity': fields.Datetime.now() + timedelta(seconds=ttl), + }) + except requests.HTTPError as error: + if error.response.status_code == 400: # invalid grant + # Delete refresh token and make sure it's commited + with self.pool.cursor() as cr: + self.env.user.with_env(self.env(cr=cr)).write({'microsoft_calendar_rtoken': False}) + error_key = error.response.json().get("error", "nc") + error_msg = _("Something went wrong during your token generation. Maybe your Authorization Code is invalid or already expired [%s]", error_key) + raise UserError(error_msg) + + def _sync_microsoft_calendar(self, calendar_service: MicrosoftCalendarService): + self.ensure_one() + full_sync = not bool(self.microsoft_calendar_sync_token) + with microsoft_calendar_token(self) as token: + try: + events, next_sync_token, default_reminders = calendar_service.get_events(self.microsoft_calendar_sync_token, token=token) + except InvalidSyncToken: + events, next_sync_token, default_reminders = calendar_service.get_events(token=token) + full_sync = True + self.microsoft_calendar_sync_token = next_sync_token + + # Microsoft -> Odoo + recurrences = events.filter(lambda e: e.is_recurrent()) + synced_events, synced_recurrences = self.env['calendar.event']._sync_microsoft2odoo(events, default_reminders=default_reminders) if events else (self.env['calendar.event'], self.env['calendar.recurrence']) + + # Odoo -> Microsoft + recurrences = self.env['calendar.recurrence']._get_microsoft_records_to_sync(full_sync=full_sync) + recurrences -= synced_recurrences + recurrences._sync_odoo2microsoft(calendar_service) + synced_events |= recurrences.calendar_event_ids + + events = self.env['calendar.event']._get_microsoft_records_to_sync(full_sync=full_sync) + (events - synced_events)._sync_odoo2microsoft(calendar_service) + + return bool(events | synced_events) or bool(recurrences | synced_recurrences) + + @api.model + def _sync_all_microsoft_calendar(self): + """ Cron job """ + users = self.env['res.users'].search([('microsoft_calendar_rtoken', '!=', False)]) + microsoft = MicrosoftCalendarService(self.env['microsoft.service']) + for user in users: + _logger.info("Calendar Synchro - Starting synchronization for %s", user) + try: + user.with_user(user).sudo()._sync_microsoft_calendar(microsoft) + except Exception as e: + _logger.exception("[%s] Calendar Synchro - Exception : %s !", user, exception_to_unicode(e)) diff --git a/addons/microsoft_calendar/security/ir.model.access.csv b/addons/microsoft_calendar/security/ir.model.access.csv new file mode 100644 index 00000000000..478501e7e4a --- /dev/null +++ b/addons/microsoft_calendar/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +microsoft_calendar_account_reset,microsoft_calendar_account_reset_access_right,model_microsoft_calendar_account_reset,base.group_system,1,1,1,0 diff --git a/addons/microsoft_calendar/static/description/icon.png b/addons/microsoft_calendar/static/description/icon.png new file mode 100644 index 00000000000..b8734363b98 Binary files /dev/null and b/addons/microsoft_calendar/static/description/icon.png differ diff --git a/addons/microsoft_calendar/static/description/icon.svg b/addons/microsoft_calendar/static/description/icon.svg new file mode 100644 index 00000000000..6056dc85197 --- /dev/null +++ b/addons/microsoft_calendar/static/description/icon.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/microsoft_calendar/static/src/img/calendar_outlook_32.png b/addons/microsoft_calendar/static/src/img/calendar_outlook_32.png new file mode 100644 index 00000000000..78553f07f7e Binary files /dev/null and b/addons/microsoft_calendar/static/src/img/calendar_outlook_32.png differ diff --git a/addons/microsoft_calendar/static/src/js/microsoft_calendar.js b/addons/microsoft_calendar/static/src/js/microsoft_calendar.js new file mode 100644 index 00000000000..41ad9e23675 --- /dev/null +++ b/addons/microsoft_calendar/static/src/js/microsoft_calendar.js @@ -0,0 +1,152 @@ +odoo.define('microsoft_calendar.CalendarView', function (require) { +"use strict"; + +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var framework = require('web.framework'); +const CalendarView = require('calendar.CalendarView'); +const CalendarRenderer = require('calendar.CalendarRenderer'); +const CalendarController = require('calendar.CalendarController'); +const CalendarModel = require('calendar.CalendarModel'); +const viewRegistry = require('web.view_registry'); + +var _t = core._t; + +const MicrosoftCalendarModel = CalendarModel.include({ + + /** + * @override + * @returns {Promise} + */ + async _loadCalendar() { + const _super = this._super.bind(this); + try { + await Promise.race([ + new Promise(resolve => setTimeout(resolve, 1000)), + this._syncMicrosoftCalendar(true) + ]); + } catch (error) { + if (error.event) { + error.event.preventDefault(); + } + console.error("Could not synchronize Microsoft events now.", error); + } + return _super(...arguments); + }, + + _syncMicrosoftCalendar(shadow = false) { + return this._rpc({ + route: '/microsoft_calendar/sync_data', + params: { + model: this.modelName, + fromurl: window.location.href, + } + }, {shadow}); + }, +}); + +const MicrosoftCalendarController = CalendarController.include({ + custom_events: _.extend({}, CalendarController.prototype.custom_events, { + syncMicrosoftCalendar: '_onSyncMicrosoftCalendar', + }), + + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Try to sync the calendar with Microsoft Calendar. According to the result + * from Microsoft API, this function may require an action of the user by the + * mean of a dialog. + * + * @private + * @returns {OdooEvent} event + */ + _onSyncMicrosoftCalendar: function (event) { + var self = this; + + return this.model._syncMicrosoftCalendar().then(function (o) { + if (o.status === "need_auth") { + Dialog.alert(self, _t("You will be redirected to Microsoft to authorize the access to your calendar."), { + confirm_callback: function() { + framework.redirect(o.url); + }, + title: _t('Redirection'), + }); + } else if (o.status === "need_config_from_admin") { + if (!_.isUndefined(o.action) && parseInt(o.action)) { + Dialog.confirm(self, _t("The Microsoft Synchronization needs to be configured before you can use it, do you want to do it now?"), { + confirm_callback: function() { + self.do_action(o.action); + }, + title: _t('Configuration'), + }); + } else { + Dialog.alert(self, _t("An administrator needs to configure Microsoft Synchronization before you can use it!"), { + title: _t('Configuration'), + }); + } + } else if (o.status === "need_refresh") { + self.reload(); + } + }).then(event.data.on_always, event.data.on_always); + } +}); + +const MicrosoftCalendarRenderer = CalendarRenderer.include({ + events: _.extend({}, CalendarRenderer.prototype.events, { + 'click .o_microsoft_sync_button': '_onSyncMicrosoftCalendar', + }), + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Adds the Sync with Microsoft button in the sidebar + * + * @private + */ + _initSidebar: function () { + var self = this; + this._super.apply(this, arguments); + this.$microsoftButton = $(); + if (this.model === "calendar.event") { + this.$microsoftButton = $('