Skip to content

Commit c302576

Browse files
author
jomae
committed
1.6.1dev: fix incorrect events and days appearing in timeline view when crossing the daylight saving time boundary (closes #13877)
git-svn-id: http://trac.edgewall.org/intertrac/log:/branches/1.6-stable@17896 af82e41b-90c4-0310-8c96-b1721e28e2e2
1 parent 209da88 commit c302576

File tree

3 files changed

+129
-10
lines changed

3 files changed

+129
-10
lines changed

trac/timeline/tests/web_ui.py

Lines changed: 86 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
# This software consists of voluntary contributions made by many
1111
# individuals. For the exact contribution history, see the revision
1212
# history and logs, available at https://trac.edgewall.org/log/.
13+
import itertools
1314
import textwrap
1415
import unittest
1516
from datetime import datetime, timedelta
@@ -22,7 +23,7 @@
2223
from trac.timeline.web_ui import TimelineModule
2324
from trac.util.datefmt import (
2425
datetime_now, format_date, format_datetime, format_time,
25-
get_date_format_hint, pretty_timedelta, utc,
26+
get_date_format_hint, get_timezone, pretty_timedelta, pytz, utc,
2627
)
2728
from trac.util.html import plaintext, tag
2829
from trac.web.chrome import Chrome
@@ -265,13 +266,12 @@ def render(context, field, event):
265266
if field == 'description':
266267
return tag(tag.h1('Title 2nd'), tag.p('body & < >'))
267268

268-
provider = self._get_event_provider('normal')
269-
provider._events = [
269+
self._set_events([
270270
('test&1', datetime(2018, 4, 27, 12, 34, 56, 123456, utc),
271271
'jo&hn', Mock(render=render)),
272272
('test&2', datetime(2018, 3, 19, 23, 56, 12, 987654, utc),
273273
'Joe <[email protected]>', Mock(render=render)),
274-
]
274+
])
275275
req = MockRequest(self.env, path_info='/timeline', tz=utc,
276276
args={'format': 'rss', 'daysback': '90',
277277
'from': '2018-04-30T00:00:00Z'})
@@ -311,12 +311,11 @@ def render(context, field, event):
311311
minidom.parseString(output) # verify valid xml
312312

313313
def test_daysback(self):
314-
provider = self._get_event_provider('normal')
315314
base_datetime = datetime(2025, 2, 20, 12, tzinfo=utc)
316-
provider._events = [
315+
self._set_events([
317316
('test', base_datetime - timedelta(days=days), 'trac', None)
318317
for days in range(-30, 120)
319-
]
318+
])
320319

321320
def render_and_get_events(daysback):
322321
req = MockRequest(self.env, path_info='/timeline', tz=utc,
@@ -348,8 +347,86 @@ def get_datetimes(events):
348347
get_datetimes(events)[-1])
349348
self.assertEqual(91, len(events))
350349

351-
def _get_event_provider(self, name):
352-
return self.timeline_event_providers[name](self.env)
350+
@unittest.skipUnless(pytz, 'pytz unavailable')
351+
def test_forward_across_dst(self):
352+
tz = get_timezone('Europe/Berlin') # DST start at 2025-03-30 02:00
353+
base = datetime(2025, 3, 30, 12, 30, tzinfo=utc)
354+
self._set_events([
355+
('test', base - timedelta(hours=hours), 'trac', None)
356+
for hours in range(-48, 48)
357+
])
358+
req = MockRequest(self.env, path_info='/timeline', tz=tz,
359+
args={'from': '2025-03-31T12:34:56Z',
360+
'daysback': '2'})
361+
rv = self._process_request(req)
362+
events = rv[1]['events']
363+
grouped_events = {
364+
str(key): list(map(str, sorted(e['datetime'] for e in g)))
365+
for key, g in itertools.groupby(events, key=lambda e: e['date'])
366+
}
367+
self.assertEqual(
368+
['2025-03-30 00:30:00+01:00', '2025-03-30 01:30:00+01:00',
369+
'2025-03-30 03:30:00+02:00', '2025-03-30 04:30:00+02:00',
370+
'2025-03-30 05:30:00+02:00', '2025-03-30 06:30:00+02:00',
371+
'2025-03-30 07:30:00+02:00', '2025-03-30 08:30:00+02:00',
372+
'2025-03-30 09:30:00+02:00', '2025-03-30 10:30:00+02:00',
373+
'2025-03-30 11:30:00+02:00', '2025-03-30 12:30:00+02:00',
374+
'2025-03-30 13:30:00+02:00', '2025-03-30 14:30:00+02:00',
375+
'2025-03-30 15:30:00+02:00', '2025-03-30 16:30:00+02:00',
376+
'2025-03-30 17:30:00+02:00', '2025-03-30 18:30:00+02:00',
377+
'2025-03-30 19:30:00+02:00', '2025-03-30 20:30:00+02:00',
378+
'2025-03-30 21:30:00+02:00', '2025-03-30 22:30:00+02:00',
379+
'2025-03-30 23:30:00+02:00'],
380+
grouped_events['2025-03-30 00:00:00+01:00'])
381+
self.assertEqual(
382+
['2025-03-29 00:00:00+01:00', '2025-03-30 00:00:00+01:00',
383+
'2025-03-31 00:00:00+02:00'], sorted(grouped_events))
384+
self.assertEqual(24, len(grouped_events['2025-03-29 00:00:00+01:00']))
385+
self.assertEqual(23, len(grouped_events['2025-03-30 00:00:00+01:00']))
386+
self.assertEqual(24, len(grouped_events['2025-03-31 00:00:00+02:00']))
387+
388+
@unittest.skipUnless(pytz, 'pytz unavailable')
389+
def test_back_across_dst(self):
390+
tz = get_timezone('Europe/Berlin') # DST end at 2024-10-27 03:00
391+
base = datetime(2024, 10, 27, 12, 30, tzinfo=utc)
392+
self._set_events([
393+
('test', base - timedelta(hours=hours), 'trac', None)
394+
for hours in range(-48, 48)
395+
])
396+
req = MockRequest(self.env, path_info='/timeline', tz=tz,
397+
args={'from': '2024-10-28T12:34:56Z',
398+
'daysback': '2'})
399+
rv = self._process_request(req)
400+
events = rv[1]['events']
401+
grouped_events = {
402+
str(key): list(map(str, sorted(e['datetime'] for e in g)))
403+
for key, g in itertools.groupby(events, key=lambda e: e['date'])
404+
}
405+
self.assertEqual(
406+
['2024-10-27 00:30:00+02:00', '2024-10-27 01:30:00+02:00',
407+
'2024-10-27 02:30:00+02:00', '2024-10-27 02:30:00+01:00',
408+
'2024-10-27 03:30:00+01:00', '2024-10-27 04:30:00+01:00',
409+
'2024-10-27 05:30:00+01:00', '2024-10-27 06:30:00+01:00',
410+
'2024-10-27 07:30:00+01:00', '2024-10-27 08:30:00+01:00',
411+
'2024-10-27 09:30:00+01:00', '2024-10-27 10:30:00+01:00',
412+
'2024-10-27 11:30:00+01:00', '2024-10-27 12:30:00+01:00',
413+
'2024-10-27 13:30:00+01:00', '2024-10-27 14:30:00+01:00',
414+
'2024-10-27 15:30:00+01:00', '2024-10-27 16:30:00+01:00',
415+
'2024-10-27 17:30:00+01:00', '2024-10-27 18:30:00+01:00',
416+
'2024-10-27 19:30:00+01:00', '2024-10-27 20:30:00+01:00',
417+
'2024-10-27 21:30:00+01:00', '2024-10-27 22:30:00+01:00',
418+
'2024-10-27 23:30:00+01:00'],
419+
grouped_events['2024-10-27 00:00:00+02:00'])
420+
self.assertEqual(
421+
['2024-10-26 00:00:00+02:00', '2024-10-27 00:00:00+02:00',
422+
'2024-10-28 00:00:00+01:00'], sorted(grouped_events))
423+
self.assertEqual(24, len(grouped_events['2024-10-26 00:00:00+02:00']))
424+
self.assertEqual(25, len(grouped_events['2024-10-27 00:00:00+02:00']))
425+
self.assertEqual(24, len(grouped_events['2024-10-28 00:00:00+01:00']))
426+
427+
def _set_events(self, events):
428+
provider = self.timeline_event_providers['normal'](self.env)
429+
provider._events = tuple(events)
353430

354431
def _process_request(self, req):
355432
mod = TimelineModule(self.env)

trac/util/datefmt.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,10 @@ def to_datetime(t, tzinfo=None):
184184

185185
def truncate_datetime(dt):
186186
"""Truncate a datetime object to the start of the day."""
187-
return dt.replace(hour=0, minute=0, second=0, microsecond=0)
187+
truncated = datetime(dt.year, dt.month, dt.day)
188+
if dt.tzinfo:
189+
truncated = to_datetime(truncated, dt.tzinfo)
190+
return truncated
188191

189192

190193
def to_timestamp(dt):

trac/util/tests/datefmt.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1025,6 +1025,44 @@ def test_format_compatibility(self):
10251025
datefmt.format_time(t, f, tz))
10261026

10271027

1028+
class TruncateDatetimeTestCase(unittest.TestCase):
1029+
1030+
def test_without_tz(self):
1031+
dt = datetime.datetime(2025, 4, 26, 12, 34, 56, 987654)
1032+
result = datefmt.truncate_datetime(dt)
1033+
self.assertEqual(datetime.datetime(2025, 4, 26), result)
1034+
self.assertEqual(None, result.tzinfo)
1035+
self.assertEqual('2025-04-26T00:00:00', result.isoformat())
1036+
1037+
def test_fixed_tz(self):
1038+
tz = datefmt.timezone('GMT +7:00')
1039+
dt = datetime.datetime(2025, 4, 26, 12, 34, 56, 987654, tz)
1040+
result = datefmt.truncate_datetime(dt)
1041+
self.assertEqual(datetime.datetime(2025, 4, 25, 17,
1042+
tzinfo=datefmt.utc), result)
1043+
self.assertEqual('2025-04-26T00:00:00+07:00', result.isoformat())
1044+
1045+
@unittest.skipUnless(datefmt.pytz, 'pytz unavailable')
1046+
def test_forward_across_dst(self):
1047+
# DST start at 2025-03-30 02:00
1048+
tz = datefmt.get_timezone('Europe/Berlin')
1049+
dt = datetime.datetime(2025, 3, 30, 12, 34, 56, 987654, tz)
1050+
result = datefmt.truncate_datetime(dt)
1051+
self.assertEqual(datetime.datetime(2025, 3, 29, 23,
1052+
tzinfo=datefmt.utc), result)
1053+
self.assertEqual('2025-03-30T00:00:00+01:00', result.isoformat())
1054+
1055+
@unittest.skipUnless(datefmt.pytz, 'pytz unavailable')
1056+
def test_back_across_dst(self):
1057+
# DST end at 2024-10-27 03:00
1058+
tz = datefmt.get_timezone('Europe/Berlin')
1059+
dt = datetime.datetime(2024, 10, 27, 12, 34, 56, 987654, tz)
1060+
result = datefmt.truncate_datetime(dt)
1061+
self.assertEqual(datetime.datetime(2024, 10, 26, 22,
1062+
tzinfo=datefmt.utc), result)
1063+
self.assertEqual('2024-10-27T00:00:00+02:00', result.isoformat())
1064+
1065+
10281066
class UTimestampTestCase(unittest.TestCase):
10291067

10301068
def test_sub_second(self):
@@ -2104,6 +2142,7 @@ def test_suite():
21042142
else:
21052143
print("SKIP: utils/tests/datefmt.py (no pytz installed)")
21062144
suite.addTest(makeSuite(DateFormatTestCase))
2145+
suite.addTest(makeSuite(TruncateDatetimeTestCase))
21072146
suite.addTest(makeSuite(UTimestampTestCase))
21082147
suite.addTest(makeSuite(ISO8601TestCase))
21092148
if I18nDateFormatTestCase:

0 commit comments

Comments
 (0)