From b2c7991f87a1d552ffb6a5dfa90a83e51b0c853a Mon Sep 17 00:00:00 2001 From: Wietze Date: Wed, 11 Mar 2026 19:08:24 -0400 Subject: [PATCH] feat: add per-deployment time zone metadata --- ami/main/admin.py | 2 + ami/main/api/serializers.py | 3 + .../migrations/0083_deployment_time_zone.py | 23 +++++ ami/main/models.py | 23 +++++ ami/main/tests.py | 90 +++++++++++++++++++ 5 files changed, 141 insertions(+) create mode 100644 ami/main/migrations/0083_deployment_time_zone.py diff --git a/ami/main/admin.py b/ami/main/admin.py index c6170b153..f9a488774 100644 --- a/ami/main/admin.py +++ b/ami/main/admin.py @@ -131,6 +131,7 @@ class DeploymentAdmin(admin.ModelAdmin[Deployment]): list_display = ( "name", "project", + "time_zone", "data_source_uri", "captures_count", "captures_size", @@ -142,6 +143,7 @@ class DeploymentAdmin(admin.ModelAdmin[Deployment]): search_fields = ( "id", "name", + "time_zone", ) def start_date(self, obj) -> str | None: diff --git a/ami/main/api/serializers.py b/ami/main/api/serializers.py index 6d0d93762..a6cc84a5f 100644 --- a/ami/main/api/serializers.py +++ b/ami/main/api/serializers.py @@ -186,6 +186,7 @@ class Meta: "updated_at", "latitude", "longitude", + "time_zone", "first_date", "last_date", "device", @@ -235,6 +236,7 @@ class Meta: "id", "name", "details", + "time_zone", ] @@ -248,6 +250,7 @@ class Meta: "details", "latitude", "longitude", + "time_zone", "events_count", # "captures_count", # "detections_count", diff --git a/ami/main/migrations/0083_deployment_time_zone.py b/ami/main/migrations/0083_deployment_time_zone.py new file mode 100644 index 000000000..50db330b8 --- /dev/null +++ b/ami/main/migrations/0083_deployment_time_zone.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.10 + +import ami.main.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0082_add_taxalist_permissions"), + ] + + operations = [ + migrations.AddField( + model_name="deployment", + name="time_zone", + field=models.CharField( + default="America/New_York", + help_text="IANA time zone identifier (e.g. 'America/New_York', 'Europe/London').", + max_length=63, + validators=[ami.main.models.validate_time_zone], + ), + ), + ] diff --git a/ami/main/models.py b/ami/main/models.py index 0bad68531..296731f7b 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -6,6 +6,7 @@ import time import typing import urllib.parse +import zoneinfo from io import BytesIO from typing import Final, final # noqa: F401 @@ -680,6 +681,22 @@ def _compare_totals_for_sync(deployment: "Deployment", total_files_found: int): ) +def validate_time_zone(value): + """Validate that value is a recognized IANA time zone identifier.""" + if not isinstance(value, str) or not value: + raise ValidationError("Time zone must be a non-empty string.") + cleaned = value.strip() + if cleaned != value: + raise ValidationError("Time zone must not contain leading or trailing whitespace.") + try: + zoneinfo.ZoneInfo(cleaned) + except (KeyError, zoneinfo.ZoneInfoNotFoundError) as exc: + raise ValidationError( + "%(value)s is not a valid IANA time zone.", + params={"value": value}, + ) from exc + + @final class Deployment(BaseModel): """ @@ -690,6 +707,12 @@ class Deployment(BaseModel): description = models.TextField(blank=True) latitude = models.FloatField(null=True, blank=True) longitude = models.FloatField(null=True, blank=True) + time_zone = models.CharField( + max_length=63, + default=settings.TIME_ZONE, + validators=[validate_time_zone], + help_text="IANA time zone identifier (e.g. 'America/New_York', 'Europe/London').", + ) image = models.ImageField(upload_to="deployments", blank=True, null=True) project = models.ForeignKey(Project, on_delete=models.SET_NULL, null=True, related_name="deployments") diff --git a/ami/main/tests.py b/ami/main/tests.py index f82148937..92e97aef3 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -4,6 +4,7 @@ from io import BytesIO from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import ValidationError from django.core.files.uploadedfile import SimpleUploadedFile from django.db import connection, models from django.test import TestCase, override_settings @@ -15,6 +16,11 @@ from ami.exports.models import DataExport from ami.jobs.models import VALID_JOB_TYPES, Job +from ami.main.api.serializers import ( + DeploymentListSerializer, + DeploymentNestedSerializer, + DeploymentNestedSerializerWithLocationAndCounts, +) from ami.main.models import ( Classification, Deployment, @@ -3744,3 +3750,87 @@ def test_list_pipelines_public_project_non_member(self): self.client.force_authenticate(user=non_member) response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) + + +class TestDeploymentTimeZone(TestCase): + """Tests for Deployment.time_zone field validation and serializer exposure.""" + + @classmethod + def setUpTestData(cls): + cls.project, cls.deployment = setup_test_project(reuse=False) + + def test_valid_iana_zones_accepted(self): + for tz in ["Europe/London", "Asia/Tokyo", "UTC", "US/Eastern", "Etc/GMT+5"]: + self.deployment.time_zone = tz + self.deployment.full_clean() + + def test_invalid_zone_rejected(self): + for bad in ["Fake/Zone", "Not_A_Zone", "123"]: + self.deployment.time_zone = bad + with self.assertRaises(ValidationError): + self.deployment.full_clean() + + def test_empty_string_rejected(self): + self.deployment.time_zone = "" + with self.assertRaises(ValidationError): + self.deployment.full_clean() + + def test_none_rejected(self): + self.deployment.time_zone = None + with self.assertRaises(ValidationError): + self.deployment.full_clean() + + def test_whitespace_padded_rejected(self): + self.deployment.time_zone = " UTC " + with self.assertRaises(ValidationError): + self.deployment.full_clean() + + def test_default_is_america_new_york(self): + d = Deployment(name="tz-default-test", project=self.project) + self.assertEqual(d.time_zone, "America/New_York") + + def test_list_serializer_includes_time_zone(self): + self.assertIn("time_zone", DeploymentListSerializer.Meta.fields) + + def test_nested_serializer_includes_time_zone(self): + self.assertIn("time_zone", DeploymentNestedSerializer.Meta.fields) + + def test_nested_with_location_serializer_includes_time_zone(self): + self.assertIn("time_zone", DeploymentNestedSerializerWithLocationAndCounts.Meta.fields) + + +class TestDeploymentTimeZoneAPI(APITestCase): + """Tests for Deployment.time_zone via the REST API.""" + + def setUp(self): + self.user = User.objects.create_superuser(email="tz-test@insectai.org", is_staff=True) + self.client.force_authenticate(user=self.user) + self.project, self.deployment = setup_test_project(reuse=False) + + def test_api_rejects_invalid_time_zone(self): + url = f"/api/v2/deployments/{self.deployment.pk}/" + response = self.client.patch(url, {"time_zone": "Fake/Zone"}, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_api_accepts_valid_time_zone(self): + url = f"/api/v2/deployments/{self.deployment.pk}/" + response = self.client.patch(url, {"time_zone": "Europe/Berlin"}, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.deployment.refresh_from_db() + self.assertEqual(self.deployment.time_zone, "Europe/Berlin") + + def test_api_list_includes_time_zone(self): + url = f"/api/v2/deployments/?project_id={self.project.pk}" + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + results = response.data["results"] + row = next((r for r in results if r["id"] == self.deployment.pk), None) + self.assertIsNotNone(row) + self.assertEqual(row["time_zone"], "America/New_York") + + def test_api_detail_includes_time_zone(self): + url = f"/api/v2/deployments/{self.deployment.pk}/" + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("time_zone", response.data) + self.assertEqual(response.data["time_zone"], "America/New_York")