From 0a364bb4c7669962dabe2a688b29478136d4a004 Mon Sep 17 00:00:00 2001 From: Wietze Date: Mon, 8 Dec 2025 19:55:31 +0000 Subject: [PATCH] feat: add deployment time zone metadata --- ami/main/api/serializers.py | 3 ++ .../migrations/0079_deployment_time_zone.py | 21 ++++++++++ ami/main/models.py | 19 +++++++++ ami/main/tests.py | 39 +++++++++++++++++++ 4 files changed, 82 insertions(+) create mode 100644 ami/main/migrations/0079_deployment_time_zone.py diff --git a/ami/main/api/serializers.py b/ami/main/api/serializers.py index 1c5b7a126..609aec86e 100644 --- a/ami/main/api/serializers.py +++ b/ami/main/api/serializers.py @@ -185,6 +185,7 @@ class Meta: "updated_at", "latitude", "longitude", + "time_zone", "first_date", "last_date", "device", @@ -234,6 +235,7 @@ class Meta: "id", "name", "details", + "time_zone", ] @@ -247,6 +249,7 @@ class Meta: "details", "latitude", "longitude", + "time_zone", "events_count", # "captures_count", # "detections_count", diff --git a/ami/main/migrations/0079_deployment_time_zone.py b/ami/main/migrations/0079_deployment_time_zone.py new file mode 100644 index 000000000..4231d9011 --- /dev/null +++ b/ami/main/migrations/0079_deployment_time_zone.py @@ -0,0 +1,21 @@ +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("main", "0078_classification_applied_to"), + ] + + operations = [ + migrations.AddField( + model_name="deployment", + name="time_zone", + field=models.CharField( + default=settings.TIME_ZONE, + help_text="IANA time zone for this deployment (e.g., 'America/Los_Angeles'). Used as metadata for interpreting local timestamps.", + max_length=64, + ), + ), + ] diff --git a/ami/main/models.py b/ami/main/models.py index 515f5286a..896d702ee 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -8,6 +8,7 @@ import urllib.parse from io import BytesIO from typing import Final, final # noqa: F401 +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError import PIL.Image import pydantic @@ -56,6 +57,15 @@ _POST_TITLE_MAX_LENGTH: Final = 80 +def validate_iana_time_zone(value: str) -> None: + if not value: + return + try: + ZoneInfo(value) + except ZoneInfoNotFoundError as exc: + raise ValidationError(f"Invalid IANA time zone: {value!r}.") from exc + + class TaxonRank(OrderedEnum): KINGDOM = "KINGDOM" PHYLUM = "PHYLUM" @@ -606,6 +616,15 @@ class Deployment(BaseModel): latitude = models.FloatField(null=True, blank=True) longitude = models.FloatField(null=True, blank=True) image = models.ImageField(upload_to="deployments", blank=True, null=True) + time_zone = models.CharField( + max_length=64, + default=settings.TIME_ZONE, + help_text=( + "IANA time zone for this deployment (e.g., 'America/Los_Angeles'). " + "Used as metadata for interpreting local timestamps." + ), + validators=[validate_iana_time_zone], + ) 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 a6be324f4..11f059298 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -4,17 +4,25 @@ 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 from guardian.shortcuts import assign_perm, get_perms, remove_perm from PIL import Image from rest_framework import status +from rest_framework import serializers from rest_framework.test import APIRequestFactory, APITestCase from rich import print from ami.exports.models import DataExport from ami.jobs.models import VALID_JOB_TYPES, Job +from ami.main.api.serializers import ( + DeploymentListSerializer, + DeploymentNestedSerializer, + DeploymentNestedSerializerWithLocationAndCounts, + DeploymentSerializer, +) from ami.main.models import ( Classification, Deployment, @@ -46,6 +54,37 @@ logger = logging.getLogger(__name__) +class TestTimeZoneNormalization(TestCase): + def test_deployment_invalid_time_zone_raises(self): + project = Project.objects.create(name="TZ Project", create_defaults=False) + serializer = DeploymentSerializer( + data={"name": "D1", "project_id": project.pk, "time_zone": "Mars/Phobos"}, + context={"request": APIRequestFactory().post("/")}, + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("time_zone", serializer.errors) + + def test_deployment_serializers_expose_time_zone(self): + project = Project.objects.create(name="TZ Project", create_defaults=False) + deployment = Deployment.objects.create(project=project, name="D1", time_zone="UTC") + + for serializer_cls in ( + DeploymentListSerializer, + DeploymentNestedSerializer, + DeploymentNestedSerializerWithLocationAndCounts, + DeploymentSerializer, + ): + self.assertIn("time_zone", serializer_cls.Meta.fields) + + class DeploymentTimeZoneOnlySerializer(serializers.ModelSerializer): + class Meta: + model = Deployment + fields = ("time_zone",) + + data = DeploymentTimeZoneOnlySerializer(deployment).data + self.assertEqual(data["time_zone"], "UTC") + + class TestProjectSetup(TestCase): def test_project_creation(self): project = Project.objects.create(name="New Project with Defaults", create_defaults=True)