[FIX] calendar, google_calendar: prevent duplicated base event

Issue:
------
It is possible to create recurring events
that are in the same DST period.
Unfortunately, the basic event is sometimes duplicated

Cause:
------
The cause comes from the Daylight Saving Time (DST).

With the base event, we create a recurrence.
This recurrence will create all the events
of the recurrence.
To achieve this, with the basic event, we create all the ranges.
Then, we compare these ranges to remove those which already have
events.
Logically, we must reconcile the first range with the base event.
Sometimes the range of the base event and the first range
calculated to generate the occurrences do not match.

The consequence is the creation of a new event.
The cause of this problem is that we go back too far to find
the starting date of the period from which we will generate the ranges.

For example, in the case of a recurrence with a frequency of `MONTHLY`,
we will take the first date of the month.
And if we are in the month when the DST changes,
we will have the problem.

Solution:
---------
The solution is not to go back
if we encounter a difference in the DSTs
between the starting date of the base event
and the starting date for generating the ranges.

opw-3143680

closes odoo/odoo#119073

X-original-commit: 065dd4a2548ff0da6cf23a7ac62321ce99500d35
Signed-off-by: Arnaud Joset <arj@odoo.com>
Signed-off-by: Lefebvre Thomas (thle) <thle@odoo.com>
This commit is contained in:
Thomas Lefebvre (thle)
2023-04-19 17:31:49 +02:00
parent 2d0e286e6b
commit 9d5b4d218e
3 changed files with 96 additions and 0 deletions
@@ -381,6 +381,16 @@ class RecurrenceRule(models.Model):
start = dt + relativedelta(day=1)
else:
start = dt
# Comparaison of DST (to manage the case of going too far back in time).
# If we detect a change in the DST between the creation date of an event
# and the date used for the occurrence period, we use the creation date of the event.
# This is a hack to avoid duplication of events (for example on google calendar).
if isinstance(dt, datetime):
timezone = self._get_timezone()
dst_dt = timezone.localize(dt).dst()
dst_start = timezone.localize(start).dst()
if dst_dt != dst_start:
start = dt
return start
def _get_first_event(self, include_outliers=False):
@@ -6,6 +6,7 @@ from datetime import datetime, date
from dateutil.relativedelta import relativedelta
from odoo.tests.common import TransactionCase, Form
from freezegun import freeze_time
class TestRecurrentEvents(TransactionCase):
@@ -306,6 +307,51 @@ class TestCreateRecurrentEvents(TestRecurrentEvents):
# after channel is created, all other events should have the same channel
self.assertEqual(detached_events[0].videocall_channel_id.id, self.event.videocall_channel_id.id)
@freeze_time('2023-03-27')
def test_backward_pass_dst(self):
"""
When we apply the rule to compute the period of the recurrence,
we take an earlier date (in `_get_start_of_period` method).
However, it is possible that this earlier date has a different DST.
This causes time difference problems.
"""
# In Europe/Brussels: 26 March 2023 from winter to summer (from no DST to DST)
# We are in the case where we create a recurring event after the time change (there is the DST).
timezone = 'Europe/Brussels'
tz = pytz.timezone(timezone)
dt = tz.localize(datetime(2023, 3, 27, 9, 0, 00)).astimezone(pytz.utc).replace(tzinfo=None)
self.event.start = dt
self.event.stop = dt + relativedelta(hours=1)
# Check before apply the recurrence
self.assertEqual(self.event.start, datetime(2023, 3, 27, 7, 0, 00)) # Because 2023-03-27 in Europe/Brussels is UTC+2
self.event._apply_recurrence_values({
'rrule_type': 'monthly', # Because we will take the first day of the month (jump back)
'interval': 1,
'end_type': 'count',
'count': 2, # To have the base event and the unique recurrence event
'month_by': 'date',
'day': 27,
'event_tz': timezone,
})
# What we expect:
# - start date of base event: datetime(2023, 3, 27, 7, 0, 00)
# - start date of the unique recurrence event: datetime(2023, 4, 27, 7, 0, 00)
# With the FIX, we replace the following lines with
# `events = self.event.recurrence_id.calendar_event_ids`
recurrence = self.env['calendar.recurrence'].search([('base_event_id', '=', self.event.id)])
events = recurrence.calendar_event_ids
self.assertEqual(len(events), 2, "It should have 2 events in the recurrence")
self.assertIn(self.event, events)
self.assertEventDates(events, [
(datetime(2023, 3, 27, 7, 00), datetime(2023, 3, 27, 8, 00)),
(datetime(2023, 4, 27, 7, 00), datetime(2023, 4, 27, 8, 00)),
])
class TestUpdateRecurrentEvents(TestRecurrentEvents):
@classmethod
@@ -1411,3 +1411,43 @@ class TestSyncGoogle2Odoo(TestSyncGoogle):
event = self.env['calendar.event'].search([('google_id', '=', values.get('id'))])
self.assertEqual(2, len(event.partner_ids), "Two attendees and two partners should be associated to the event")
self.assertGoogleAPINotCalled()
@patch_api
def test_recurrence_range_start_date_in_other_dst_period(self):
"""
It is possible to create recurring events that are in the same DST period
but when calculating the start date for the range, it is possible to change the dst period.
This results in a duplication of the basic event.
"""
# DST change: 2023-03-26
frequency = "MONTHLY"
count = "1" # Just to go into the flow of the recurrence
recurrence_id = "9lxiofipomymx2yr1yt0hpep99"
google_value = [{
"summary": "Start date in DST period",
"id": recurrence_id,
"creator": {"email": "john.doe@example.com"},
"organizer": {"email": "john.doe@example.com"},
"created": "2023-03-27T11:45:07.000Z",
"start": {"dateTime": "2023-03-27T09:00:00+02:00", "timeZone": "Europe/Brussels"},
"end": {"dateTime": "2023-03-27T10:00:00+02:00", "timeZone": "Europe/Brussels"},
"recurrence": [f"RRULE:FREQ={frequency};COUNT={count}"],
"reminders": {"useDefault": True},
"updated": "2023-03-27T11:45:08.547Z",
}]
google_event = GoogleEvent(google_value)
self.env['calendar.recurrence']._sync_google2odoo(google_event)
# Get the time slot of the day
day_start = datetime.fromisoformat(google_event.start["dateTime"]).astimezone(pytz.utc).replace(tzinfo=None).replace(hour=0)
day_end = datetime.fromisoformat(google_event.end["dateTime"]).astimezone(pytz.utc).replace(tzinfo=None).replace(hour=23)
# Get created events
day_events = self.env["calendar.event"].search(
[
("name", "=", google_event.summary),
("start", ">=", day_start),
("stop", "<=", day_end)
]
)
self.assertGoogleAPINotCalled()
# Check for non-duplication
self.assertEqual(len(day_events), 1)