Skip to content

Commit a7217fe

Browse files
authored
Merge pull request #824 from roflcoopter/feature/timezone-corrections
unified timezone handling
2 parents 56484d6 + a2a4391 commit a7217fe

File tree

14 files changed

+289
-84
lines changed

14 files changed

+289
-84
lines changed

tests/components/webserver/api/v1/test_events.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,23 @@ def prepare_and_mock(self, get_db_session: sessionmaker[Session]):
2626
session.execute(
2727
insert(Motion).values(
2828
camera_identifier="test",
29-
start_time=datetime.datetime(2024, 6, 22, 1, 0, 0),
30-
end_time=datetime.datetime(2024, 6, 22, 1, 1, 0),
29+
start_time=datetime.datetime(
30+
2024, 6, 22, 1, 0, 0, tzinfo=datetime.timezone.utc
31+
),
32+
end_time=datetime.datetime(
33+
2024, 6, 22, 1, 1, 0, tzinfo=datetime.timezone.utc
34+
),
3135
)
3236
)
3337
session.execute(
3438
insert(Motion).values(
3539
camera_identifier="test",
36-
start_time=datetime.datetime(2024, 6, 22, 3, 0, 0),
37-
end_time=datetime.datetime(2024, 6, 22, 3, 1, 0),
40+
start_time=datetime.datetime(
41+
2024, 6, 22, 3, 0, 0, tzinfo=datetime.timezone.utc
42+
),
43+
end_time=datetime.datetime(
44+
2024, 6, 22, 3, 1, 0, tzinfo=datetime.timezone.utc
45+
),
3846
)
3947
)
4048
session.execute(
@@ -43,7 +51,9 @@ def prepare_and_mock(self, get_db_session: sessionmaker[Session]):
4351
domain="face_recognition",
4452
snapshot_path="test",
4553
data={"label": "test", "confidence": 0.5},
46-
created_at=datetime.datetime(2024, 6, 22, 1, 0, 0),
54+
created_at=datetime.datetime(
55+
2024, 6, 22, 1, 0, 0, tzinfo=datetime.timezone.utc
56+
),
4757
)
4858
)
4959
session.execute(
@@ -52,7 +62,9 @@ def prepare_and_mock(self, get_db_session: sessionmaker[Session]):
5262
domain="face_recognition",
5363
snapshot_path="test",
5464
data={"label": "test", "confidence": 0.5},
55-
created_at=datetime.datetime(2024, 6, 22, 23, 0, 0),
65+
created_at=datetime.datetime(
66+
2024, 6, 22, 23, 0, 0, tzinfo=datetime.timezone.utc
67+
),
5668
)
5769
)
5870
session.commit()

tests/domains/camera/test_fragmenter.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,6 @@ def test_extract_extinf_number():
146146
def test_extract_program_date_time() -> None:
147147
"""Test _extract_program_date_time."""
148148
date_time_tag = _extract_program_date_time(PLAYLIST_CONTENT, "1723111156.m4s")
149-
assert date_time_tag == datetime.datetime(2024, 8, 8, 9, 59, 16, 199000)
149+
assert date_time_tag == datetime.datetime(
150+
2024, 8, 8, 9, 59, 16, 199000, tzinfo=datetime.timezone.utc
151+
)

tests/domains/camera/test_recorder.py

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from __future__ import annotations
33

44
import datetime
5+
import time
56
from collections.abc import Callable
67
from typing import TYPE_CHECKING, Literal
78
from unittest.mock import MagicMock, Mock, patch
@@ -31,29 +32,47 @@ def get_db_session_recordings(get_db_session: Callable[[], Session]):
3132
session.execute(
3233
insert(Recordings).values(
3334
camera_identifier="test1",
34-
start_time=datetime.datetime(2023, 3, 1, 23, 30), # 23:30 UTC March 1
35-
adjusted_start_time=datetime.datetime(2023, 3, 1, 23, 30),
36-
end_time=datetime.datetime(2023, 3, 2, 0, 30), # 00:30 UTC March 2
35+
start_time=datetime.datetime(
36+
2023, 3, 1, 23, 30, tzinfo=datetime.timezone.utc
37+
), # 23:30 UTC March 1
38+
adjusted_start_time=datetime.datetime(
39+
2023, 3, 1, 23, 30, tzinfo=datetime.timezone.utc
40+
),
41+
end_time=datetime.datetime(
42+
2023, 3, 2, 0, 30, tzinfo=datetime.timezone.utc
43+
), # 00:30 UTC March 2
3744
thumbnail_path="test",
3845
)
3946
)
4047
# Mid-day recording
4148
session.execute(
4249
insert(Recordings).values(
4350
camera_identifier="test1",
44-
start_time=datetime.datetime(2023, 3, 2, 12, 0), # 12:00 UTC March 2
45-
adjusted_start_time=datetime.datetime(2023, 3, 2, 12, 0),
46-
end_time=datetime.datetime(2023, 3, 2, 13, 0), # 13:00 UTC March 2
51+
start_time=datetime.datetime(
52+
2023, 3, 2, 12, 0, tzinfo=datetime.timezone.utc
53+
), # 12:00 UTC March 2
54+
adjusted_start_time=datetime.datetime(
55+
2023, 3, 2, 12, 0, tzinfo=datetime.timezone.utc
56+
),
57+
end_time=datetime.datetime(
58+
2023, 3, 2, 13, 0, tzinfo=datetime.timezone.utc
59+
), # 13:00 UTC March 2
4760
thumbnail_path="test",
4861
)
4962
)
5063
# Recording near day boundary
5164
session.execute(
5265
insert(Recordings).values(
5366
camera_identifier="test1",
54-
start_time=datetime.datetime(2023, 3, 2, 22, 45), # 22:45 UTC March 2
55-
adjusted_start_time=datetime.datetime(2023, 3, 2, 22, 45),
56-
end_time=datetime.datetime(2023, 3, 2, 23, 45), # 23:45 UTC March 2
67+
start_time=datetime.datetime(
68+
2023, 3, 2, 22, 45, tzinfo=datetime.timezone.utc
69+
), # 22:45 UTC March 2
70+
adjusted_start_time=datetime.datetime(
71+
2023, 3, 2, 22, 45, tzinfo=datetime.timezone.utc
72+
),
73+
end_time=datetime.datetime(
74+
2023, 3, 2, 23, 45, tzinfo=datetime.timezone.utc
75+
), # 23:45 UTC March 2
5776
thumbnail_path="test",
5877
)
5978
)
@@ -201,12 +220,16 @@ def test_delete_recording(
201220
"""Test delete_recording."""
202221
mock_delete_recording.return_value = []
203222
recorder_base = Recorder(vis, MagicMock(), MockCamera())
204-
result = recorder_base.delete_recording()
223+
result = recorder_base.delete_recording(
224+
datetime.timedelta(seconds=time.localtime().tm_gmtoff)
225+
)
205226
assert result is False
206227

207228
mock_delete_recording.return_value = [
208229
MagicMock(spec=Recordings),
209230
MagicMock(spec=Recordings),
210231
]
211-
result = recorder_base.delete_recording()
232+
result = recorder_base.delete_recording(
233+
datetime.timedelta(seconds=time.localtime().tm_gmtoff)
234+
)
212235
assert result is True

tests/helpers/test__init__.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Test helpers module."""
22
from contextlib import nullcontext
3+
from datetime import datetime, timedelta, timezone
34

45
import pytest
56

@@ -81,3 +82,102 @@ def test_convert_letterboxed_bbox(
8182
assert converted_bbox == expected
8283
if message:
8384
assert str(exception.value) == message
85+
86+
87+
def test_basic_conversion_zero_offset():
88+
"""Test with zero UTC offset."""
89+
date = "2024-01-01"
90+
utc_offset = timedelta(hours=0)
91+
92+
time_from, time_to = helpers.daterange_to_utc(date, utc_offset)
93+
94+
assert time_from == datetime(2024, 1, 1, 0, 0, 0, 0, tzinfo=timezone.utc)
95+
assert time_to == datetime(2024, 1, 1, 23, 59, 59, 999999, tzinfo=timezone.utc)
96+
97+
98+
def test_positive_utc_offset():
99+
"""Test with positive UTC offset (e.g., UTC+5)."""
100+
date = "2024-01-01"
101+
utc_offset = timedelta(hours=5)
102+
103+
time_from, time_to = helpers.daterange_to_utc(date, utc_offset)
104+
105+
# With UTC+5, the UTC time should be 5 hours earlier
106+
assert time_from == datetime(2023, 12, 31, 19, 0, 0, 0, tzinfo=timezone.utc)
107+
assert time_to == datetime(2024, 1, 1, 18, 59, 59, 999999, tzinfo=timezone.utc)
108+
109+
110+
def test_negative_utc_offset():
111+
"""Test with negative UTC offset (e.g., UTC-5)."""
112+
date = "2024-01-01"
113+
utc_offset = timedelta(hours=-5)
114+
115+
time_from, time_to = helpers.daterange_to_utc(date, utc_offset)
116+
117+
# With UTC-5, the UTC time should be 5 hours later
118+
assert time_from == datetime(2024, 1, 1, 5, 0, 0, 0, tzinfo=timezone.utc)
119+
assert time_to == datetime(2024, 1, 2, 4, 59, 59, 999999, tzinfo=timezone.utc)
120+
121+
122+
def test_fractional_hour_offset():
123+
"""Test with UTC offset including minutes."""
124+
date = "2024-01-01"
125+
utc_offset = timedelta(hours=5, minutes=30) # UTC+5:30 (like India)
126+
127+
time_from, time_to = helpers.daterange_to_utc(date, utc_offset)
128+
129+
assert time_from == datetime(2023, 12, 31, 18, 30, 0, 0, tzinfo=timezone.utc)
130+
assert time_to == datetime(2024, 1, 1, 18, 29, 59, 999999, tzinfo=timezone.utc)
131+
132+
133+
def test_year_boundary():
134+
"""Test date at year boundary with offset that crosses the year."""
135+
date = "2024-01-01"
136+
utc_offset = timedelta(hours=2)
137+
138+
time_from, time_to = helpers.daterange_to_utc(date, utc_offset)
139+
140+
assert time_from == datetime(2023, 12, 31, 22, 0, 0, 0, tzinfo=timezone.utc)
141+
assert time_to == datetime(2024, 1, 1, 21, 59, 59, 999999, tzinfo=timezone.utc)
142+
143+
144+
def test_invalid_date_format():
145+
"""Test that invalid date format raises ValueError."""
146+
date = "2024/01/01" # Wrong format
147+
utc_offset = timedelta(hours=0)
148+
149+
with pytest.raises(ValueError):
150+
helpers.daterange_to_utc(date, utc_offset)
151+
152+
153+
def test_leap_year_date():
154+
"""Test with February 29th on a leap year."""
155+
date = "2024-02-29" # 2024 is a leap year
156+
utc_offset = timedelta(hours=0)
157+
158+
time_from, time_to = helpers.daterange_to_utc(date, utc_offset)
159+
160+
assert time_from == datetime(2024, 2, 29, 0, 0, 0, 0, tzinfo=timezone.utc)
161+
assert time_to == datetime(2024, 2, 29, 23, 59, 59, 999999, tzinfo=timezone.utc)
162+
163+
164+
def test_extreme_offset():
165+
"""Test with maximum possible UTC offset."""
166+
date = "2024-01-01"
167+
utc_offset = timedelta(hours=14) # Maximum UTC offset (UTC+14)
168+
169+
time_from, time_to = helpers.daterange_to_utc(date, utc_offset)
170+
171+
assert time_from == datetime(2023, 12, 31, 10, 0, 0, 0, tzinfo=timezone.utc)
172+
assert time_to == datetime(2024, 1, 1, 9, 59, 59, 999999, tzinfo=timezone.utc)
173+
174+
175+
def test_microsecond_precision():
176+
"""Test that microsecond precision is maintained."""
177+
date = "2024-01-01"
178+
utc_offset = timedelta(hours=0)
179+
180+
time_from, time_to = helpers.daterange_to_utc(date, utc_offset)
181+
182+
assert time_from.microsecond == 0
183+
assert time_to.microsecond == 999999

viseron/components/storage/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ class UTCDateTime(types.TypeDecorator):
3333

3434
def process_bind_param(self, value, _dialect):
3535
"""Remove timezone info from datetime."""
36+
# Only allow UTC datetimes
3637
if isinstance(value, datetime.datetime):
38+
if value.tzinfo is None:
39+
raise ValueError("Only UTC datetimes are allowed")
3740
return value.replace(tzinfo=None)
3841
return value
3942

viseron/components/storage/queries.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -318,9 +318,9 @@ def get_time_period_fragments(
318318
now=None,
319319
):
320320
"""Return a list of files for the requested time period."""
321-
start = datetime.datetime.utcfromtimestamp(start_timestamp)
321+
start = datetime.datetime.fromtimestamp(start_timestamp, tz=datetime.timezone.utc)
322322
if end_timestamp:
323-
end = datetime.datetime.utcfromtimestamp(end_timestamp)
323+
end = datetime.datetime.fromtimestamp(end_timestamp, tz=datetime.timezone.utc)
324324
else:
325325
end = now if now else utcnow()
326326

viseron/components/webserver/api/v1/events.py

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from viseron.domains.license_plate_recognition.const import (
2323
DOMAIN as LICENSE_PLATE_RECOGNITION_DOMAIN,
2424
)
25+
from viseron.helpers import daterange_to_utc
2526

2627
if TYPE_CHECKING:
2728
from sqlalchemy.orm import Session
@@ -76,8 +77,12 @@ def _motion_events(
7677
time_to: int,
7778
) -> list:
7879
"""Select motion events from database."""
79-
time_from_datetime = datetime.datetime.fromtimestamp(time_from)
80-
time_to_datetime = datetime.datetime.fromtimestamp(time_to)
80+
time_from_datetime = datetime.datetime.fromtimestamp(
81+
time_from, tz=datetime.timezone.utc
82+
)
83+
time_to_datetime = datetime.datetime.fromtimestamp(
84+
time_to, tz=datetime.timezone.utc
85+
)
8186
with get_session() as session:
8287
stmt = (
8388
select(Motion)
@@ -120,8 +125,12 @@ def _object_event(
120125
time_to: int,
121126
):
122127
"""Select object events from database."""
123-
time_from_datetime = datetime.datetime.fromtimestamp(time_from)
124-
time_to_datetime = datetime.datetime.fromtimestamp(time_to)
128+
time_from_datetime = datetime.datetime.fromtimestamp(
129+
time_from, tz=datetime.timezone.utc
130+
)
131+
time_to_datetime = datetime.datetime.fromtimestamp(
132+
time_to, tz=datetime.timezone.utc
133+
)
125134
with get_session() as session:
126135
stmt = (
127136
select(Objects)
@@ -156,8 +165,12 @@ def _recording_events(
156165
time_to: int,
157166
) -> list:
158167
"""Select recording events from database."""
159-
time_from_datetime = datetime.datetime.fromtimestamp(time_from)
160-
time_to_datetime = datetime.datetime.fromtimestamp(time_to)
168+
time_from_datetime = datetime.datetime.fromtimestamp(
169+
time_from, tz=datetime.timezone.utc
170+
)
171+
time_to_datetime = datetime.datetime.fromtimestamp(
172+
time_to, tz=datetime.timezone.utc
173+
)
161174
with get_session() as session:
162175
stmt = (
163176
select(Recordings)
@@ -203,8 +216,12 @@ def _post_processor_events(
203216
time_to: int,
204217
) -> list:
205218
"""Select post processor events from database."""
206-
time_from_datetime = datetime.datetime.fromtimestamp(time_from)
207-
time_to_datetime = datetime.datetime.fromtimestamp(time_to)
219+
time_from_datetime = datetime.datetime.fromtimestamp(
220+
time_from, tz=datetime.timezone.utc
221+
)
222+
time_to_datetime = datetime.datetime.fromtimestamp(
223+
time_to, tz=datetime.timezone.utc
224+
)
208225
with get_session() as session:
209226
stmt = (
210227
select(PostProcessorResults)
@@ -253,14 +270,10 @@ async def get_events(
253270
)
254271
return
255272

256-
# Get start of day in utc
273+
# Convert local start of day to UTC
257274
if "date" in self.request_arguments:
258-
_time_from = (
259-
datetime.datetime.strptime(self.request_arguments["date"], "%Y-%m-%d")
260-
- self.utc_offset
261-
)
262-
_time_to = _time_from + datetime.timedelta(
263-
hours=23, minutes=59, seconds=59, milliseconds=999999
275+
_time_from, _time_to = daterange_to_utc(
276+
self.request_arguments["date"], self.utc_offset
264277
)
265278
time_from = _time_from.timestamp()
266279
time_to = _time_to.timestamp()

viseron/components/webserver/api/v1/hls.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
generate_playlist,
2121
get_available_timespans,
2222
)
23-
from viseron.helpers import utcnow
23+
from viseron.helpers import daterange_to_utc, utcnow
2424
from viseron.helpers.fixed_size_dict import FixedSizeDict
2525
from viseron.helpers.validators import request_argument_no_value
2626

@@ -185,13 +185,13 @@ async def get_available_timespans(
185185
)
186186
return
187187

188-
# Get start of day in utc
188+
# Convert local start of day to UTC
189189
if "date" in self.request_arguments:
190-
time_from = (
191-
datetime.datetime.strptime(self.request_arguments["date"], "%Y-%m-%d")
192-
- self.utc_offset
193-
).timestamp()
194-
time_to = time_from + 86400
190+
_time_from, _time_to = daterange_to_utc(
191+
self.request_arguments["date"], self.utc_offset
192+
)
193+
time_from = _time_from.timestamp()
194+
time_to = _time_to.timestamp()
195195
else:
196196
time_from = self.request_arguments["time_from"]
197197
time_to = self.request_arguments["time_to"]

0 commit comments

Comments
 (0)