From 9d5b4d218eaede5f585e87cb8d0099bb8cb7f4d2 Mon Sep 17 00:00:00 2001 From: "Thomas Lefebvre (thle)" Date: Fri, 31 Mar 2023 10:01:14 +0000 Subject: [PATCH] [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 Signed-off-by: Lefebvre Thomas (thle) --- addons/calendar/models/calendar_recurrence.py | 10 ++++ .../calendar/tests/test_event_recurrence.py | 46 +++++++++++++++++++ .../tests/test_sync_google2odoo.py | 40 ++++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/addons/calendar/models/calendar_recurrence.py b/addons/calendar/models/calendar_recurrence.py index afaba5356c4..5887a731ee9 100644 --- a/addons/calendar/models/calendar_recurrence.py +++ b/addons/calendar/models/calendar_recurrence.py @@ -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): diff --git a/addons/calendar/tests/test_event_recurrence.py b/addons/calendar/tests/test_event_recurrence.py index 8833ff661ea..16dfa46f547 100644 --- a/addons/calendar/tests/test_event_recurrence.py +++ b/addons/calendar/tests/test_event_recurrence.py @@ -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 diff --git a/addons/google_calendar/tests/test_sync_google2odoo.py b/addons/google_calendar/tests/test_sync_google2odoo.py index 20a61a3a99f..286f8dc535f 100644 --- a/addons/google_calendar/tests/test_sync_google2odoo.py +++ b/addons/google_calendar/tests/test_sync_google2odoo.py @@ -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)