summaryrefslogtreecommitdiff
path: root/addons/microsoft_calendar/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/microsoft_calendar/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/microsoft_calendar/models')
-rw-r--r--addons/microsoft_calendar/models/__init__.py9
-rw-r--r--addons/microsoft_calendar/models/calendar.py411
-rw-r--r--addons/microsoft_calendar/models/calendar_attendee.py20
-rw-r--r--addons/microsoft_calendar/models/calendar_recurrence_rule.py118
-rw-r--r--addons/microsoft_calendar/models/microsoft_sync.py378
-rw-r--r--addons/microsoft_calendar/models/res_config_settings.py11
-rw-r--r--addons/microsoft_calendar/models/res_users.py104
7 files changed, 1051 insertions, 0 deletions
diff --git a/addons/microsoft_calendar/models/__init__.py b/addons/microsoft_calendar/models/__init__.py
new file mode 100644
index 00000000..a87bf35b
--- /dev/null
+++ b/addons/microsoft_calendar/models/__init__.py
@@ -0,0 +1,9 @@
+# -*- 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
+from . import calendar_attendee
diff --git a/addons/microsoft_calendar/models/calendar.py b/addons/microsoft_calendar/models/calendar.py
new file mode 100644
index 00000000..625587c0
--- /dev/null
+++ b/addons/microsoft_calendar/models/calendar.py
@@ -0,0 +1,411 @@
+# -*- 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, ValidationError
+
+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 []
+ 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)])
+ elif self.env.user.partner_id.email not in emails:
+ commands_attendee += [(0, 0, {'state': 'accepted', 'partner_id': self.env.user.partner_id.id})]
+ commands_partner += [(4, self.env.user.partner_id.id)]
+ 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 _ensure_attendees_have_email(self):
+ invalid_event_ids = self.env['calendar.event'].search_read(
+ domain=[('id', 'in', self.ids), ('attendee_ids.partner_id.email', '=', False)],
+ fields=['display_time', 'display_name'],
+ order='start',
+ )
+ if invalid_event_ids:
+ list_length_limit = 50
+ total_invalid_events = len(invalid_event_ids)
+ invalid_event_ids = invalid_event_ids[:list_length_limit]
+ invalid_events = ['\t- %s: %s' % (event['display_time'], event['display_name'])
+ for event in invalid_event_ids]
+ invalid_events = '\n'.join(invalid_events)
+ details = "(%d/%d)" % (list_length_limit, total_invalid_events) if list_length_limit < total_invalid_events else "(%d)" % total_invalid_events
+ raise ValidationError(_("For a correct synchronization between Odoo and Outlook Calendar, "
+ "all attendees must have an email address. However, some events do "
+ "not respect this condition. As long as the events are incorrect, "
+ "the calendars will not be synchronized."
+ "\nEither update the events/attendees or archive these events %s:"
+ "\n%s", details, invalid_events))
+
+ 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'
+
+ def _notify_attendees(self):
+ # filter events before notifying attendees through calendar_alarm_manager
+ need_notifs = self.filtered(lambda event: event.alarm_ids and event.stop >= fields.Datetime.now())
+ partners = need_notifs.partner_ids
+ if partners:
+ self.env['calendar.alarm_manager']._notify_next_alarm(partners.ids)
diff --git a/addons/microsoft_calendar/models/calendar_attendee.py b/addons/microsoft_calendar/models/calendar_attendee.py
new file mode 100644
index 00000000..9a462c38
--- /dev/null
+++ b/addons/microsoft_calendar/models/calendar_attendee.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models
+
+from odoo.addons.microsoft_calendar.models.microsoft_sync import microsoft_calendar_token
+
+
+class Attendee(models.Model):
+ _name = 'calendar.attendee'
+ _inherit = 'calendar.attendee'
+
+ def _send_mail_to_attendees(self, template_xmlid, force_send=False, ignore_recurrence=False):
+ """ Override the super method
+ If not synced with Microsoft Outlook, let Odoo in charge of sending emails
+ Otherwise, Microsoft Outlook will send them
+ """
+ with microsoft_calendar_token(self.env.user.sudo()) as token:
+ if not token:
+ super()._send_mail_to_attendees(template_xmlid, force_send, ignore_recurrence)
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 00000000..431e01b6
--- /dev/null
+++ b/addons/microsoft_calendar/models/calendar_recurrence_rule.py
@@ -0,0 +1,118 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import re
+
+from odoo import api, fields, models
+from dateutil.relativedelta import relativedelta
+
+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
+
+ def _ensure_attendees_have_email(self):
+ self.calendar_event_ids.filtered(lambda e: e.active)._ensure_attendees_have_email()
+
+ def _notify_attendees(self):
+ recurrences = self.filtered(
+ lambda recurrence: recurrence.base_event_id.alarm_ids and (
+ not recurrence.until or recurrence.until >= fields.Date.today() - relativedelta(days=1)
+ ) and max(recurrence.calendar_event_ids.mapped('stop')) >= fields.Datetime.now()
+ )
+ partners = recurrences.base_event_id.partner_ids
+ if partners:
+ self.env['calendar.alarm_manager']._notify_next_alarm(partners.ids)
diff --git a/addons/microsoft_calendar/models/microsoft_sync.py b/addons/microsoft_calendar/models/microsoft_sync.py
new file mode 100644
index 00000000..ab2189cc
--- /dev/null
+++ b/addons/microsoft_calendar/models/microsoft_sync.py
@@ -0,0 +1,378 @@
+# -*- 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
+
+ @self.env.cr.postcommit.add
+ 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)
+
+ 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 Outlook 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
+
+ records_to_sync._ensure_attendees_have_email()
+ 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))
+ if value.get('start') and value.get('stop'):
+ 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.with_context(dont_notify=True).create(odoo_values)
+
+ synced_recurrent_records = self.with_context(dont_notify=True)._sync_recurrence_microsoft2odoo(new_recurrent)
+ if not self._context.get("dont_notify"):
+ new_odoo._notify_attendees()
+ synced_recurrent_records._notify_attendees()
+
+ 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:
+ self._ensure_attendees_have_email()
+ 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:
+ self._ensure_attendees_have_email()
+ 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 _ensure_attendees_have_email(self):
+ 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()
+
+ def _notify_attendees(self):
+ """ Notify calendar event partners.
+ This is called when creating new calendar events in _sync_microsoft2odoo.
+ At the initialization of a synced calendar, Odoo requests all events for a specific
+ MicrosoftCalendar. Among those there will probably be lots of events that will never triggers a notification
+ (e.g. single events that occured in the past). Processing all these events through the notification procedure
+ of calendar.event.create is a possible performance bottleneck. This method aimed at alleviating that.
+ """
+ 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 00000000..38ac9565
--- /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 00000000..dec51d17
--- /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 Outlook 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))