1# -*- coding: utf-8 -*- 2# Part of Odoo. See LICENSE file for full copyright and licensing details. 3 4import logging 5from contextlib import contextmanager 6from functools import wraps 7import requests 8import pytz 9from dateutil.parser import parse 10 11from odoo import api, fields, models, registry, _ 12from odoo.tools import ormcache_context 13from odoo.exceptions import UserError 14from odoo.osv import expression 15 16from odoo.addons.microsoft_calendar.utils.microsoft_event import MicrosoftEvent 17from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService 18from odoo.addons.microsoft_account.models.microsoft_service import TIMEOUT 19 20_logger = logging.getLogger(__name__) 21 22MAX_RECURRENT_EVENT = 720 23 24 25# API requests are sent to Microsoft Calendar after the current transaction ends. 26# This ensures changes are sent to Microsoft only if they really happened in the Odoo database. 27# It is particularly important for event creation , otherwise the event might be created 28# twice in Microsoft if the first creation crashed in Odoo. 29def after_commit(func): 30 @wraps(func) 31 def wrapped(self, *args, **kwargs): 32 dbname = self.env.cr.dbname 33 context = self.env.context 34 uid = self.env.uid 35 36 @self.env.cr.postcommit.add 37 def called_after(): 38 db_registry = registry(dbname) 39 with api.Environment.manage(), db_registry.cursor() as cr: 40 env = api.Environment(cr, uid, context) 41 try: 42 func(self.with_env(env), *args, **kwargs) 43 except Exception as e: 44 _logger.warning("Could not sync record now: %s" % self) 45 _logger.exception(e) 46 47 return wrapped 48 49@contextmanager 50def microsoft_calendar_token(user): 51 try: 52 yield user._get_microsoft_calendar_token() 53 except requests.HTTPError as e: 54 if e.response.status_code == 401: # Invalid token. 55 # The transaction should be rolledback, but the user's tokens 56 # should be reset. The user will be asked to authenticate again next time. 57 # Rollback manually first to avoid concurrent access errors/deadlocks. 58 user.env.cr.rollback() 59 with user.pool.cursor() as cr: 60 env = user.env(cr=cr) 61 user.with_env(env)._set_microsoft_auth_tokens(False, False, 0) 62 raise e 63 64class MicrosoftSync(models.AbstractModel): 65 _name = 'microsoft.calendar.sync' 66 _description = "Synchronize a record with Microsoft Calendar" 67 68 microsoft_id = fields.Char('Microsoft Calendar Id', copy=False) 69 need_sync_m = fields.Boolean(default=True, copy=False) 70 active = fields.Boolean(default=True) 71 72 def write(self, vals): 73 microsoft_service = MicrosoftCalendarService(self.env['microsoft.service']) 74 if 'microsoft_id' in vals: 75 self._from_microsoft_ids.clear_cache(self) 76 synced_fields = self._get_microsoft_synced_fields() 77 if 'need_sync_m' not in vals and vals.keys() & synced_fields: 78 fields_to_sync = [x for x in vals.keys() if x in synced_fields] 79 if fields_to_sync: 80 vals['need_sync_m'] = True 81 else: 82 fields_to_sync = [x for x in vals.keys() if x in synced_fields] 83 84 result = super().write(vals) 85 for record in self.filtered('need_sync_m'): 86 if record.microsoft_id and fields_to_sync: 87 values = record._microsoft_values(fields_to_sync) 88 if not values: 89 continue 90 record._microsoft_patch(microsoft_service, record.microsoft_id, values, timeout=3) 91 92 return result 93 94 @api.model_create_multi 95 def create(self, vals_list): 96 if any(vals.get('microsoft_id') for vals in vals_list): 97 self._from_microsoft_ids.clear_cache(self) 98 records = super().create(vals_list) 99 100 microsoft_service = MicrosoftCalendarService(self.env['microsoft.service']) 101 records_to_sync = records.filtered(lambda r: r.need_sync_m and r.active) 102 for record in records_to_sync: 103 record._microsoft_insert(microsoft_service, record._microsoft_values(self._get_microsoft_synced_fields()), timeout=3) 104 return records 105 106 def unlink(self): 107 """We can't delete an event that is also in Microsoft Calendar. Otherwise we would 108 have no clue that the event must must deleted from Microsoft Calendar at the next sync. 109 """ 110 synced = self.filtered('microsoft_id') 111 if self.env.context.get('archive_on_error') and self._active_name: 112 synced.write({self._active_name: False}) 113 self = self - synced 114 elif synced: 115 raise UserError(_("You cannot delete a record synchronized with Outlook Calendar, archive it instead.")) 116 return super().unlink() 117 118 @api.model 119 @ormcache_context('microsoft_ids', keys=('active_test',)) 120 def _from_microsoft_ids(self, microsoft_ids): 121 if not microsoft_ids: 122 return self.browse() 123 return self.search([('microsoft_id', 'in', microsoft_ids)]) 124 125 def _sync_odoo2microsoft(self, microsoft_service: MicrosoftCalendarService): 126 if not self: 127 return 128 if self._active_name: 129 records_to_sync = self.filtered(self._active_name) 130 else: 131 records_to_sync = self 132 cancelled_records = self - records_to_sync 133 134 records_to_sync._ensure_attendees_have_email() 135 updated_records = records_to_sync.filtered('microsoft_id') 136 new_records = records_to_sync - updated_records 137 for record in cancelled_records.filtered('microsoft_id'): 138 record._microsoft_delete(microsoft_service, record.microsoft_id) 139 for record in new_records: 140 values = record._microsoft_values(self._get_microsoft_synced_fields()) 141 if isinstance(values, dict): 142 record._microsoft_insert(microsoft_service, values) 143 else: 144 for value in values: 145 record._microsoft_insert(microsoft_service, value) 146 for record in updated_records: 147 values = record._microsoft_values(self._get_microsoft_synced_fields()) 148 if not values: 149 continue 150 record._microsoft_patch(microsoft_service, record.microsoft_id, values) 151 152 def _cancel_microsoft(self): 153 self.microsoft_id = False 154 self.unlink() 155 156 def _sync_recurrence_microsoft2odoo(self, microsoft_events: MicrosoftEvent): 157 recurrent_masters = microsoft_events.filter(lambda e: e.is_recurrence()) 158 recurrents = microsoft_events.filter(lambda e: e.is_recurrent_not_master()) 159 default_values = {'need_sync_m': False} 160 161 new_recurrence = self.env['calendar.recurrence'] 162 163 for recurrent_master in recurrent_masters: 164 new_calendar_recurrence = dict(self.env['calendar.recurrence']._microsoft_to_odoo_values(recurrent_master, (), default_values), need_sync_m=False) 165 to_create = recurrents.filter(lambda e: e.seriesMasterId == new_calendar_recurrence['microsoft_id']) 166 recurrents -= to_create 167 base_values = dict(self.env['calendar.event']._microsoft_to_odoo_values(recurrent_master, (), default_values), need_sync_m=False) 168 to_create_values = [] 169 if new_calendar_recurrence.get('end_type', False) in ['count', 'forever']: 170 to_create = list(to_create)[:MAX_RECURRENT_EVENT] 171 for recurrent_event in to_create: 172 if recurrent_event.type == 'occurrence': 173 value = self.env['calendar.event']._microsoft_to_odoo_recurrence_values(recurrent_event, (), base_values) 174 else: 175 value = self.env['calendar.event']._microsoft_to_odoo_values(recurrent_event, (), default_values) 176 177 to_create_values += [dict(value, need_sync_m=False)] 178 179 new_calendar_recurrence['calendar_event_ids'] = [(0, 0, to_create_value) for to_create_value in to_create_values] 180 new_recurrence_odoo = self.env['calendar.recurrence'].create(new_calendar_recurrence) 181 new_recurrence_odoo.base_event_id = new_recurrence_odoo.calendar_event_ids[0] if new_recurrence_odoo.calendar_event_ids else False 182 new_recurrence |= new_recurrence_odoo 183 184 for recurrent_master_id in set([x.seriesMasterId for x in recurrents]): 185 recurrence_id = self.env['calendar.recurrence'].search([('microsoft_id', '=', recurrent_master_id)]) 186 to_update = recurrents.filter(lambda e: e.seriesMasterId == recurrent_master_id) 187 for recurrent_event in to_update: 188 if recurrent_event.type == 'occurrence': 189 value = self.env['calendar.event']._microsoft_to_odoo_recurrence_values(recurrent_event, (), {'need_sync_m': False}) 190 else: 191 value = self.env['calendar.event']._microsoft_to_odoo_values(recurrent_event, (), default_values) 192 existing_event = recurrence_id.calendar_event_ids.filtered(lambda e: e._range() == (value['start'], value['stop'])) 193 if not existing_event: 194 continue 195 value.pop('start') 196 value.pop('stop') 197 existing_event.write(value) 198 new_recurrence |= recurrence_id 199 return new_recurrence 200 201 def _update_microsoft_recurrence(self, recurrence_event, events): 202 vals = dict(self.base_event_id._microsoft_to_odoo_values(recurrence_event, ()), need_sync_m=False) 203 vals['microsoft_recurrence_master_id'] = vals.pop('microsoft_id') 204 self.base_event_id.write(vals) 205 values = {} 206 default_values = {} 207 208 normal_events = [] 209 events_to_update = events.filter(lambda e: e.seriesMasterId == self.microsoft_id) 210 if self.end_type in ['count', 'forever']: 211 events_to_update = list(events_to_update)[:MAX_RECURRENT_EVENT] 212 213 for recurrent_event in events_to_update: 214 if recurrent_event.type == 'occurrence': 215 value = self.env['calendar.event']._microsoft_to_odoo_recurrence_values(recurrent_event, (), default_values) 216 normal_events += [recurrent_event.odoo_id(self.env)] 217 else: 218 value = self.env['calendar.event']._microsoft_to_odoo_values(recurrent_event, (), default_values) 219 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)) 220 if value.get('start') and value.get('stop'): 221 values[(self.id, value.get('start'), value.get('stop'))] = dict(value, need_sync_m=False) 222 223 if (self.id, vals.get('start'), vals.get('stop')) in values: 224 base_event_vals = dict(vals) 225 base_event_vals.update(values[(self.id, vals.get('start'), vals.get('stop'))]) 226 self.base_event_id.write(base_event_vals) 227 228 old_record = self._apply_recurrence(specific_values_creation=values, no_send_edit=True) 229 230 vals.pop('microsoft_id', None) 231 vals.pop('start', None) 232 vals.pop('stop', None) 233 normal_events = [e for e in normal_events if e in self.calendar_event_ids.ids] 234 normal_event_ids = self.env['calendar.event'].browse(normal_events) - old_record 235 if normal_event_ids: 236 vals['follow_recurrence'] = True 237 (self.env['calendar.event'].browse(normal_events) - old_record).write(vals) 238 239 old_record._cancel_microsoft() 240 if not self.base_event_id: 241 self.base_event_id = self._get_first_event(include_outliers=False) 242 243 @api.model 244 def _sync_microsoft2odoo(self, microsoft_events: MicrosoftEvent, default_reminders=()): 245 """Synchronize Microsoft recurrences in Odoo. Creates new recurrences, updates 246 existing ones. 247 248 :return: synchronized odoo 249 """ 250 existing = microsoft_events.exists(self.env) 251 new = microsoft_events - existing - microsoft_events.cancelled() 252 new_recurrent = new.filter(lambda e: e.is_recurrent()) 253 254 default_values = {} 255 256 odoo_values = [ 257 dict(self._microsoft_to_odoo_values(e, default_reminders, default_values), need_sync_m=False) 258 for e in (new - new_recurrent) 259 ] 260 new_odoo = self.with_context(dont_notify=True).create(odoo_values) 261 262 synced_recurrent_records = self.with_context(dont_notify=True)._sync_recurrence_microsoft2odoo(new_recurrent) 263 if not self._context.get("dont_notify"): 264 new_odoo._notify_attendees() 265 synced_recurrent_records._notify_attendees() 266 267 cancelled = existing.cancelled() 268 cancelled_odoo = self.browse(cancelled.odoo_ids(self.env)) 269 cancelled_odoo._cancel_microsoft() 270 271 recurrent_cancelled = self.env['calendar.recurrence'].search([ 272 ('microsoft_id', 'in', (microsoft_events.cancelled() - cancelled).microsoft_ids())]) 273 recurrent_cancelled._cancel_microsoft() 274 275 synced_records = new_odoo + cancelled_odoo + synced_recurrent_records.calendar_event_ids 276 277 for mevent in (existing - cancelled).filter(lambda e: e.lastModifiedDateTime and not e.seriesMasterId): 278 # Last updated wins. 279 # This could be dangerous if microsoft server time and odoo server time are different 280 if mevent.is_recurrence(): 281 odoo_record = self.env['calendar.recurrence'].browse(mevent.odoo_id(self.env)) 282 else: 283 odoo_record = self.browse(mevent.odoo_id(self.env)) 284 odoo_record_updated = pytz.utc.localize(odoo_record.write_date) 285 updated = parse(mevent.lastModifiedDateTime or str(odoo_record_updated)) 286 if updated >= odoo_record_updated: 287 vals = dict(odoo_record._microsoft_to_odoo_values(mevent, default_reminders), need_sync_m=False) 288 odoo_record.write(vals) 289 if odoo_record._name == 'calendar.recurrence': 290 odoo_record._update_microsoft_recurrence(mevent, microsoft_events) 291 synced_recurrent_records |= odoo_record 292 else: 293 synced_records |= odoo_record 294 295 return synced_records, synced_recurrent_records 296 297 @after_commit 298 def _microsoft_delete(self, microsoft_service: MicrosoftCalendarService, microsoft_id, timeout=TIMEOUT): 299 with microsoft_calendar_token(self.env.user.sudo()) as token: 300 if token: 301 microsoft_service.delete(microsoft_id, token=token, timeout=timeout) 302 303 @after_commit 304 def _microsoft_patch(self, microsoft_service: MicrosoftCalendarService, microsoft_id, values, timeout=TIMEOUT): 305 with microsoft_calendar_token(self.env.user.sudo()) as token: 306 if token: 307 self._ensure_attendees_have_email() 308 microsoft_service.patch(microsoft_id, values, token=token, timeout=timeout) 309 self.need_sync_m = False 310 311 @after_commit 312 def _microsoft_insert(self, microsoft_service: MicrosoftCalendarService, values, timeout=TIMEOUT): 313 if not values: 314 return 315 with microsoft_calendar_token(self.env.user.sudo()) as token: 316 if token: 317 self._ensure_attendees_have_email() 318 microsoft_id = microsoft_service.insert(values, token=token, timeout=timeout) 319 self.write({ 320 'microsoft_id': microsoft_id, 321 'need_sync_m': False, 322 }) 323 324 def _get_microsoft_records_to_sync(self, full_sync=False): 325 """Return records that should be synced from Odoo to Microsoft 326 327 :param full_sync: If True, all events attended by the user are returned 328 :return: events 329 """ 330 domain = self._get_microsoft_sync_domain() 331 if not full_sync: 332 is_active_clause = (self._active_name, '=', True) if self._active_name else expression.TRUE_LEAF 333 domain = expression.AND([domain, [ 334 '|', 335 '&', ('microsoft_id', '=', False), is_active_clause, 336 ('need_sync_m', '=', True), 337 ]]) 338 return self.with_context(active_test=False).search(domain) 339 340 @api.model 341 def _microsoft_to_odoo_values(self, microsoft_event: MicrosoftEvent, default_reminders=()): 342 """Implements this method to return a dict of Odoo values corresponding 343 to the Microsoft event given as parameter 344 :return: dict of Odoo formatted values 345 """ 346 raise NotImplementedError() 347 348 def _microsoft_values(self, fields_to_sync): 349 """Implements this method to return a dict with values formatted 350 according to the Microsoft Calendar API 351 :return: dict of Microsoft formatted values 352 """ 353 raise NotImplementedError() 354 355 def _ensure_attendees_have_email(self): 356 raise NotImplementedError() 357 358 def _get_microsoft_sync_domain(self): 359 """Return a domain used to search records to synchronize. 360 e.g. return a domain to synchronize records owned by the current user. 361 """ 362 raise NotImplementedError() 363 364 def _get_microsoft_synced_fields(self): 365 """Return a set of field names. Changing one of these fields 366 marks the record to be re-synchronized. 367 """ 368 raise NotImplementedError() 369 370 def _notify_attendees(self): 371 """ Notify calendar event partners. 372 This is called when creating new calendar events in _sync_microsoft2odoo. 373 At the initialization of a synced calendar, Odoo requests all events for a specific 374 MicrosoftCalendar. Among those there will probably be lots of events that will never triggers a notification 375 (e.g. single events that occured in the past). Processing all these events through the notification procedure 376 of calendar.event.create is a possible performance bottleneck. This method aimed at alleviating that. 377 """ 378 raise NotImplementedError() 379