[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:
@@ -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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user