From 61ba5037dc6767882671d369b3a1c5418344fc6e Mon Sep 17 00:00:00 2001 From: "Thomas Lefebvre (thle)" Date: Mon, 8 May 2023 12:16:37 +0000 Subject: [PATCH] [FIX] google_calendar: edit schedule of recurring event Steps to reproduce: ------------------- We google calendar, create a recurring event with three occurrences. We obtain 3 events in chronological order: - A - B - C Change the title of the event B and apply the changes to the following events. We get: - A: First title - B: Second title - C: Second title Modify the schedule of event C and apply the changes only to this event. We get: - A: First title + First schedule - B: Second title + First schedule - C: Second title + Second schedule Synchronize Google calendar with Odoo. Issue: ------ Event C is "duplicated". Event C will exist with the first schedule and the second schedule. Cause: ------ A recurrent event with `google_id` is equal to `ID_RANGE_TIMESTAMP` can be rescheduled. If this is the case, its `google_id` will be equal to `ID_TIMESTAMP`. We do not manage the deletion of the event that belongs to the old `google id`. Solution: --------- When we detect a recurring event with a modified schedule, we have to delete the event from the old `google_id` (which is the event with the old schedule). For this, it is necessary to find the old `google_id` based on the elements contained in the `id` and the `recurringEventId` of the event. opw-3143680 closes odoo/odoo#121825 X-original-commit: 3f145af00307383e2d0a0891d05b8db59b13662a Signed-off-by: Arnaud Joset (arj) Signed-off-by: Lefebvre Thomas (thle) --- addons/google_calendar/models/google_sync.py | 12 +++ .../tests/test_sync_google2odoo.py | 98 +++++++++++++++++++ addons/google_calendar/utils/google_event.py | 26 +++++ 3 files changed, 136 insertions(+) diff --git a/addons/google_calendar/models/google_sync.py b/addons/google_calendar/models/google_sync.py index c8fc05c1db8..11e2adc8e50 100644 --- a/addons/google_calendar/models/google_sync.py +++ b/addons/google_calendar/models/google_sync.py @@ -158,6 +158,18 @@ class GoogleSync(models.AbstractModel): new_odoo = self.with_context(dont_notify=True)._create_from_google(new, odoo_values) cancelled = existing.cancelled() cancelled_odoo = self.browse(cancelled.odoo_ids(self.env)) + + # Check if it is a recurring event that has been rescheduled. + # We have to check if an event already exists in Odoo. + # Explanation: + # A recurrent event with `google_id` is equal to ID_RANGE_TIMESTAMP can be rescheduled. + # The new `google_id` will be equal to ID_TIMESTAMP. + # We have to delete the event created under the old `google_id`. + rescheduled_events = new.filter(lambda gevent: not gevent.is_recurrence_follower()) + if rescheduled_events: + google_ids_to_remove = [event.full_recurring_event_id() for event in rescheduled_events] + cancelled_odoo += self.env['calendar.event'].search([('google_id', 'in', google_ids_to_remove)]) + cancelled_odoo._cancel() synced_records = new_odoo + cancelled_odoo for gevent in existing - cancelled: diff --git a/addons/google_calendar/tests/test_sync_google2odoo.py b/addons/google_calendar/tests/test_sync_google2odoo.py index 92cd7312283..68df932695f 100644 --- a/addons/google_calendar/tests/test_sync_google2odoo.py +++ b/addons/google_calendar/tests/test_sync_google2odoo.py @@ -1451,3 +1451,101 @@ class TestSyncGoogle2Odoo(TestSyncGoogle): self.assertGoogleAPINotCalled() # Check for non-duplication self.assertEqual(len(day_events), 1) + + @patch_api + def test_recurrence_edit_specific_event(self): + google_values = [ + { + 'kind': 'calendar#event', + 'etag': '"3367067678542000"', + 'id': '59orfkiunbn2vlp6c2tndq6ui0', + 'status': 'confirmed', + 'created': '2023-05-08T08:16:54.000Z', + 'updated': '2023-05-08T08:17:19.271Z', + 'summary': 'First title', + 'creator': {'email': 'john.doe@example.com', 'self': True}, + 'organizer': {'email': 'john.doe@example.com', 'self': True}, + 'start': {'dateTime': '2023-05-12T09:00:00+02:00', 'timeZone': 'Europe/Brussels'}, + 'end': {'dateTime': '2023-05-12T10:00:00+02:00', 'timeZone': 'Europe/Brussels'}, + 'recurrence': ['RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20230518T215959Z;BYDAY=FR'], + 'iCalUID': '59orfkiunbn2vlp6c2tndq6ui0@google.com', + 'reminders': {'useDefault': True}, + }, + { + 'kind': 'calendar#event', + 'etag': '"3367067678542000"', + 'id': '59orfkiunbn2vlp6c2tndq6ui0_R20230519T070000', + 'status': 'confirmed', + 'created': '2023-05-08T08:16:54.000Z', + 'updated': '2023-05-08T08:17:19.271Z', + 'summary': 'Second title', + 'creator': {'email': 'john.doe@example.com', 'self': True}, + 'organizer': {'email': 'john.doe@example.com', 'self': True}, + 'start': {'dateTime': '2023-05-19T09:00:00+02:00', 'timeZone': 'Europe/Brussels'}, + 'end': {'dateTime': '2023-05-19T10:00:00+02:00', 'timeZone': 'Europe/Brussels'}, + 'recurrence': ['RRULE:FREQ=WEEKLY;WKST=SU;COUNT=2;BYDAY=FR'], + 'iCalUID': '59orfkiunbn2vlp6c2tndq6ui0_R20230519T070000@google.com', + 'reminders': {'useDefault': True}, + }, + { + 'kind': 'calendar#event', + 'etag': '"3367067704194000"', + 'id': '59orfkiunbn2vlp6c2tndq6ui0_20230526T070000Z', + 'status': 'confirmed', + 'created': '2023-05-08T08:16:54.000Z', + 'updated': '2023-05-08T08:17:32.097Z', + 'summary': 'Second title', + 'creator': {'email': 'john.doe@example.com', 'self': True}, + 'organizer': {'email': 'john.doe@example.com', 'self': True}, + 'start': {'dateTime': '2023-05-26T08:00:00+02:00', 'timeZone': 'Europe/Brussels'}, + 'end': {'dateTime': '2023-05-26T09:00:00+02:00', 'timeZone': 'Europe/Brussels'}, + 'recurringEventId': '59orfkiunbn2vlp6c2tndq6ui0_R20230519T070000', + 'originalStartTime': {'dateTime': '2023-05-26T09:00:00+02:00', 'timeZone': 'Europe/Brussels'}, + 'reminders': {'useDefault': True}, + } + ] + google_events = GoogleEvent(google_values) + + recurrent_events = google_events.filter(lambda e: e.is_recurrence()) + specific_event = google_events - recurrent_events + # recurrence_event: 59orfkiunbn2vlp6c2tndq6ui0 and 59orfkiunbn2vlp6c2tndq6ui0_R20230519T070000 + # specific_event: 59orfkiunbn2vlp6c2tndq6ui0_20230526T070000Z + + # Range to check + day_start = datetime.fromisoformat(specific_event.start["dateTime"]).astimezone(pytz.utc).replace(tzinfo=None).replace(hour=0) + day_end = datetime.fromisoformat(specific_event.end["dateTime"]).astimezone(pytz.utc).replace(tzinfo=None).replace(hour=23) + + # Synchronize recurrent events + self.env['calendar.recurrence']._sync_google2odoo(recurrent_events) + events = self.env["calendar.event"].search( + [ + ("name", "=", specific_event.summary), + ("start", ">=", day_start), + ("stop", "<=", day_end,) + ] + ) + self.assertEqual(len(events), 1) + + # Events: + # 'First title' --> '59orfkiunbn2vlp6c2tndq6ui0_20230512T070000Z' + # 'Second title' --> '59orfkiunbn2vlp6c2tndq6ui0_R20230519T070000_20230519T070000Z' + # 'Second title' --> '59orfkiunbn2vlp6c2tndq6ui0_R20230519T070000_20230526T070000Z' + + # We want to apply change on '59orfkiunbn2vlp6c2tndq6ui0_R20230519T070000_20230526T070000Z' + # with values from '59orfkiunbn2vlp6c2tndq6ui0_20230526T070000Z' + + # To match the google ids, we create a new event and delete the old one to avoid duplication + + # Synchronize specific event + self.env['calendar.event']._sync_google2odoo(specific_event) + events = self.env["calendar.event"].search( + [ + ("name", "=", specific_event.summary), + ("start", ">=", day_start), + ("stop", "<=", day_end,) + ] + ) + self.assertEqual(len(events), 1) + + # Not call API + self.assertGoogleAPINotCalled() diff --git a/addons/google_calendar/utils/google_event.py b/addons/google_calendar/utils/google_event.py index 1426c8c1f46..98eb6575853 100644 --- a/addons/google_calendar/utils/google_event.py +++ b/addons/google_calendar/utils/google_event.py @@ -5,6 +5,7 @@ from odoo.tools import email_normalize import logging from typing import Iterator, Mapping from collections import abc +import re _logger = logging.getLogger(__name__) @@ -170,6 +171,31 @@ class GoogleEvent(abc.Set): def is_recurrence_follower(self): return bool(not self.originalStartTime or self.originalStartTime == self.start) + def full_recurring_event_id(self): + """ + Give the complete identifier with elements + in `id` and `recurringEventId`. + :return: concatenation of the id created by the recurrence + and the id created by the modification of a specific event + :rtype: string if recurrent event and correct ids, `None` otherwise + """ + # Regex expressions to match elements (according to the google support [not documented]): + # - ID: [a-zA-Z0-9]+ + # - RANGE: R[0-9]+T[0-9]+ + # - TIMESTAMP: [0-9]+T[0-9]+Z + # With: + # - id: 'ID_TIMESTAMP' + # - recurringEventID: 'ID_RANGE' + # Find: 'ID_RANGE_TIMESTAMP' + if not self.is_recurrent(): + return None + # Check if ids are the same + if re.match(r'(\w+_)', self.recurringEventId).group(1) != re.match(r'(\w+_)', self.id).group(1): + return None + ID_RANGE = re.search(r'\w+_R\d+T\d+', self.recurringEventId).group() + TIMESTAMP = re.search(r'\d+T\d+Z', self.id).group() + return f"{ID_RANGE}_{TIMESTAMP}" + def cancelled(self): return self.filter(lambda e: e.status == 'cancelled')