Skip to content

Commit ceea5db

Browse files
authored
Merge branch 'main' into feat/taxa-tags
2 parents 826a3c1 + 634cbe0 commit ceea5db

29 files changed

+696
-356
lines changed

ami/main/api/views.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,7 @@ class SourceImageCollectionViewSet(DefaultViewSet, ProjectMixin):
646646
]
647647
filterset_fields = ["method"]
648648
ordering_fields = [
649+
"id",
649650
"created_at",
650651
"updated_at",
651652
"name",
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Generated by Django 4.2.10 on 2025-07-08 16:51
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("main", "0059_alter_project_options"),
9+
]
10+
11+
operations = [
12+
migrations.AlterField(
13+
model_name="sourceimagecollection",
14+
name="method",
15+
field=models.CharField(
16+
choices=[
17+
("common_combined", "common_combined"),
18+
("random", "random"),
19+
("stratified_random", "stratified_random"),
20+
("interval", "interval"),
21+
("manual", "manual"),
22+
("starred", "starred"),
23+
("random_from_each_event", "random_from_each_event"),
24+
("last_and_random_from_each_event", "last_and_random_from_each_event"),
25+
("greatest_file_size_from_each_event", "greatest_file_size_from_each_event"),
26+
("detections_only", "detections_only"),
27+
("full", "full"),
28+
],
29+
default="common_combined",
30+
max_length=255,
31+
),
32+
),
33+
]

ami/main/models.py

Lines changed: 141 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,9 @@ def sync_captures(self, batch_size=1000, regroup_events_per_batch=False, job: "J
612612
job.progress.add_stage("Update deployment cache")
613613
job.update_progress()
614614

615+
# Regroup source images if needed
616+
if deployment_event_needs_update(deployment):
617+
group_images_into_events(deployment)
615618
self.save()
616619
self.update_calculated_fields(save=True)
617620

@@ -1093,6 +1096,23 @@ def group_images_into_events(
10931096
return events
10941097

10951098

1099+
def deployment_event_needs_update(deployment: Deployment) -> bool:
1100+
"""
1101+
Returns True if there are any SourceImages in the deployment
1102+
that haven't been assigned to an `Event`.
1103+
1104+
Note: This does not detect if images were deleted from the deployment
1105+
after being grouped. We currently have limited support for image deletion,
1106+
so handling that is out of scope for this check.
1107+
"""
1108+
1109+
ungrouped_images_exist = SourceImage.objects.filter(deployment=deployment, event__isnull=True).exists()
1110+
1111+
logger.debug(f"Deployment {deployment.pk}: ungrouped images exist = {ungrouped_images_exist}")
1112+
1113+
return ungrouped_images_exist
1114+
1115+
10961116
def delete_empty_events(deployment: Deployment, dry_run=False):
10971117
"""
10981118
Delete events that have no images, occurrences or other related records.
@@ -3022,7 +3042,7 @@ def html(self) -> str:
30223042

30233043

30243044
_SOURCE_IMAGE_SAMPLING_METHODS = [
3025-
"common_combined",
3045+
"common_combined", # Deprecated
30263046
"random",
30273047
"stratified_random",
30283048
"interval",
@@ -3032,6 +3052,7 @@ def html(self) -> str:
30323052
"last_and_random_from_each_event",
30333053
"greatest_file_size_from_each_event",
30343054
"detections_only",
3055+
"full",
30353056
]
30363057

30373058

@@ -3157,7 +3178,16 @@ def taxa_count(self) -> int | None:
31573178
# This should always be pre-populated using queryset annotations
31583179
return None
31593180

3160-
def get_queryset(self):
3181+
def get_queryset(
3182+
self,
3183+
hour_start: int | None = None,
3184+
hour_end: int | None = None,
3185+
month_start: int | None = None,
3186+
month_end: int | None = None,
3187+
date_start: str | None = None,
3188+
date_end: str | None = None,
3189+
deployment_ids: list[int] | None = None,
3190+
):
31613191
return SourceImage.objects.filter(project=self.project)
31623192

31633193
@classmethod
@@ -3184,33 +3214,17 @@ def populate_sample(self, job: "Job | None" = None):
31843214
self.save()
31853215
task_logger.info(f"Done sampling and saving captures to {self}")
31863216

3187-
def sample_random(self, size: int = 100):
3188-
"""Create a random sample of source images"""
3189-
3190-
qs = self.get_queryset()
3191-
return qs.order_by("?")[:size]
3192-
3193-
def sample_manual(self, image_ids: list[int]):
3194-
"""Create a sample of source images based on a list of source image IDs"""
3195-
3196-
qs = self.get_queryset()
3197-
return qs.filter(id__in=image_ids)
3198-
3199-
def sample_common_combined(
3217+
def _filter_sample(
32003218
self,
3201-
minute_interval: int | None = None,
3202-
max_num: int | None = None,
3203-
shuffle: bool = True, # This is applicable if max_num is set and minute_interval is not set
3219+
qs: models.QuerySet,
32043220
hour_start: int | None = None,
32053221
hour_end: int | None = None,
32063222
month_start: int | None = None,
32073223
month_end: int | None = None,
32083224
date_start: str | None = None,
32093225
date_end: str | None = None,
32103226
deployment_ids: list[int] | None = None,
3211-
) -> models.QuerySet | typing.Generator[SourceImage, None, None]:
3212-
qs = self.get_queryset()
3213-
3227+
):
32143228
if deployment_ids is not None:
32153229
qs = qs.filter(deployment__in=deployment_ids)
32163230
if date_start is not None:
@@ -3235,6 +3249,66 @@ def sample_common_combined(
32353249
elif hour_end is not None:
32363250
qs = qs.filter(timestamp__hour__lte=hour_end)
32373251

3252+
return qs
3253+
3254+
def sample_random(
3255+
self,
3256+
size: int = 100,
3257+
hour_start: int | None = None,
3258+
hour_end: int | None = None,
3259+
month_start: int | None = None,
3260+
month_end: int | None = None,
3261+
date_start: str | None = None,
3262+
date_end: str | None = None,
3263+
deployment_ids: list[int] | None = None,
3264+
):
3265+
"""Create a random sample of source images"""
3266+
3267+
qs = self.get_queryset()
3268+
qs = self._filter_sample(
3269+
qs=qs,
3270+
hour_start=hour_start,
3271+
hour_end=hour_end,
3272+
month_start=month_start,
3273+
month_end=month_end,
3274+
date_start=date_start,
3275+
date_end=date_end,
3276+
deployment_ids=deployment_ids,
3277+
)
3278+
return qs.order_by("?")[:size]
3279+
3280+
def sample_manual(self, image_ids: list[int]):
3281+
"""Create a sample of source images based on a list of source image IDs"""
3282+
3283+
qs = self.get_queryset()
3284+
return qs.filter(id__in=image_ids)
3285+
3286+
# Deprecated
3287+
def sample_common_combined(
3288+
self,
3289+
minute_interval: int | None = None,
3290+
max_num: int | None = None,
3291+
shuffle: bool = True, # This is applicable if max_num is set and minute_interval is not set
3292+
hour_start: int | None = None,
3293+
hour_end: int | None = None,
3294+
month_start: int | None = None,
3295+
month_end: int | None = None,
3296+
date_start: str | None = None,
3297+
date_end: str | None = None,
3298+
deployment_ids: list[int] | None = None,
3299+
) -> models.QuerySet | typing.Generator[SourceImage, None, None]:
3300+
qs = self.get_queryset()
3301+
qs = self._filter_sample(
3302+
qs=qs,
3303+
hour_start=hour_start,
3304+
hour_end=hour_end,
3305+
month_start=month_start,
3306+
month_end=month_end,
3307+
date_start=date_start,
3308+
date_end=date_end,
3309+
deployment_ids=deployment_ids,
3310+
)
3311+
32383312
if minute_interval is not None:
32393313
# @TODO can this be done in the database and return a queryset?
32403314
# this currently returns a list of source images
@@ -3250,11 +3324,31 @@ def sample_common_combined(
32503324
return qs
32513325

32523326
def sample_interval(
3253-
self, minute_interval: int = 10, exclude_events: list[int] = [], deployment_id: int | None = None
3327+
self,
3328+
minute_interval: int = 10,
3329+
exclude_events: list[int] = [],
3330+
deployment_id: int | None = None, # Deprecated
3331+
hour_start: int | None = None,
3332+
hour_end: int | None = None,
3333+
month_start: int | None = None,
3334+
month_end: int | None = None,
3335+
date_start: str | None = None,
3336+
date_end: str | None = None,
3337+
deployment_ids: list[int] | None = None,
32543338
):
32553339
"""Create a sample of source images based on a time interval"""
32563340

32573341
qs = self.get_queryset()
3342+
qs = self._filter_sample(
3343+
qs=qs,
3344+
hour_start=hour_start,
3345+
hour_end=hour_end,
3346+
month_start=month_start,
3347+
month_end=month_end,
3348+
date_start=date_start,
3349+
date_end=date_end,
3350+
deployment_ids=deployment_ids,
3351+
)
32583352
if deployment_id:
32593353
qs = qs.filter(deployment=deployment_id)
32603354
if exclude_events:
@@ -3314,6 +3408,31 @@ def sample_detections_only(self):
33143408
qs = self.get_queryset()
33153409
return qs.filter(detections__isnull=False).distinct()
33163410

3411+
def sample_full(
3412+
self,
3413+
hour_start: int | None = None,
3414+
hour_end: int | None = None,
3415+
month_start: int | None = None,
3416+
month_end: int | None = None,
3417+
date_start: str | None = None,
3418+
date_end: str | None = None,
3419+
deployment_ids: list[int] | None = None,
3420+
):
3421+
"""Sample all source images"""
3422+
3423+
qs = self.get_queryset()
3424+
qs = self._filter_sample(
3425+
qs=qs,
3426+
hour_start=hour_start,
3427+
hour_end=hour_end,
3428+
month_start=month_start,
3429+
month_end=month_end,
3430+
date_start=date_start,
3431+
date_end=date_end,
3432+
deployment_ids=deployment_ids,
3433+
)
3434+
return qs.all().distinct()
3435+
33173436
@classmethod
33183437
def get_or_create_starred_collection(cls, project: Project) -> "SourceImageCollection":
33193438
"""

ami/main/tests.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
)
3131
from ami.ml.models.pipeline import Pipeline
3232
from ami.tests.fixtures.main import create_captures, create_occurrences, create_taxa, setup_test_project
33+
from ami.tests.fixtures.storage import populate_bucket
3334
from ami.users.models import User
3435
from ami.users.roles import BasicMember, Identifier, ProjectManager
3536

@@ -1572,3 +1573,56 @@ def test_project_manager_permissions_(self):
15721573
self._test_sourceimageupload_permissions(
15731574
user=self.project_manager, permission_map=self.PERMISSIONS_MAPS["project_manager"]["sourceimageupload"]
15741575
)
1576+
1577+
1578+
class TestDeploymentSyncCreatesEvents(TestCase):
1579+
def test_sync_creates_events_and_updates_counts(self):
1580+
# Set up a new project and deployment with test data
1581+
project, deployment = setup_test_project(reuse=False)
1582+
1583+
# Populate the object store with image data
1584+
assert deployment.data_source is not None
1585+
populate_bucket(
1586+
config=deployment.data_source.config,
1587+
subdir=f"deployment_{deployment.pk}",
1588+
skip_existing=False,
1589+
)
1590+
1591+
# Sync captures
1592+
deployment.sync_captures()
1593+
1594+
# Refresh and check results
1595+
deployment.refresh_from_db()
1596+
initial_events = Event.objects.filter(deployment=deployment)
1597+
initial_events_count = initial_events.count()
1598+
1599+
# Assertions
1600+
self.assertTrue(initial_events.exists(), "Expected events to be created")
1601+
self.assertEqual(
1602+
deployment.events_count, initial_events.count(), "Deployment events_count should match actual events"
1603+
)
1604+
# Simulate new images added to object store
1605+
populate_bucket(
1606+
config=deployment.data_source.config,
1607+
subdir=f"deployment_{deployment.pk}",
1608+
skip_existing=False,
1609+
num_nights=2,
1610+
images_per_day=5,
1611+
minutes_interval=120,
1612+
)
1613+
1614+
# Sync again
1615+
deployment.sync_captures()
1616+
deployment.refresh_from_db()
1617+
updated_events = Event.objects.filter(deployment=deployment)
1618+
1619+
# Assertions for second sync
1620+
self.assertGreater(
1621+
updated_events.count(), initial_events_count, "New events should be created after adding new images"
1622+
)
1623+
self.assertEqual(
1624+
deployment.events_count,
1625+
updated_events.count(),
1626+
"Deployment events_count should reflect updated event count",
1627+
)
1628+
logger.info(f"Initial events count: {initial_events_count}, Updated events count: {updated_events.count()}")

0 commit comments

Comments
 (0)