[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) <arj@odoo.com>
Signed-off-by: Lefebvre Thomas (thle) <thle@odoo.com>
This commit is contained in:
Thomas Lefebvre (thle)
2023-05-22 13:53:56 +02:00
parent 35e2ea33c7
commit 61ba5037dc
3 changed files with 136 additions and 0 deletions
@@ -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:
@@ -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()
@@ -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')