diff --git a/addons/microsoft_calendar/models/calendar.py b/addons/microsoft_calendar/models/calendar.py index ec4b3ba286f..280ce6aee2b 100644 --- a/addons/microsoft_calendar/models/calendar.py +++ b/addons/microsoft_calendar/models/calendar.py @@ -45,8 +45,9 @@ class Meeting(models.Model): @api.model_create_multi def create(self, vals_list): - return super().create([ - dict(vals, need_sync_m=False) if vals.get('recurrency') else vals + notify_context = self.env.context.get('dont_notify', False) + return super(Meeting, self.with_context(dont_notify=notify_context)).create([ + dict(vals, need_sync_m=False) if vals.get('recurrence_id') or vals.get('recurrency') else vals for vals in vals_list ]) @@ -61,10 +62,26 @@ class Meeting(models.Model): 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) + notify_context = self.env.context.get('dont_notify', False) + res = super(Meeting, self.with_context(dont_notify=notify_context)).write(values) + if recurrence_update_setting in ('all_events',) and len(self) == 1 and values.keys() & self._get_microsoft_synced_fields(): + self.recurrence_id.need_sync_m = True + return res def _get_microsoft_sync_domain(self): - return [('partner_ids.user_ids', 'in', self.env.user.id)] + # in case of full sync, limit to a range of 1y in past and 1y in the future by default + ICP = self.env['ir.config_parameter'].sudo() + day_range = int(ICP.get_param('google_calendar.sync.range_days', default=365)) + lower_bound = fields.Datetime.subtract(fields.Datetime.now(), days=day_range) + upper_bound = fields.Datetime.add(fields.Datetime.now(), days=day_range) + return [ + ('partner_ids.user_ids', 'in', self.env.user.id), + ('stop', '>', lower_bound), + ('start', '<', upper_bound), + # Do not sync events that follow the recurrence, they are already synced at recurrence creation + '!', '&', '&', ('recurrency', '=', True), ('recurrence_id', '!=', False), ('follow_recurrence', '=', True) + ] + @api.model def _microsoft_to_odoo_values(self, microsoft_event, default_reminders=(), default_values={}): @@ -93,13 +110,20 @@ class Meeting(models.Model): '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() } + if commands_partner: + # Add partner_commands only if set from Microsoft. The write method on calendar_events will + # override attendee commands if the partner_ids command is set but empty. + values['partner_ids'] = commands_partner + + if microsoft_event.is_recurrent() and not microsoft_event.is_recurrence(): + # Propagate the follow_recurrence according to the google result + values['follow_recurrence'] = not microsoft_event.is_recurrence_outlier() values['microsoft_id'] = microsoft_event.id if microsoft_event.is_recurrent(): @@ -306,7 +330,7 @@ class Meeting(models.Model): pattern['type'] = recurrence.rrule_type else: prefix = 'absolute' if recurrence.month_by == 'date' else 'relative' - pattern['type'] = prefix + recurrence.rrule_type.capitalize() + pattern['type'] = recurrence.rrule_type and prefix + recurrence.rrule_type.capitalize() if recurrence.month_by == 'date': pattern['dayOfMonth'] = recurrence.day @@ -334,8 +358,9 @@ class Meeting(models.Model): } pattern['index'] = byday_selection[recurrence.byday] + dtstart = recurrence.dtstart or fields.Datetime.now() rule_range = { - 'startDate': (recurrence.dtstart.date()).isoformat() + 'startDate': (dtstart.date()).isoformat() } if recurrence.end_type == 'count': # e.g. stop after X occurence diff --git a/addons/microsoft_calendar/models/calendar_attendee.py b/addons/microsoft_calendar/models/calendar_attendee.py index c15e5faf994..6a28a68e0f5 100644 --- a/addons/microsoft_calendar/models/calendar_attendee.py +++ b/addons/microsoft_calendar/models/calendar_attendee.py @@ -11,17 +11,6 @@ class Attendee(models.Model): _name = 'calendar.attendee' _inherit = 'calendar.attendee' - def write(self, vals): - """ - Tell to resync the link microsoft/odoo of the event when the status of an attendee is modified - """ - res = super().write(vals) - if vals.get('state'): - # When the state is changed, the corresponding event must be sync with microsoft - microsoft_service = MicrosoftCalendarService(self.env['microsoft.service']) - self.event_id.filtered('microsoft_id')._sync_odoo2microsoft(microsoft_service) - return res - def _send_mail_to_attendees(self, mail_template, force_send=False): """ Override the super method If not synced with Microsoft Outlook, let Odoo in charge of sending emails diff --git a/addons/microsoft_calendar/models/calendar_recurrence_rule.py b/addons/microsoft_calendar/models/calendar_recurrence_rule.py index b26776195b3..c7cfbe8761e 100644 --- a/addons/microsoft_calendar/models/calendar_recurrence_rule.py +++ b/addons/microsoft_calendar/models/calendar_recurrence_rule.py @@ -1,8 +1,6 @@ # -*- 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 @@ -18,8 +16,20 @@ class RecurrenceRule(models.Model): microsoft_id = fields.Char('Microsoft Calendar Recurrence Id') + def _compute_rrule(self): + for recurrence in self: + if recurrence.rrule != recurrence._rrule_serialize(): + recurrence.write({'rrule': recurrence._rrule_serialize()}) + + def _inverse_rrule(self): + for recurrence in self.filtered('rrule'): + values = self._rrule_parse(recurrence.rrule, recurrence.dtstart) + # We avoid to trigger patch in the inverse + values.update({'need_sync_m': False}) + recurrence.write(values) + def _apply_recurrence(self, specific_values_creation=None, no_send_edit=False): - events = self.calendar_event_ids + events = self.filtered('need_sync_m').calendar_event_ids detached_events = super()._apply_recurrence(specific_values_creation, no_send_edit) microsoft_service = MicrosoftCalendarService(self.env['microsoft.service']) @@ -44,18 +54,6 @@ class RecurrenceRule(models.Model): 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 @@ -63,10 +61,13 @@ class RecurrenceRule(models.Model): # 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_rrule(self, dtstart=None): + if not dtstart and self.dtstart: + dtstart = self.dtstart + return super()._get_rrule(dtstart) + def _get_microsoft_synced_fields(self): return {'rrule'} | self.env['calendar.event']._get_microsoft_synced_fields() @@ -76,13 +77,56 @@ class RecurrenceRule(models.Model): 'need_sync_m': True, }) + def _write_from_microsoft(self, microsoft_event, vals): + current_rrule = self.rrule + # event_tz is written on event in Microsoft but on recurrence in Odoo + vals['event_tz'] = microsoft_event.start.get('timeZone') + super()._write_from_microsoft(microsoft_event, vals) + base_event_time_fields = ['start', 'stop', 'allday'] + new_event_values = self.env["calendar.event"]._microsoft_to_odoo_values(microsoft_event) + old_event_values = self.base_event_id and self.base_event_id.read(base_event_time_fields)[0] + if old_event_values and any(new_event_values[key] != old_event_values[key] for key in base_event_time_fields): + # we need to recreate the recurrence, time_fields were modified. + base_event_id = self.base_event_id + # We archive the old events to recompute the recurrence. These events are already deleted on Microsoft side. + # We can't call _cancel because events without user_id would not be deleted + (self.calendar_event_ids - base_event_id).microsoft_id = False + (self.calendar_event_ids - base_event_id).unlink() + base_event_id.with_context(dont_notify=True).write(dict(new_event_values, microsoft_id=False, need_sync_m=False)) + if self.rrule == current_rrule: + # if the rrule has changed, it will be recalculated below + # There is no detached event now + self.with_context(dont_notify=True)._apply_recurrence() + else: + time_fields = ( + self.env["calendar.event"]._get_time_fields() + | self.env["calendar.event"]._get_recurrent_fields() + ) + # We avoid to write time_fields because they are not shared between events. + self._write_events(dict({ + field: value + for field, value in new_event_values.items() + if field not in time_fields + }, need_sync_m=False) + ) + # We apply the rrule check after the time_field check because the microsoft_id are generated according + # to base_event start datetime. + if self.rrule != current_rrule: + detached_events = self._apply_recurrence() + detached_events.microsoft_id = False + detached_events.unlink() + def _get_microsoft_sync_domain(self): - return [('calendar_event_ids.user_id', '=', self.env.user.id)] + # Empty rrule may exists in historical data. It is not a desired behavior but it could have been created with + # older versions of the module. When synced, these recurrence may come back from Microsoft after database cleaning + # and trigger errors as the records are not properly populated. + # We also prevent sync of other user recurrent events. + return [('calendar_event_ids.user_id', '=', self.env.user.id), ('rrule', '!=', False)] + 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() + super()._cancel_microsoft() @api.model def _microsoft_to_odoo_values(self, microsoft_recurrence, default_reminders=(), default_values={}): @@ -94,19 +138,21 @@ class RecurrenceRule(models.Model): } 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) + events = self.calendar_event_ids.sorted('start') 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'})] + 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] - + values['id'] = self.microsoft_id + if events_outliers: + # We send the data as a list. If we directly send a list of values, we have issues... + values = [values] + for event in events_outliers: + event_value = event._microsoft_values(fields_to_sync) + values += [event_value] return values def _ensure_attendees_have_email(self): diff --git a/addons/microsoft_calendar/models/microsoft_sync.py b/addons/microsoft_calendar/models/microsoft_sync.py index 8b052410886..cd0a84e5fb2 100644 --- a/addons/microsoft_calendar/models/microsoft_sync.py +++ b/addons/microsoft_calendar/models/microsoft_sync.py @@ -73,13 +73,16 @@ class MicrosoftSync(models.AbstractModel): fields_to_sync = [x for x in vals.keys() if x in synced_fields] result = super().write(vals) + need_delete = 'active' in vals.keys() and not vals.get('active') for record in self.filtered('need_sync_m'): - if record.microsoft_id and fields_to_sync: + if need_delete and record.microsoft_id: + # We need to delete the event. Cancel is not sufficant. Errors may occurs + record._microsoft_delete(microsoft_service, record.microsoft_id, timeout=3) + elif 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 @@ -97,23 +100,20 @@ class MicrosoftSync(models.AbstractModel): record._microsoft_insert(microsoft_service, record._microsoft_values(self._get_microsoft_synced_fields()), timeout=3) return records - @api.ondelete(at_uninstall=False) - def _unlink_except_synchronized(self): - if self.env.context.get('archive_on_error') and self._active_name: - return - if self.filtered('microsoft_id'): - raise UserError(_("You cannot delete a record synchronized with Microsoft Calendar, archive it instead.")) - 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 + microsoft_service = MicrosoftCalendarService(self.env['microsoft.service']) + for ev in synced: + ev._microsoft_delete(microsoft_service, ev.microsoft_id) return super().unlink() + def _write_from_microsoft(self, microsoft_event, vals): + self.write(vals) + + @api.model + def _create_from_microsoft(self, microsoft_event, vals_list): + return self.create(vals_list) + @api.model @ormcache_context('microsoft_ids', keys=('active_test',)) def _from_microsoft_ids(self, microsoft_ids): @@ -142,7 +142,7 @@ class MicrosoftSync(models.AbstractModel): else: for value in values: record._microsoft_insert(microsoft_service, value) - for record in updated_records: + for record in updated_records.filtered('need_sync_m'): values = record._microsoft_values(self._get_microsoft_synced_fields()) if not values: continue @@ -180,8 +180,10 @@ class MicrosoftSync(models.AbstractModel): 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 + microsoft_ids = [x.seriesMasterId for x in recurrents] + recurrences = self.env['calendar.recurrence'].search([('microsoft_id', 'in', microsoft_ids)]) for recurrent_master_id in set([x.seriesMasterId for x in recurrents]): - recurrence_id = self.env['calendar.recurrence'].search([('microsoft_id', '=', recurrent_master_id)]) + recurrence_id = recurrences.filtered(lambda ev: ev.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': @@ -193,7 +195,7 @@ class MicrosoftSync(models.AbstractModel): continue value.pop('start') value.pop('stop') - existing_event.write(value) + existing_event._write_from_microsoft(recurrent_event, value) new_recurrence |= recurrence_id return new_recurrence @@ -256,7 +258,7 @@ class MicrosoftSync(models.AbstractModel): 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) + new_odoo = self.with_context(dont_notify=True)._create_from_microsoft(new, odoo_values) synced_recurrent_records = self._sync_recurrence_microsoft2odoo(new_recurrent) @@ -281,7 +283,7 @@ class MicrosoftSync(models.AbstractModel): 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) + odoo_record._write_from_microsoft(mevent, vals) if odoo_record._name == 'calendar.recurrence': odoo_record._update_microsoft_recurrence(mevent, microsoft_events) synced_recurrent_records |= odoo_record diff --git a/addons/microsoft_calendar/static/src/js/microsoft_calendar.js b/addons/microsoft_calendar/static/src/js/microsoft_calendar.js index e1644447962..1c3adb6fce1 100644 --- a/addons/microsoft_calendar/static/src/js/microsoft_calendar.js +++ b/addons/microsoft_calendar/static/src/js/microsoft_calendar.js @@ -265,8 +265,6 @@ const MicrosoftCalendarRenderer = CalendarRenderer.include({ var self = this; var context = this.getSession().user_context; this.$microsoftButton.prop('disabled', true); - this._getMicrosoftStopButton(); - this.$microsoftButton.hide(); this.trigger_up('syncMicrosoftCalendar', { on_always: function () { self.$microsoftButton.prop('disabled', false); diff --git a/addons/microsoft_calendar/tests/test_sync_odoo2microsoft.py b/addons/microsoft_calendar/tests/test_sync_odoo2microsoft.py index 689e136604c..c1d552af424 100644 --- a/addons/microsoft_calendar/tests/test_sync_odoo2microsoft.py +++ b/addons/microsoft_calendar/tests/test_sync_odoo2microsoft.py @@ -69,11 +69,12 @@ class TestSyncOdoo2Microsoft(TransactionCase): 'partner_id': partner.id, }) user.stop_microsoft_synchronization() + # In case of full sync, limit to a range of 1y in past and 1y in the future by default event = self.env['calendar.event'].with_user(user).create({ 'microsoft_id': microsoft_id, 'name': "Event", - 'start': datetime(2020, 1, 15, 8, 0), - 'stop': datetime(2020, 1, 15, 18, 0), + 'start': datetime(2021, 1, 15, 8, 0), + 'stop': datetime(2021, 1, 15, 18, 0), 'partner_ids': [(4, partner.id)], }) @@ -82,8 +83,8 @@ class TestSyncOdoo2Microsoft(TransactionCase): microsoft_guid = self.env['ir.config_parameter'].sudo().get_param('microsoft_calendar.microsoft_guid', False) self.assertMicrosoftEventPatched(event.microsoft_id, { 'id': event.microsoft_id, - 'start': {'dateTime': '2020-01-15T08:00:00+00:00', 'timeZone': 'Europe/London'}, - 'end': {'dateTime': '2020-01-15T18:00:00+00:00', 'timeZone': 'Europe/London'}, + 'start': {'dateTime': '2021-01-15T08:00:00+00:00', 'timeZone': 'Europe/London'}, + 'end': {'dateTime': '2021-01-15T18:00:00+00:00', 'timeZone': 'Europe/London'}, 'subject': 'Event', 'body': {'content': '', 'contentType': 'text'}, 'attendees': [], diff --git a/addons/microsoft_calendar/utils/microsoft_calendar.py b/addons/microsoft_calendar/utils/microsoft_calendar.py index f74959f1d2b..b8f03f60ba5 100644 --- a/addons/microsoft_calendar/utils/microsoft_calendar.py +++ b/addons/microsoft_calendar/utils/microsoft_calendar.py @@ -7,7 +7,6 @@ import logging from werkzeug import urls -from odoo import api, _ from odoo.addons.microsoft_calendar.utils.microsoft_event import MicrosoftEvent from odoo.addons.microsoft_account.models.microsoft_service import TIMEOUT @@ -66,14 +65,14 @@ class MicrosoftCalendarService(): headers = {'Content-type': 'application/json', 'Authorization': 'Bearer %s' % token} if not values.get('id'): values.pop('id', None) - status, data, time = self.microsoft_service._do_request(url, json.dumps(values), headers, method='POST', timeout=timeout) + dummy, data, dummy = self.microsoft_service._do_request(url, json.dumps(values, separators=(',', ':')), headers, method='POST', timeout=timeout) return data['id'] @requires_auth_token def patch(self, event_id, values, token=None, timeout=TIMEOUT): url = "/v1.0/me/calendar/events/%s" % event_id headers = {'Content-type': 'application/json', 'Authorization': 'Bearer %s' % token} - self.microsoft_service._do_request(url, json.dumps(values), headers, method='PATCH', timeout=timeout) + self.microsoft_service._do_request(url, json.dumps(values, separators=(',', ':')), headers, method='PATCH', timeout=timeout) @requires_auth_token def delete(self, event_id, token=None, timeout=TIMEOUT): diff --git a/addons/microsoft_calendar/utils/microsoft_event.py b/addons/microsoft_calendar/utils/microsoft_event.py index 7f13a3d03de..e137aa0ebd0 100644 --- a/addons/microsoft_calendar/utils/microsoft_event.py +++ b/addons/microsoft_calendar/utils/microsoft_event.py @@ -191,7 +191,8 @@ class MicrosoftEvent(abc.Set): if month_by: result['month_by'] = month_by - week_days = [x[:2] for x in pattern.get('daysOfWeek', [])] + # daysOfWeek contains the full name of the day, the fields contain the first 3 letters (mon, tue, etc) + week_days = [x[:3] for x in pattern.get('daysOfWeek', [])] for week_day in ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']: result[week_day] = week_day in week_days if week_days: