Skip to content

Commit b3d494c

Browse files
committed
feat: normalize datetimes with deployment time zones
1 parent f899482 commit b3d494c

5 files changed

Lines changed: 130 additions & 1 deletion

File tree

ami/main/api/serializers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ class Meta:
185185
"updated_at",
186186
"latitude",
187187
"longitude",
188+
"time_zone",
188189
"first_date",
189190
"last_date",
190191
"device",
@@ -234,6 +235,7 @@ class Meta:
234235
"id",
235236
"name",
236237
"details",
238+
"time_zone",
237239
]
238240

239241

@@ -247,6 +249,7 @@ class Meta:
247249
"details",
248250
"latitude",
249251
"longitude",
252+
"time_zone",
250253
"events_count",
251254
# "captures_count",
252255
# "detections_count",
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from django.conf import settings
2+
from django.db import migrations, models
3+
4+
5+
class Migration(migrations.Migration):
6+
7+
dependencies = [
8+
("main", "0078_classification_applied_to"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="deployment",
14+
name="time_zone",
15+
field=models.CharField(
16+
default=settings.TIME_ZONE,
17+
help_text="IANA time zone for this deployment. Naive datetimes are interpreted in this zone before being stored as UTC.",
18+
max_length=64,
19+
),
20+
),
21+
]

ami/main/models.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import urllib.parse
99
from io import BytesIO
1010
from typing import Final, final # noqa: F401
11+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
1112

1213
import PIL.Image
1314
import pydantic
@@ -606,6 +607,11 @@ class Deployment(BaseModel):
606607
latitude = models.FloatField(null=True, blank=True)
607608
longitude = models.FloatField(null=True, blank=True)
608609
image = models.ImageField(upload_to="deployments", blank=True, null=True)
610+
time_zone = models.CharField(
611+
max_length=64,
612+
default=settings.TIME_ZONE,
613+
help_text="IANA time zone for this deployment. Naive datetimes are interpreted in this zone before being stored as UTC.",
614+
)
609615

610616
project = models.ForeignKey(Project, on_delete=models.SET_NULL, null=True, related_name="deployments")
611617

@@ -662,6 +668,14 @@ class Deployment(BaseModel):
662668
class Meta:
663669
ordering = ["name"]
664670

671+
def clean(self):
672+
super().clean()
673+
if self.time_zone:
674+
try:
675+
ZoneInfo(self.time_zone)
676+
except ZoneInfoNotFoundError as exc:
677+
raise ValidationError({"time_zone": f"Invalid IANA time zone '{self.time_zone}': {exc}"}) from exc
678+
665679
def taxa(self) -> models.QuerySet["Taxon"]:
666680
return Taxon.objects.filter(Q(occurrences__deployment=self)).distinct()
667681

ami/main/signals.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import logging
2+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
23

34
from django.contrib.auth.models import Group
45
from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_save
56
from django.dispatch import receiver
7+
from django.conf import settings
8+
from django.utils import timezone
69
from guardian.shortcuts import assign_perm
710

811
from ami.users.roles import BasicMember, ProjectManager, create_roles_for_project
912

10-
from .models import Project, User
13+
from .models import Classification, Detection, Event, Project, SourceImage, User
1114

1215
logger = logging.getLogger(__name__)
1316

@@ -110,3 +113,52 @@ def delete_project_groups(sender, instance, **kwargs):
110113
prefix = f"{instance.pk}_"
111114
# Find and delete all groups that start with {project_id}_
112115
Group.objects.filter(name__startswith=prefix).delete()
116+
117+
118+
_TZ_FIELDS = {
119+
Event: ("start", "end", "calculated_fields_updated_at"),
120+
SourceImage: ("timestamp", "last_modified"),
121+
Detection: ("timestamp", "detection_time"),
122+
Classification: ("timestamp",),
123+
}
124+
125+
126+
def _resolve_deployment(instance):
127+
deployment = getattr(instance, "deployment", None)
128+
if deployment:
129+
return deployment
130+
131+
source_image = getattr(instance, "source_image", None)
132+
if source_image:
133+
return getattr(source_image, "deployment", None)
134+
135+
detection = getattr(instance, "detection", None)
136+
if detection:
137+
src = getattr(detection, "source_image", None)
138+
return getattr(src, "deployment", None) if src else None
139+
140+
return None
141+
142+
143+
def _normalize_datetimes(sender, instance, **_kwargs):
144+
fields = _TZ_FIELDS.get(sender)
145+
if not fields or not settings.USE_TZ:
146+
return
147+
148+
deployment = _resolve_deployment(instance)
149+
try:
150+
tz = ZoneInfo(getattr(deployment, "time_zone", None) or timezone.get_default_timezone_name())
151+
except (ZoneInfoNotFoundError, TypeError):
152+
tz = timezone.get_default_timezone()
153+
154+
for field in fields:
155+
value = getattr(instance, field, None)
156+
if value is None:
157+
continue
158+
if timezone.is_naive(value):
159+
value = timezone.make_aware(value, tz)
160+
setattr(instance, field, value.astimezone(timezone.utc))
161+
162+
163+
for model in _TZ_FIELDS:
164+
pre_save.connect(_normalize_datetimes, sender=model, dispatch_uid=f"tznorm-{model.__name__}", weak=False)

ami/main/tests.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,20 @@
44
from io import BytesIO
55

66
from django.contrib.auth.models import AnonymousUser
7+
from django.core.exceptions import ValidationError
78
from django.core.files.uploadedfile import SimpleUploadedFile
89
from django.db import connection, models
910
from django.test import TestCase, override_settings
1011
from guardian.shortcuts import assign_perm, get_perms, remove_perm
1112
from PIL import Image
1213
from rest_framework import status
1314
from rest_framework.test import APIRequestFactory, APITestCase
15+
from django.utils import timezone
1416
from rich import print
1517

1618
from ami.exports.models import DataExport
1719
from ami.jobs.models import VALID_JOB_TYPES, Job
20+
from ami.main.api.serializers import DeploymentSerializer
1821
from ami.main.models import (
1922
Classification,
2023
Deployment,
@@ -46,6 +49,42 @@
4649
logger = logging.getLogger(__name__)
4750

4851

52+
class TestTimeZoneNormalization(TestCase):
53+
def test_deployment_invalid_time_zone_raises(self):
54+
project = Project.objects.create(name="TZ Project", create_defaults=False)
55+
deployment = Deployment(project=project, name="D1", time_zone="Mars/Phobos")
56+
with self.assertRaises(ValidationError):
57+
deployment.full_clean()
58+
59+
def test_deployment_serializer_exposes_time_zone(self):
60+
project = Project.objects.create(name="TZ Project", create_defaults=False)
61+
deployment = Deployment.objects.create(project=project, name="D1", time_zone="UTC")
62+
63+
class MinimalDeploymentSerializer(DeploymentSerializer):
64+
class Meta(DeploymentSerializer.Meta):
65+
fields = ("id", "time_zone")
66+
67+
request = APIRequestFactory().get("/")
68+
request.user = AnonymousUser()
69+
70+
data = MinimalDeploymentSerializer(deployment, context={"request": request}).data
71+
self.assertEqual(data["time_zone"], "UTC")
72+
73+
@override_settings(USE_TZ=True)
74+
def test_event_presave_normalizes_with_deployment_tz(self):
75+
project = Project.objects.create(name="TZ Project", create_defaults=False)
76+
deployment = Deployment.objects.create(project=project, name="D1", time_zone="America/New_York")
77+
start = datetime.datetime(2020, 1, 1, 0, 0, 0) # naive
78+
end = datetime.datetime(2020, 1, 1, 1, 0, 0)
79+
event = Event.objects.create(project=project, deployment=deployment, start=start, end=end, group_by="g1")
80+
self.assertEqual(event.start.tzinfo, timezone.utc)
81+
self.assertEqual(event.end.tzinfo, timezone.utc)
82+
expected_start = datetime.datetime(2020, 1, 1, 5, 0, 0, tzinfo=timezone.utc)
83+
expected_end = datetime.datetime(2020, 1, 1, 6, 0, 0, tzinfo=timezone.utc)
84+
self.assertEqual(event.start, expected_start) # America/New_York (Eastern Standard Time, UTC-5) -> UTC
85+
self.assertEqual(event.end, expected_end)
86+
87+
4988
class TestProjectSetup(TestCase):
5089
def test_project_creation(self):
5190
project = Project.objects.create(name="New Project with Defaults", create_defaults=True)

0 commit comments

Comments
 (0)