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