From a2a1d9a876f9b03e7b63a761b12887968592dd0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20-=20Le=20Filament?= <30716308+remi-filament@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:55:00 +0100 Subject: [PATCH] [FIX] hr_holidays_leave_repeated: handle DST timezones --- hr_holidays_leave_repeated/models/hr_leave.py | 36 +++--- .../tests/test_holidays_leave_repeated.py | 104 ++++++++++++++++++ 2 files changed, 127 insertions(+), 13 deletions(-) diff --git a/hr_holidays_leave_repeated/models/hr_leave.py b/hr_holidays_leave_repeated/models/hr_leave.py index d523462ad..a9390ef4a 100644 --- a/hr_holidays_leave_repeated/models/hr_leave.py +++ b/hr_holidays_leave_repeated/models/hr_leave.py @@ -29,25 +29,38 @@ class HrLeave(models.Model): @api.model def _update_repeated_workday_dates(self, resource_calendar, from_dt, to_dt, days): - user = self.env.user - from_dt = fields.Datetime.context_timestamp(user, from_dt) - to_dt = fields.Datetime.context_timestamp(user, to_dt) + client_tz = timezone(self._context.get("tz") or self.env.user.tz or "UTC") + from_dt = from_dt.astimezone(client_tz) + to_dt = to_dt.astimezone(client_tz) work_hours = resource_calendar.get_work_hours_count( from_dt, to_dt, compute_leaves=False ) while work_hours: + previous_from_dt = from_dt + previous_to_dt = to_dt from_dt = from_dt + relativedelta(days=days) to_dt = to_dt + relativedelta(days=days) + # Handle case where Daylight Saving Time changes between 2 dates + # We want to keep the same hours in localized time + from_dt = from_dt.astimezone(client_tz) + to_dt = to_dt.astimezone(client_tz) + + from_dt_dst_diff = previous_from_dt.dst() - from_dt.dst() + to_dt_dst_diff = previous_to_dt.dst() - to_dt.dst() + + from_dt = from_dt + from_dt_dst_diff + from_dt = from_dt.astimezone(client_tz) + to_dt = to_dt + to_dt_dst_diff + to_dt = to_dt.astimezone(client_tz) + new_work_hours = resource_calendar.get_work_hours_count( from_dt, to_dt, compute_leaves=True ) if new_work_hours and work_hours <= new_work_hours: break - return from_dt.astimezone(utc).replace(tzinfo=None), to_dt.astimezone( - utc - ).replace(tzinfo=None) + return from_dt, to_dt @api.model def _get_repeated_vals_dict(self): @@ -95,16 +108,13 @@ def _update_repeated_leave_vals(self, leave, resource_calendar): from_dt, to_dt = self._update_repeated_workday_dates( resource_calendar, from_dt, to_dt, param_dict["days"] ) - client_tz = timezone(self._context.get("tz") or self.env.user.tz or "UTC") - request_date_from = utc.localize(from_dt).astimezone(client_tz) - request_date_to = utc.localize(to_dt).astimezone(client_tz) return { "employee_id": leave.employee_id.id, - "date_from": from_dt, - "date_to": to_dt, - "request_date_from": request_date_from, - "request_date_to": request_date_to, + "date_from": from_dt.astimezone(utc).replace(tzinfo=None), + "date_to": to_dt.astimezone(utc).replace(tzinfo=None), + "request_date_from": from_dt, + "request_date_to": to_dt, } @api.model diff --git a/hr_holidays_leave_repeated/tests/test_holidays_leave_repeated.py b/hr_holidays_leave_repeated/tests/test_holidays_leave_repeated.py index a7cd6ed31..8e8936bfe 100644 --- a/hr_holidays_leave_repeated/tests/test_holidays_leave_repeated.py +++ b/hr_holidays_leave_repeated/tests/test_holidays_leave_repeated.py @@ -320,3 +320,107 @@ def test_09_check_repeat_end_date(self): } ) self.assertEqual(len(leaves), 5) + + def test_10_check_dst_time(self): + self.calendar.tz = "Europe/Paris" + self.employee_1.tz = "Europe/Paris" + allocation = self.env["hr.leave.allocation"].create( + { + "name": "Initial Allocation", + "holiday_status_id": self.status_1.id, + "number_of_days": 20, + "employee_id": self.employee_1.id, + "date_from": date(2025, 1, 1), + } + ) + allocation.action_validate() + leaves_1 = self.env["hr.leave"].create( + { + "holiday_status_id": self.status_1.id, + "repeat_every": "week", + "repeat_mode": "times", + "repeat_limit": 5, + "request_date_from": datetime(2025, 3, 20, 8), + "request_date_to": datetime(2025, 3, 20, 18), + "employee_id": self.employee_1.id, + } + ) + leaves_2 = self.env["hr.leave"].create( + { + "holiday_status_id": self.status_1.id, + "repeat_every": "week", + "repeat_mode": "times", + "repeat_limit": 5, + "request_date_from": datetime(2025, 10, 16, 8), + "request_date_to": datetime(2025, 10, 16, 18), + "employee_id": self.employee_1.id, + } + ) + self.assertEqual( + leaves_1.mapped("date_from"), + [ + datetime(2025, 3, 20, 7), + datetime(2025, 3, 27, 7), + datetime(2025, 4, 3, 6), + datetime(2025, 4, 10, 6), + datetime(2025, 4, 17, 6), + ], + ) + self.assertEqual( + leaves_2.mapped("date_from"), + [ + datetime(2025, 10, 16, 6), + datetime(2025, 10, 23, 6), + datetime(2025, 10, 30, 7), + datetime(2025, 11, 6, 7), + datetime(2025, 11, 13, 7), + ], + ) + self.assertEqual( + list( + set( + leaves_1.mapped( + lambda leave: leave.date_from.astimezone( + timezone("Europe/Paris") + ).hour + ) + ) + ), + [8], + ) + self.assertEqual( + list( + set( + leaves_1.mapped( + lambda leave: leave.date_to.astimezone( + timezone("Europe/Paris") + ).hour + ) + ) + ), + [18], + ) + self.assertEqual( + list( + set( + leaves_2.mapped( + lambda leave: leave.date_from.astimezone( + timezone("Europe/Paris") + ).hour + ) + ) + ), + [8], + ) + self.assertEqual( + list( + set( + leaves_2.mapped( + lambda leave: leave.date_to.astimezone( + timezone("Europe/Paris") + ).hour + ) + ) + ), + [18], + )