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)