Skip to content

Commit b2c7991

Browse files
committed
feat: add per-deployment time zone metadata
1 parent dafb83a commit b2c7991

5 files changed

Lines changed: 141 additions & 0 deletions

File tree

ami/main/admin.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ class DeploymentAdmin(admin.ModelAdmin[Deployment]):
131131
list_display = (
132132
"name",
133133
"project",
134+
"time_zone",
134135
"data_source_uri",
135136
"captures_count",
136137
"captures_size",
@@ -142,6 +143,7 @@ class DeploymentAdmin(admin.ModelAdmin[Deployment]):
142143
search_fields = (
143144
"id",
144145
"name",
146+
"time_zone",
145147
)
146148

147149
def start_date(self, obj) -> str | None:

ami/main/api/serializers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ class Meta:
186186
"updated_at",
187187
"latitude",
188188
"longitude",
189+
"time_zone",
189190
"first_date",
190191
"last_date",
191192
"device",
@@ -235,6 +236,7 @@ class Meta:
235236
"id",
236237
"name",
237238
"details",
239+
"time_zone",
238240
]
239241

240242

@@ -248,6 +250,7 @@ class Meta:
248250
"details",
249251
"latitude",
250252
"longitude",
253+
"time_zone",
251254
"events_count",
252255
# "captures_count",
253256
# "detections_count",
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Generated by Django 4.2.10
2+
3+
import ami.main.models
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
("main", "0082_add_taxalist_permissions"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="deployment",
15+
name="time_zone",
16+
field=models.CharField(
17+
default="America/New_York",
18+
help_text="IANA time zone identifier (e.g. 'America/New_York', 'Europe/London').",
19+
max_length=63,
20+
validators=[ami.main.models.validate_time_zone],
21+
),
22+
),
23+
]

ami/main/models.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import time
77
import typing
88
import urllib.parse
9+
import zoneinfo
910
from io import BytesIO
1011
from typing import Final, final # noqa: F401
1112

@@ -680,6 +681,22 @@ def _compare_totals_for_sync(deployment: "Deployment", total_files_found: int):
680681
)
681682

682683

684+
def validate_time_zone(value):
685+
"""Validate that value is a recognized IANA time zone identifier."""
686+
if not isinstance(value, str) or not value:
687+
raise ValidationError("Time zone must be a non-empty string.")
688+
cleaned = value.strip()
689+
if cleaned != value:
690+
raise ValidationError("Time zone must not contain leading or trailing whitespace.")
691+
try:
692+
zoneinfo.ZoneInfo(cleaned)
693+
except (KeyError, zoneinfo.ZoneInfoNotFoundError) as exc:
694+
raise ValidationError(
695+
"%(value)s is not a valid IANA time zone.",
696+
params={"value": value},
697+
) from exc
698+
699+
683700
@final
684701
class Deployment(BaseModel):
685702
"""
@@ -690,6 +707,12 @@ class Deployment(BaseModel):
690707
description = models.TextField(blank=True)
691708
latitude = models.FloatField(null=True, blank=True)
692709
longitude = models.FloatField(null=True, blank=True)
710+
time_zone = models.CharField(
711+
max_length=63,
712+
default=settings.TIME_ZONE,
713+
validators=[validate_time_zone],
714+
help_text="IANA time zone identifier (e.g. 'America/New_York', 'Europe/London').",
715+
)
693716
image = models.ImageField(upload_to="deployments", blank=True, null=True)
694717

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

ami/main/tests.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
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
@@ -15,6 +16,11 @@
1516

1617
from ami.exports.models import DataExport
1718
from ami.jobs.models import VALID_JOB_TYPES, Job
19+
from ami.main.api.serializers import (
20+
DeploymentListSerializer,
21+
DeploymentNestedSerializer,
22+
DeploymentNestedSerializerWithLocationAndCounts,
23+
)
1824
from ami.main.models import (
1925
Classification,
2026
Deployment,
@@ -3744,3 +3750,87 @@ def test_list_pipelines_public_project_non_member(self):
37443750
self.client.force_authenticate(user=non_member)
37453751
response = self.client.get(url)
37463752
self.assertEqual(response.status_code, status.HTTP_200_OK)
3753+
3754+
3755+
class TestDeploymentTimeZone(TestCase):
3756+
"""Tests for Deployment.time_zone field validation and serializer exposure."""
3757+
3758+
@classmethod
3759+
def setUpTestData(cls):
3760+
cls.project, cls.deployment = setup_test_project(reuse=False)
3761+
3762+
def test_valid_iana_zones_accepted(self):
3763+
for tz in ["Europe/London", "Asia/Tokyo", "UTC", "US/Eastern", "Etc/GMT+5"]:
3764+
self.deployment.time_zone = tz
3765+
self.deployment.full_clean()
3766+
3767+
def test_invalid_zone_rejected(self):
3768+
for bad in ["Fake/Zone", "Not_A_Zone", "123"]:
3769+
self.deployment.time_zone = bad
3770+
with self.assertRaises(ValidationError):
3771+
self.deployment.full_clean()
3772+
3773+
def test_empty_string_rejected(self):
3774+
self.deployment.time_zone = ""
3775+
with self.assertRaises(ValidationError):
3776+
self.deployment.full_clean()
3777+
3778+
def test_none_rejected(self):
3779+
self.deployment.time_zone = None
3780+
with self.assertRaises(ValidationError):
3781+
self.deployment.full_clean()
3782+
3783+
def test_whitespace_padded_rejected(self):
3784+
self.deployment.time_zone = " UTC "
3785+
with self.assertRaises(ValidationError):
3786+
self.deployment.full_clean()
3787+
3788+
def test_default_is_america_new_york(self):
3789+
d = Deployment(name="tz-default-test", project=self.project)
3790+
self.assertEqual(d.time_zone, "America/New_York")
3791+
3792+
def test_list_serializer_includes_time_zone(self):
3793+
self.assertIn("time_zone", DeploymentListSerializer.Meta.fields)
3794+
3795+
def test_nested_serializer_includes_time_zone(self):
3796+
self.assertIn("time_zone", DeploymentNestedSerializer.Meta.fields)
3797+
3798+
def test_nested_with_location_serializer_includes_time_zone(self):
3799+
self.assertIn("time_zone", DeploymentNestedSerializerWithLocationAndCounts.Meta.fields)
3800+
3801+
3802+
class TestDeploymentTimeZoneAPI(APITestCase):
3803+
"""Tests for Deployment.time_zone via the REST API."""
3804+
3805+
def setUp(self):
3806+
self.user = User.objects.create_superuser(email="tz-test@insectai.org", is_staff=True)
3807+
self.client.force_authenticate(user=self.user)
3808+
self.project, self.deployment = setup_test_project(reuse=False)
3809+
3810+
def test_api_rejects_invalid_time_zone(self):
3811+
url = f"/api/v2/deployments/{self.deployment.pk}/"
3812+
response = self.client.patch(url, {"time_zone": "Fake/Zone"}, format="json")
3813+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
3814+
3815+
def test_api_accepts_valid_time_zone(self):
3816+
url = f"/api/v2/deployments/{self.deployment.pk}/"
3817+
response = self.client.patch(url, {"time_zone": "Europe/Berlin"}, format="json")
3818+
self.assertEqual(response.status_code, status.HTTP_200_OK)
3819+
self.deployment.refresh_from_db()
3820+
self.assertEqual(self.deployment.time_zone, "Europe/Berlin")
3821+
3822+
def test_api_list_includes_time_zone(self):
3823+
url = f"/api/v2/deployments/?project_id={self.project.pk}"
3824+
response = self.client.get(url)
3825+
self.assertEqual(response.status_code, status.HTTP_200_OK)
3826+
results = response.data["results"]
3827+
row = next((r for r in results if r["id"] == self.deployment.pk), None)
3828+
self.assertIsNotNone(row)
3829+
self.assertEqual(row["time_zone"], "America/New_York")
3830+
3831+
def test_api_detail_includes_time_zone(self):
3832+
url = f"/api/v2/deployments/{self.deployment.pk}/"
3833+
response = self.client.get(url)
3834+
self.assertEqual(response.status_code, status.HTTP_200_OK)
3835+
self.assertIn("time_zone", response.data)
3836+
self.assertEqual(response.data["time_zone"], "America/New_York")

0 commit comments

Comments
 (0)