From 75374ad17d10c646aaa2fbb5ffe6ba260a2cfd0e Mon Sep 17 00:00:00 2001 From: "Thomas Lefebvre (thle)" Date: Tue, 14 Nov 2023 08:27:32 +0000 Subject: [PATCH] [FIX] calendar: trigger activity according to timezone Steps to reproduce: ------------------- - be in UTC -5 (America/Lima for example) on the system (and so browser) and the user profile; - create an event from 4pm to 5pm the next day; - be at 2pm today; Issue: ------ We have a notification in activities for this event today, which is one day too soon. Cause: ------ To find the events that need to be notified to the user, we use a domain which, in this case, can be expressed as follows: ``` ( the `start` of the event must be after "now" (the event has not yet passed) OR the event `stop` must be after now (the event is in progress) ) AND the `start` of the event must be less than the end of the day (the event must be today) ``` The end of the day must be determined, but taking into account the user's timezone. As the domain has to work with UTC values (because the `start` and `stop` values of the event are in UTC in DB), in order to determine the end of the current day, we first need to determine which day the user is in UTC. Logic before this commit (using the example): ```py start_dt = datetime.datetime.utcnow() # 19:00 (because 14h in America/Lima -5) start_date = timezone(tz).localize(start_dt).astimezone(UTC).date() # 00:00 next day end_dt = datetime.datetime.combine(start_date, datetime.time.max) # 23:59 next day end_dt = timezone(tz).localize(end_dt).astimezone(UTC) # 4:59 second next day ``` The result (for an America/Lima timezone) is that if the event has its `start` in UTC before 4:59 two days later, the event will be notified. Solution: --------- Correct the values used to construct the domain. Logic after this commit (using the example): ```py start_dt_utc = now_utc.replace(tzinfo=UTC) # 19:00 (because 14h in America/Lima -5) start_dt = start_dt_utc.astimezone(user_tz) # 14:00 (because 14h in America/Lima -5) stop_dt = datetime.datetime.combine(start_dt.date(), datetime.time.max).replace(tzinfo=user_tz).astimezone(UTC) # 4:59 next day ``` Because 23:59 today using user timezone and then converted to UTC gives 4:59 next day. In fact, the day of a user in the America/Lima (UTC -5) timezone ends in UTC at 4:59 the next day. If this same user creates an event that starts at midnight (and therefore tomorrow), the activity must not be displayed before midnight and therefore 5:00 the next day in UTC. opw-3523558 closes odoo/odoo#144064 X-original-commit: 5a997d34f53d4b00e230b4a9d3131a015b190548 Signed-off-by: Arnaud Joset (arj) Signed-off-by: Thomas Lefebvre (thle) --- addons/calendar/models/res_users.py | 54 ++++++++++++++----- .../tests/test_event_notifications.py | 52 ++++++++++++++++++ 2 files changed, 94 insertions(+), 12 deletions(-) diff --git a/addons/calendar/models/res_users.py b/addons/calendar/models/res_users.py index 5c70a4eaad6..6cd185de0e0 100644 --- a/addons/calendar/models/res_users.py +++ b/addons/calendar/models/res_users.py @@ -11,26 +11,56 @@ class Users(models.Model): _inherit = 'res.users' def _systray_get_calendar_event_domain(self): + # Determine the domain for which the users should be notified. This method sends notification to + # events occurring between now and the end of the day. Note that "now" needs to be computed in the + # user TZ and converted into UTC to compare with the records values and "the end of the day" needs + # also conversion. Otherwise TZ diverting a lot from UTC would send notification for events occurring + # tomorrow. + # The user is notified if the start is occurring between now and the end of the day + # if the event is not finished. + # | | + # |===========|===> DAY A (`start_dt`): now in the user TZ + # | | + # | | <--- `start_dt_utc`: now is on the right if the user lives + # | | in West Longitude (America for example) + # | | + # | ------- | <--- `start`: the start of the event (in UTC) + # | | event | | + # | ------- | <--- `stop`: the stop of the event (in UTC) + # | | + # | | + # | | <--- `stop_dt_utc` = `stop_dt` if user lives in an area of East longitude (positive shift compared to UTC, Belgium for example) + # | | + # | | + # |-----------| <--- `stop_dt` = end of the day for DAY A from user point of view (23:59 in this TZ) + # |===========|===> DAY B + # | | + # | | <--- `stop_dt_utc` = `stop_dt` if user lives in an area of West longitude (positive shift compared to UTC, America for example) + # | | + now_utc = datetime.datetime.utcnow() + start_dt_utc = start_dt = now_utc.replace(tzinfo=UTC) + stop_dt_utc = datetime.datetime.combine(now_utc.date(), datetime.time.max).replace(tzinfo=UTC) + + tz = self.env.user.tz + if tz: + user_tz = timezone(tz) + start_dt = start_dt_utc.astimezone(user_tz) + stop_dt = datetime.datetime.combine(start_dt.date(), datetime.time.max).replace(tzinfo=user_tz) + stop_dt_utc = stop_dt.astimezone(UTC) + + start_date = start_dt.date() + current_user_non_declined_attendee_ids = self.env['calendar.attendee']._search([ ('partner_id', '=', self.env.user.partner_id.id), ('state', '!=', 'declined'), ]) - tz = self.env.user.tz - start_dt = datetime.datetime.utcnow() - if tz: - start_date = timezone(tz).localize(start_dt).astimezone(UTC).date() - else: - start_date = datetime.date.today() - end_dt = datetime.datetime.combine(start_date, datetime.time.max) - if tz: - end_dt = timezone(tz).localize(end_dt).astimezone(UTC) return ['&', '|', '&', '|', - ['start', '>=', fields.Datetime.to_string(start_dt)], - ['stop', '>=', fields.Datetime.to_string(start_dt)], - ['start', '<=', fields.Datetime.to_string(end_dt)], + ['start', '>=', fields.Datetime.to_string(start_dt_utc)], + ['stop', '>=', fields.Datetime.to_string(start_dt_utc)], + ['start', '<=', fields.Datetime.to_string(stop_dt_utc)], '&', ['allday', '=', True], ['start_date', '=', fields.Date.to_string(start_date)], diff --git a/addons/calendar/tests/test_event_notifications.py b/addons/calendar/tests/test_event_notifications.py index 954090e90fe..e696226203c 100644 --- a/addons/calendar/tests/test_event_notifications.py +++ b/addons/calendar/tests/test_event_notifications.py @@ -267,3 +267,55 @@ class TestEventNotifications(TransactionCase, MailCase, CronMixinCase): }).with_context(mail_notrack=True) self.env.flush_all() self.assertEqual(len(capt.records), 1) + + def test_notification_event_timezone(self): + """ + Check the domain that decides when calendar events should be notified to the user. + """ + def search_event(): + return self.env['calendar.event'].search(self.env['res.users']._systray_get_calendar_event_domain()) + + self.env.user.tz = 'Europe/Brussels' # UTC +1 15th November 2023 + event = self.env['calendar.event'].create({ + 'name': "Meeting", + 'start': datetime(2023, 11, 15, 18, 0), # 19:00 + 'stop': datetime(2023, 11, 15, 19, 0), # 20:00 + }).with_context(mail_notrack=True) + with freeze_time('2023-11-15 17:30:00'): # 18:30 before event + self.assertEqual(search_event(), event) + with freeze_time('2023-11-15 18:00:00'): # 19:00 during event + self.assertEqual(search_event(), event) + with freeze_time('2023-11-15 18:30:00'): # 19:30 during event + self.assertEqual(search_event(), event) + with freeze_time('2023-11-15 19:00:00'): # 20:00 during event + self.assertEqual(search_event(), event) + with freeze_time('2023-11-15 19:30:00'): # 20:30 after event + self.assertEqual(len(search_event()), 0) + event.unlink() + + self.env.user.tz = 'America/Lima' # UTC -5 15th November 2023 + event = self.env['calendar.event'].create({ + 'name': "Meeting", + 'start': datetime(2023, 11, 16, 0, 0), # 19:00 15th November + 'stop': datetime(2023, 11, 16, 1, 0), # 20:00 15th November + }).with_context(mail_notrack=True) + with freeze_time('2023-11-15 23:30:00'): # 18:30 before event + self.assertEqual(search_event(), event) + with freeze_time('2023-11-16 00:00:00'): # 19:00 during event + self.assertEqual(search_event(), event) + with freeze_time('2023-11-16 00:30:00'): # 19:30 during event + self.assertEqual(search_event(), event) + with freeze_time('2023-11-16 01:00:00'): # 20:00 during event + self.assertEqual(search_event(), event) + with freeze_time('2023-11-16 01:30:00'): # 20:30 after event + self.assertEqual(len(search_event()), 0) + event.unlink() + + event = self.env['calendar.event'].create({ + 'name': "Meeting", + 'start': datetime(2023, 11, 16, 21, 0), # 16:00 16th November + 'stop': datetime(2023, 11, 16, 22, 0), # 27:00 16th November + }).with_context(mail_notrack=True) + with freeze_time('2023-11-15 19:00:00'): # 14:00 the day before event + self.assertEqual(len(search_event()), 0) + event.unlink()