Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ami/main/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ class DeploymentAdmin(admin.ModelAdmin[Deployment]):
list_display = (
"name",
"project",
"time_zone",
"data_source_uri",
"captures_count",
"captures_size",
Expand All @@ -142,6 +143,7 @@ class DeploymentAdmin(admin.ModelAdmin[Deployment]):
search_fields = (
"id",
"name",
"time_zone",
)

def start_date(self, obj) -> str | None:
Expand Down
3 changes: 3 additions & 0 deletions ami/main/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ class Meta:
"updated_at",
"latitude",
"longitude",
"time_zone",
"first_date",
"last_date",
"device",
Expand Down Expand Up @@ -235,6 +236,7 @@ class Meta:
"id",
"name",
"details",
"time_zone",
]


Expand All @@ -248,6 +250,7 @@ class Meta:
"details",
"latitude",
"longitude",
"time_zone",
"events_count",
# "captures_count",
# "detections_count",
Expand Down
23 changes: 23 additions & 0 deletions ami/main/migrations/0083_deployment_time_zone.py
Original file line number Diff line number Diff line change
@@ -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],
),
),
]
23 changes: 23 additions & 0 deletions ami/main/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import time
import typing
import urllib.parse
import zoneinfo
from io import BytesIO
from typing import Final, final # noqa: F401

Expand Down Expand Up @@ -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):
"""
Expand All @@ -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")
Expand Down
90 changes: 90 additions & 0 deletions ami/main/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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")