From 05446fcc99c04276cfbffbc2daceaf0e2c8d1c06 Mon Sep 17 00:00:00 2001 From: fw-bot Date: Thu, 27 May 2021 15:49:38 +0000 Subject: [PATCH] Cherry pick of 3c8bdde81d508fdee301f13bcbc6e4eefc9fb02c failed stdout: stderr: 17:49:30.703303 git.c:344 trace: built-in: git cherry-pick 3c8bdde81d508fdee301f13bcbc6e4eefc9fb02c error: Cherry-picking is not possible because you have unmerged files. hint: Fix them up in the work tree, and then use 'git add/rm ' hint: as appropriate to mark resolution and make a commit. fatal: cherry-pick failed ---------- status: closes odoo/odoo#71390 X-original-commit: 7033f4aa7d1c65ad13dcd19e66378b101b56238b Signed-off-by: Arnaud Joset Signed-off-by: Martin Trigaux (mat) --- addons/calendar/models/calendar_event.py | 8 +- addons/calendar/models/calendar_recurrence.py | 2 +- addons/google_calendar/models/calendar.py | 41 +++++--- .../models/calendar_attendee.py | 27 ++++-- .../models/calendar_recurrence_rule.py | 9 +- addons/google_calendar/models/google_sync.py | 69 ++++++++++++-- .../tests/test_sync_google2odoo.py | 95 +++++++++++++++++++ .../tests/test_sync_odoo2google.py | 10 +- addons/google_calendar/utils/google_event.py | 4 +- 9 files changed, 223 insertions(+), 42 deletions(-) diff --git a/addons/calendar/models/calendar_event.py b/addons/calendar/models/calendar_event.py index baf32a001bb..c8ee1a71b04 100644 --- a/addons/calendar/models/calendar_event.py +++ b/addons/calendar/models/calendar_event.py @@ -460,8 +460,14 @@ class Meeting(models.Model): update_recurrence = recurrence_update_setting in ('all_events', 'future_events') and len(self) == 1 break_recurrence = values.get('recurrency') is False + update_alarms = False if 'partner_ids' in values: values['attendee_ids'] = self._attendees_values(values['partner_ids']) + update_alarms = True + + # master arj todo: factorize use of _get_time_fields() + if any([values.get(key) for key in self.env['calendar.event']._get_time_fields()]) or 'alarm_ids' in values: + update_alarms = True if (not recurrence_update_setting or recurrence_update_setting == 'self_only' and len(self) == 1) and 'follow_recurrence' not in values: if any({field: values.get(field) for field in self.env['calendar.event']._get_time_fields() if field in values}): @@ -489,7 +495,7 @@ class Meeting(models.Model): (detached_events & self).active = False (detached_events - self).with_context(archive_on_error=True).unlink() - if not self.env.context.get('dont_notify'): + if not self.env.context.get('dont_notify') and update_alarms: self._setup_alarms() current_attendees = self.filtered('active').attendee_ids diff --git a/addons/calendar/models/calendar_recurrence.py b/addons/calendar/models/calendar_recurrence.py index 3895886f067..2b163b6f9b8 100644 --- a/addons/calendar/models/calendar_recurrence.py +++ b/addons/calendar/models/calendar_recurrence.py @@ -312,7 +312,7 @@ class RecurrenceRule(models.Model): data = {} day_list = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] - if 'Z' in rule_str and not date_start.tzinfo: + if 'Z' in rule_str and date_start and not date_start.tzinfo: date_start = pytz.utc.localize(date_start) rule = rrule.rrulestr(rule_str, dtstart=date_start) diff --git a/addons/google_calendar/models/calendar.py b/addons/google_calendar/models/calendar.py index 1574659ee6c..24878c93be7 100644 --- a/addons/google_calendar/models/calendar.py +++ b/addons/google_calendar/models/calendar.py @@ -66,7 +66,9 @@ class Meeting(models.Model): return [ ('partner_ids.user_ids', 'in', self.env.user.id), ('stop', '>', lower_bound), - ('start', '<', upper_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 @@ -76,20 +78,27 @@ class Meeting(models.Model): alarm_commands = self._odoo_reminders_commands(google_event.reminders.get('overrides') or default_reminders) attendee_commands, partner_commands = self._odoo_attendee_commands(google_event) + related_event = self.search([('google_id', '=', google_event.id)], limit=1) + name = google_event.summary or related_event and related_event.name or _("(No title)") values = { - 'name': google_event.summary or _("(No title)"), + 'name': name, 'description': google_event.description, 'location': google_event.location, 'user_id': google_event.owner(self.env).id, 'privacy': google_event.visibility or self.default_get(['privacy'])['privacy'], 'attendee_ids': attendee_commands, - 'partner_ids': partner_commands, 'alarm_ids': alarm_commands, 'recurrency': google_event.is_recurrent() } - + if partner_commands: + # Add partner_commands only if set from Google. The write method on calendar_events will + # override attendee commands if the partner_ids command is set but empty. + values['partner_ids'] = partner_commands if not google_event.is_recurrence(): values['google_id'] = google_event.id + if google_event.is_recurrent() and not google_event.is_recurrence(): + # Propagate the follow_recurrence according to the google result + values['follow_recurrence'] = google_event.is_recurrence_follower() if google_event.start.get('dateTime'): # starting from python3.7, use the new [datetime, date].fromisoformat method start = parse(google_event.start.get('dateTime')).astimezone(pytz.utc).replace(tzinfo=None) @@ -190,13 +199,16 @@ class Meeting(models.Model): start = {'date': self.start_date.isoformat()} end = {'date': (self.stop_date + relativedelta(days=1)).isoformat()} else: - start = {'dateTime': pytz.utc.localize(self.start).isoformat()} - end = {'dateTime': pytz.utc.localize(self.stop).isoformat()} - + event_tz = self.event_tz or 'Etc/UTC' + start = {'dateTime': self.start.isoformat(), 'timeZone': event_tz} + end = {'dateTime': self.stop.isoformat(), 'timeZone': event_tz} reminders = [{ 'method': "email" if alarm.alarm_type == "email" else "popup", 'minutes': alarm.duration_minutes } for alarm in self.alarm_ids] + attendee_values = [{'email': attendee.partner_id.email_normalized, 'responseStatus': attendee.state} for attendee in self.attendee_ids if attendee.partner_id.email_normalized] + # We sort the attendees to avoid undeterministic test fails. It's not mandatory for Google. + attendee_values.sort(key=lambda k: k['email']) values = { 'id': self.google_id, 'start': start, @@ -206,7 +218,7 @@ class Meeting(models.Model): 'location': self.location or '', 'guestsCanModify': True, 'organizer': {'email': self.user_id.email, 'self': self.user_id == self.env.user}, - 'attendees': [{'email': attendee.email, 'responseStatus': attendee.state} for attendee in self.attendee_ids], + 'attendees': attendee_values, 'extendedProperties': { 'shared': { '%s_odoo_id' % self.env.cr.dbname: self.id, @@ -221,15 +233,16 @@ class Meeting(models.Model): values['visibility'] = self.privacy if not self.active: values['status'] = 'cancelled' - if self.user_id and self.user_id != self.env.user: + if self.user_id and self.user_id != self.env.user and not bool(self.user_id.sudo().google_calendar_token): + # The organizer is an Odoo user that do not sync his calendar values['extendedProperties']['shared']['%s_owner_id' % self.env.cr.dbname] = self.user_id.id elif not self.user_id: - # We don't store the real owner identity (mail) - # We can't store on the shared properties in that case without getting a 403 - # If several odoo users are attendees but the owner is not in odoo, the event will be duplicated on odoo database - # if we are not the owner, we should change the post values to avoid errors because we don't have enough rights + # We can't store on the shared properties in that case without getting a 403. It can happen when + # the owner is not an Odoo user: We don't store the real owner identity (mail) + # If we are not the owner, we should change the post values to avoid errors because we don't have + # write permissions # See https://developers.google.com/calendar/concepts/sharing - keep_keys = ['id', 'attendees', 'start', 'end', 'reminders'] + keep_keys = ['id', 'summary', 'attendees', 'start', 'end', 'reminders'] values = {key: val for key, val in values.items() if key in keep_keys} # values['extendedProperties']['private] should be used if the owner is not an odoo user values['extendedProperties'] = { diff --git a/addons/google_calendar/models/calendar_attendee.py b/addons/google_calendar/models/calendar_attendee.py index d9b0aa86da3..a32e0f69b50 100644 --- a/addons/google_calendar/models/calendar_attendee.py +++ b/addons/google_calendar/models/calendar_attendee.py @@ -19,10 +19,25 @@ class Attendee(models.Model): if not token: super()._send_mail_to_attendees(mail_template, force_send) - def write(self, vals): - res = super().write(vals) - if vals.get('state'): - # When the state is changed, the corresponding event must be sync with google - google_service = GoogleCalendarService(self.env['google.service']) - self.event_id.filtered('google_id')._sync_odoo2google(google_service) + def do_tentative(self): + # Synchronize event after state change + res = super().do_tentative() + self._sync_event() return res + + def do_accept(self): + # Synchronize event after state change + res = super().do_accept() + self._sync_event() + return res + + + def do_decline(self): + # Synchronize event after state change + res = super().do_decline() + self._sync_event() + return res + + def _sync_event(self): + google_service = GoogleCalendarService(self.env['google.service']) + self.event_id.filtered(lambda e: e.google_id)._sync_odoo2google(google_service) diff --git a/addons/google_calendar/models/calendar_recurrence_rule.py b/addons/google_calendar/models/calendar_recurrence_rule.py index 1924268f5ce..574698494d2 100644 --- a/addons/google_calendar/models/calendar_recurrence_rule.py +++ b/addons/google_calendar/models/calendar_recurrence_rule.py @@ -92,11 +92,11 @@ class RecurrenceRule(models.Model): # We can't call _cancel because events without user_id would not be deleted (self.calendar_event_ids - base_event_id).google_id = False (self.calendar_event_ids - base_event_id).unlink() - base_event_id.write(dict(new_event_values, google_id=False, need_sync=False)) + base_event_id.with_context(dont_notify=True).write(dict(new_event_values, google_id=False, need_sync=False)) if self.rrule == current_rrule: # if the rrule has changed, it will be recalculated below # There is no detached event now - self._apply_recurrence() + self.with_context(dont_notify=True)._apply_recurrence() else: time_fields = ( self.env["calendar.event"]._get_time_fields() @@ -160,10 +160,9 @@ class RecurrenceRule(models.Model): return {} values = event._google_values() values['id'] = self.google_id - if not self._is_allday(): - values['start']['timeZone'] = self.event_tz - values['end']['timeZone'] = self.event_tz + values['start']['timeZone'] = self.event_tz or 'Etc/UTC' + values['end']['timeZone'] = self.event_tz or 'Etc/UTC' # DTSTART is not allowed by Google Calendar API. # Event start and end times are specified in the start and end fields. diff --git a/addons/google_calendar/models/google_sync.py b/addons/google_calendar/models/google_sync.py index 5d96f055fc5..0e67011268c 100644 --- a/addons/google_calendar/models/google_sync.py +++ b/addons/google_calendar/models/google_sync.py @@ -4,7 +4,7 @@ import logging from contextlib import contextmanager from functools import wraps -import requests +from requests import HTTPError import pytz from dateutil.parser import parse @@ -166,6 +166,49 @@ class GoogleSync(models.AbstractModel): return synced_records + def _google_error_handling(self, http_error): + # We only handle the most problematic errors of sync events. + if http_error.response.status_code in (403, 400): + response = http_error.response.json() + if self._name == 'calendar.event': + start = self.start.strftime('%Y-%m-%d at %H:%M') + event_ids = self.id + name = self.name + error_log = "Error while syncing event: " + event = self + else: + # calendar recurrence is triggering the error + start = self.base_event_id.start.strftime('%Y-%m-%d at %H:%M') + event_ids = _("%(id)s and %(length)s following", id=self.base_event_id.id, length=len(self.calendar_event_ids.ids)) + name = self.base_event_id.name + # prevent to sync other events + self.calendar_event_ids.need_sync = False + error_log = "Error while syncing recurrence: " + event = self.base_event_id + + # We don't have right access on the event or the request paramaters were bad. + # https://developers.google.com/calendar/v3/errors#403_forbidden_for_non-organizer + if http_error.response.status_code == 403 and "forbiddenForNonOrganizer" in http_error.response.text: + reason = _("you don't seem to have permission to modify this event on Google Calendar") + else: + reason = _("Google gave the following explanation: %s", response['error'].get('message')) + + error_log += "The event (%(id)s - %(name)s at %(start)s) could not be synced. It will not be synced while " \ + "it is not updated. Reason: %(reason)s" % {'id': event_ids, 'start': start, 'name': name, + 'reason': reason} + _logger.error(error_log) + + body = _( + "The following event could not be synced with Google Calendar.
" + "It will not be synced as long at it is not updated.
" + "%(reason)s", reason=reason) + + event.message_post( + body=body, + message_type='comment', + subtype_xmlid='mail.mt_note', + ) + @after_commit def _google_delete(self, google_service: GoogleCalendarService, google_id, timeout=TIMEOUT): with google_calendar_token(self.env.user.sudo()) as token: @@ -173,13 +216,17 @@ class GoogleSync(models.AbstractModel): google_service.delete(google_id, token=token, timeout=timeout) # When the record has been deleted on our side, we need to delete it on google but we don't want # to raise an error because the record don't exists anymore. - self.exists().need_sync = False + self.exists().with_context(dont_notify=True).need_sync = False @after_commit def _google_patch(self, google_service: GoogleCalendarService, google_id, values, timeout=TIMEOUT): with google_calendar_token(self.env.user.sudo()) as token: if token: - google_service.patch(google_id, values, token=token, timeout=timeout) + try: + google_service.patch(google_id, values, token=token, timeout=timeout) + except HTTPError as e: + if e.response.status_code in (400, 403): + self._google_error_handling(e) self.need_sync = False @after_commit @@ -188,11 +235,17 @@ class GoogleSync(models.AbstractModel): return with google_calendar_token(self.env.user.sudo()) as token: if token: - google_id = google_service.insert(values, token=token, timeout=timeout) - self.write({ - 'google_id': google_id, - 'need_sync': False, - }) + try: + google_id = google_service.insert(values, token=token, timeout=timeout) + # Everything went smoothly + self.with_context(dont_notify=True).write({ + 'google_id': google_id, + 'need_sync': False, + }) + except HTTPError as e: + if e.response.status_code in (400, 403): + self._google_error_handling(e) + self.need_sync = False def _get_records_to_sync(self, full_sync=False): """Return records that should be synced from Odoo to Google diff --git a/addons/google_calendar/tests/test_sync_google2odoo.py b/addons/google_calendar/tests/test_sync_google2odoo.py index 98bb12dd566..6cdca96f2ca 100644 --- a/addons/google_calendar/tests/test_sync_google2odoo.py +++ b/addons/google_calendar/tests/test_sync_google2odoo.py @@ -157,10 +157,30 @@ class TestSyncGoogle2Odoo(TestSyncGoogle): user_attendee = event.attendee_ids self.assertTrue(user_attendee) self.assertEqual(user_attendee.state, 'declined') + self.assertGoogleAPINotCalled() + + @patch_api + def test_private_extended_properties(self): + google_id = 'oj44nep1ldf8a3ll02uip0c9aa' + event = self.env['calendar.event'].create({ + 'name': 'coucou', + 'start': date(2020, 1, 6), + 'stop': date(2020, 1, 6), + 'allday': True, + 'google_id': google_id, + 'need_sync': False, + 'user_id': False, # Not the current user + 'partner_ids': [(6, 0, self.env.user.partner_id.ids)] # current user is attendee + }) + user_attendee = event.attendee_ids + self.assertTrue(user_attendee) + self.assertEqual(user_attendee.state, 'accepted') + user_attendee.do_decline() # To avoid 403 errors, we send a limited dictionnary when we don't have write access. # guestsCanModify property is not properly handled yet self.assertGoogleEventPatched(event.google_id, { 'id': event.google_id, + 'summary': 'coucou', 'start': {'date': str(event.start_date)}, 'end': {'date': str(event.stop_date + relativedelta(days=1))}, 'attendees': [{'email': 'odoobot@example.com', 'responseStatus': 'declined'}], @@ -746,3 +766,78 @@ class TestSyncGoogle2Odoo(TestSyncGoogle): new_triggers = triggers_after - triggers_before self.assertFalse(new_triggers, "The event should not be created with triggers.") self.assertGoogleAPINotCalled() + + @patch_api + def test_attendee_state(self): + user = new_test_user(self.env, login='calendar-user') + google_id = 'oj44nep1ldf8a3ll02uip0c9aa' + event = self.env['calendar.event'].with_user(user).create({ + 'name': 'Event with me', + 'start': date(2020, 1, 6), + 'stop': date(2020, 1, 6), + 'google_id': google_id, + 'user_id': False, # user is not owner + 'need_sync': False, + 'partner_ids': [(6, 0, user.partner_id.ids)], # but user is attendee + }) + self.assertEqual(event.attendee_ids.state, 'accepted') + # The event is declined from Google + values = { + 'id': google_id, + 'description': 'Changed my mind', + "updated": self.now, + 'organizer': {'email': 'odoocalendarref@gmail.com', 'self': True}, + 'summary': """I don't want to be with me anymore""", + 'visibility': 'public', + 'attendees': [{ + 'displayName': 'calendar-user (base.group_user)', + 'email': 'c.c@example.com', + 'responseStatus': 'declined' + }, ], + 'reminders': {'useDefault': True}, + 'start': { + 'dateTime': '2020-01-13T16:55:00+01:00', + 'timeZone': 'Europe/Brussels' + }, + 'end': { + 'dateTime': '2020-01-13T19:55:00+01:00', + 'timeZone': 'Europe/Brussels' + }, + } + self.env['calendar.event']._sync_google2odoo(GoogleEvent([values])) + self.assertEqual(event.attendee_ids.state, 'declined') + self.assertGoogleAPINotCalled() + + @patch_api + def test_attendees_same_event_both_share(self): + google_id = 'oj44nep1ldf8a3ll02uip0c9aa' + other_user = new_test_user(self.env, login='calendar-user') + event = self.env['calendar.event'].create({ + 'name': 'coucou', + 'start': date(2020, 1, 6), + 'stop': date(2020, 1, 6), + 'allday': True, + 'google_id': google_id, + 'need_sync': False, + 'user_id': other_user.id, # Not the current user + 'partner_ids': [(6, 0, [self.env.user.partner_id.id, other_user.partner_id.id], )] # current user is attendee + }) + event.write({'start': date(2020, 1, 7), 'stop': date(2020, 1, 8)}) + # To avoid 403 errors, we send a limited dictionnary when we don't have write access. + # guestsCanModify property is not properly handled yet + self.assertGoogleEventPatched(event.google_id, { + 'id': event.google_id, + 'start': {'date': str(event.start_date)}, + 'end': {'date': str(event.stop_date + relativedelta(days=1))}, + 'summary': 'coucou', + 'description': '', + 'location': '', + 'guestsCanModify': True, + 'organizer': {'email': 'c.c@example.com', 'self': False}, + 'attendees': [{'email': 'c.c@example.com', 'responseStatus': 'needsAction'}, + {'email': 'odoobot@example.com', 'responseStatus': 'accepted'},], + 'extendedProperties': {'shared': {'%s_odoo_id' % self.env.cr.dbname: event.id, + '%s_owner_id' % self.env.cr.dbname: other_user.id}}, + 'reminders': {'overrides': [], 'useDefault': False}, + 'visibility': 'public', + }, timeout=3) diff --git a/addons/google_calendar/tests/test_sync_odoo2google.py b/addons/google_calendar/tests/test_sync_odoo2google.py index f9a8c64e8f8..58cad26ab40 100644 --- a/addons/google_calendar/tests/test_sync_odoo2google.py +++ b/addons/google_calendar/tests/test_sync_odoo2google.py @@ -40,8 +40,8 @@ class TestSyncOdoo2Google(TestSyncGoogle): event._sync_odoo2google(self.google_service) self.assertGoogleEventInserted({ 'id': False, - 'start': {'dateTime': '2020-01-15T08:00:00+00:00'}, - 'end': {'dateTime': '2020-01-15T18:00:00+00:00'}, + 'start': {'dateTime': '2020-01-15T08:00:00', 'timeZone': 'Etc/UTC'}, + 'end': {'dateTime': '2020-01-15T18:00:00', 'timeZone': 'Etc/UTC'}, 'summary': 'Event', 'description': '', 'location': '', @@ -269,8 +269,8 @@ class TestSyncOdoo2Google(TestSyncGoogle): user.with_user(user).restart_google_synchronization() self.assertGoogleEventPatched(event.google_id, { 'id': event.google_id, - 'start': {'dateTime': '2020-01-15T08:00:00+00:00'}, - 'end': {'dateTime': '2020-01-15T18:00:00+00:00'}, + 'start': {'dateTime': '2020-01-15T08:00:00', 'timeZone': 'Etc/UTC'}, + 'end': {'dateTime': '2020-01-15T18:00:00', 'timeZone': 'Etc/UTC'}, 'summary': 'Event', 'description': '', 'location': '', @@ -432,7 +432,7 @@ class TestSyncOdoo2Google(TestSyncGoogle): self.assertEqual(event.attendee_ids.state, 'needsAction', "The attendee state should be 'needsAction") - event.attendee_ids.write({'state': 'declined'}) + event.attendee_ids.do_decline() self.assertGoogleEventPatched(event.google_id, { 'id': event.google_id, 'start': {'date': str(event.start_date)}, diff --git a/addons/google_calendar/utils/google_event.py b/addons/google_calendar/utils/google_event.py index 3655dd3e905..47e93674929 100644 --- a/addons/google_calendar/utils/google_event.py +++ b/addons/google_calendar/utils/google_event.py @@ -174,8 +174,8 @@ class GoogleEvent(abc.Set): def is_cancelled(self): return self.status == 'cancelled' - def is_recurrence_outlier(self): - return bool(self.originalStartTime) + def is_recurrence_follower(self): + return bool(not self.originalStartTime or self.originalStartTime == self.start) def cancelled(self): return self.filter(lambda e: e.status == 'cancelled')