Skip to content
12 changes: 12 additions & 0 deletions ami/main/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
Site,
SourceImage,
SourceImageCollection,
SourceImageThumbnail,
Tag,
TaxaList,
Taxon,
Expand Down Expand Up @@ -293,6 +294,17 @@ def get_queryset(self, request: HttpRequest) -> QuerySet[Any]:
)


@admin.register(SourceImageThumbnail)
class SourceImageThumbnailAdmin(AdminBase):
"""Admin panel for ``SourceImageThumbnail`` model."""

list_display = ("source_image", "path", "label", "width", "height", "size")
list_filter = ("source_image__deployment__project", "source_image__deployment__data_source", "label")

def get_queryset(self, request: HttpRequest) -> QuerySet[Any]:
return super().get_queryset(request).select_related("source_image", "source_image__deployment")


class ClassificationInline(admin.TabularInline):
model = Classification
extra = 0
Expand Down
15 changes: 14 additions & 1 deletion ami/main/api/serializers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime

from django.conf import settings
from django.db.models import QuerySet
from guardian.shortcuts import get_perms
from rest_framework import serializers
Expand Down Expand Up @@ -1097,6 +1098,18 @@ class SourceImageListSerializer(DefaultSerializer):
event = EventNestedSerializer(read_only=True)
project = serializers.PrimaryKeyRelatedField(queryset=Project.objects.all(), required=False)
# file = serializers.ImageField(allow_empty_file=False, use_url=True)
thumbnails = serializers.SerializerMethodField()

def get_thumbnails(self, obj: SourceImage) -> dict | None:
return {
label: reverse_with_params(
"sourceimagethumbnail-detail",
args=(obj.pk,),
request=self.context.get("request"),
params={"label": label},
)
for label in settings.THUMBNAILS["SIZES"]
}

class Meta:
model = SourceImage
Expand All @@ -1107,7 +1120,6 @@ class Meta:
"event",
"url",
"path",
# "thumbnail",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Funny that this thumbnail field has been hanging out empty all along! Glad to retire it

"timestamp",
"width",
"height",
Expand All @@ -1118,6 +1130,7 @@ class Meta:
"taxa_count",
"detections",
"project",
"thumbnails",
Comment thread
loppear marked this conversation as resolved.
]


Expand Down
83 changes: 82 additions & 1 deletion ami/main/api/views.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import datetime
import io
import logging
from statistics import mode

from django.conf import settings
from django.contrib.postgres.search import TrigramSimilarity
from django.core import exceptions
from django.core.files.storage import default_storage
from django.db import models
from django.db.models import Prefetch, Q
from django.db.models.functions import Coalesce
from django.db.models.query import QuerySet
from django.forms import BooleanField, CharField, IntegerField
from django.shortcuts import get_object_or_404
from django.shortcuts import get_object_or_404, redirect
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.types import OpenApiTypes
Expand All @@ -24,6 +27,7 @@
from rest_framework.response import Response
from rest_framework.views import APIView

import ami.utils.s3
from ami.base.filters import NullsLastOrderingFilter, ThresholdFilter
from ami.base.models import BaseQuerySet
from ami.base.pagination import LimitOffsetPaginationWithPermissions
Expand Down Expand Up @@ -52,6 +56,7 @@
Site,
SourceImage,
SourceImageCollection,
SourceImageThumbnail,
SourceImageUpload,
Tag,
TaxaList,
Expand Down Expand Up @@ -709,6 +714,82 @@ def unstar(self, _request, pk=None) -> Response:
raise api_exceptions.ValidationError(detail="Source image must be associated with a project")


class SourceImageThumbnailViewSet(DefaultReadOnlyViewSet, ProjectMixin):
"""
Endpoint for capture thumbnails
"""

queryset = SourceImage.objects.all()

permission_classes = [ObjectPermission]

def list(self, request):
raise api_exceptions.NotFound(detail=f"No collection of thumbnails")

def retrieve(self, request, pk=None):
_sizes = settings.THUMBNAILS["SIZES"]
_prefix = settings.THUMBNAILS["STORAGE_PREFIX"]

label = self.request.query_params.get("label", next(iter(_sizes)))
size = _sizes.get(label, None)
Comment thread
loppear marked this conversation as resolved.
if size is None:
raise api_exceptions.ValidationError(
detail=f"Invalid thumbnail size label provided: {label} not in {', '.join(_sizes.keys())}"
)
obj: SourceImage = self.get_object()
try:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm tempted to say we don't need the SourceImageThumbnail model and can rely on the exists() check in the storage. But then I suppose we have to parse the height/width from the file name, and make sure we have a way to create those consistently. So I could go either way! This will be a big table eventually.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming files are on S3, I don't think we'll want to do filesystem name checks. Table size will be commensurate to SourceImage, could be pruned for age if that becomes an issue, though would lose the "we still have thumbnails if source files are removed" benefit. We don't join to the table just per-image lookups - should probably add a (source_image, label) index.

thumb = obj.thumbnails.get(label=label)
except SourceImageThumbnail.DoesNotExist:
thumb = None
if (
not thumb
or thumb.width != size["width"]
or thumb.last_modified < obj.last_modified
or not default_storage.exists(thumb.path)
):
if obj.path and obj.deployment and obj.deployment.data_source:
config = obj.deployment.data_source.config
# Get the file
try:
img = ami.utils.s3.read_image(config=config, key=obj.path)
Comment thread
mihow marked this conversation as resolved.
Outdated
except ami.utils.s3.botocore.exceptions.ClientError as e:
logger.error(f"Could not read image for {obj.path}: {e}")
raise api_exceptions.NotFound(detail=f"SourceImage with id {obj.id} media not found")
Comment thread
loppear marked this conversation as resolved.
Outdated
else:
# Make the thumbnail
orig_width, orig_height = img.size
width = size["width"]
height = size.get("height", None)
if not height:
height = int(orig_height * (width / float(orig_width)))
new_size = (width, height)
img.thumbnail(new_size)

buffer = io.BytesIO()
img.save(buffer, format="JPEG")
contents = buffer.getvalue()
file_size = len(contents)

# Write to storage
buffer.seek(0)
thumbnail_key = f"{_prefix}capture_{obj.id}/{label}.jpg"
thumbnail_path = default_storage.save(thumbnail_key, buffer)

# Save to DB
width, height = img.size
# Remove prior thumbnails for this size
for t in obj.thumbnails.filter(label=label):
default_storage.delete(t.path)
t.delete()
thumb = obj.thumbnails.create(
path=thumbnail_path, label=label, width=width, height=height, size=file_size
)
else:
raise api_exceptions.NotFound(detail=f"SourceImage with id {obj.id} media config not found")

return redirect(default_storage.url(thumb.path), permanent=True)


class SourceImageCollectionViewSet(DefaultViewSet, ProjectMixin):
"""
Endpoint for viewing capture sets or samples of captures.
Expand Down
39 changes: 39 additions & 0 deletions ami/main/migrations/0085_sourceimagethumbnail.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Generated by Django 4.2.10 on 2026-05-13 10:17

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):
dependencies = [
("main", "0084_revoke_delete_job_from_roles"),
]

operations = [
migrations.CreateModel(
name="SourceImageThumbnail",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("path", models.CharField(blank=True, max_length=255)),
("label", models.CharField(blank=True, max_length=255, null=True)),
("width", models.IntegerField(blank=True, null=True)),
("height", models.IntegerField(blank=True, null=True)),
("size", models.BigIntegerField(blank=True, null=True)),
("last_modified", models.DateTimeField(blank=True, null=True, auto_now_add=True)),
(
"source_image",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="thumbnails",
to="main.sourceimage",
),
),
Comment on lines +25 to +33
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

SET_NULL on source_image will leave orphaned thumbnail rows and storage objects when a SourceImage is deleted.

A thumbnail is meaningless without its SourceImage, and the viewset only resolves thumbnails through a SourceImage pk — orphaned rows (with source_image=NULL) become unreachable while still occupying DB and the underlying default_storage blob. Prefer CASCADE so deletes propagate, and pair it with cleanup of the stored file (e.g. a pre_delete signal on SourceImageThumbnail that calls default_storage.delete(instance.path)).

🐛 Proposed fix
                 (
                     "source_image",
                     models.ForeignKey(
-                        null=True,
-                        on_delete=django.db.models.deletion.SET_NULL,
+                        on_delete=django.db.models.deletion.CASCADE,
                         related_name="thumbnails",
                         to="main.sourceimage",
                     ),
                 ),

Note: this requires removing null=True from the corresponding field on the SourceImageThumbnail model and likely a follow-up migration if any existing rows have source_image_id IS NULL. Storage-side cleanup on delete is a separate concern and won’t be handled by changing on_delete alone.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
(
"source_image",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="thumbnails",
to="main.sourceimage",
),
),
(
"source_image",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="thumbnails",
to="main.sourceimage",
),
),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ami/main/migrations/0085_sourceimagethumbnail.py` around lines 25 - 33,
Change the ForeignKey on SourceImageThumbnail named source_image to use
on_delete=django.db.models.deletion.CASCADE and remove null=True from the field
definition in the SourceImageThumbnail model so thumbnails are deleted with
their SourceImage; add a pre_delete signal (or model delete override) for
SourceImageThumbnail that calls
django.core.files.storage.default_storage.delete(instance.path) to remove the
file from storage when a thumbnail is deleted; ensure you create a follow-up
migration to update existing rows that have source_image_id IS NULL (or
delete/associate them) before removing nullability.

],
options={
"abstract": False,
},
),
]
14 changes: 14 additions & 0 deletions ami/main/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2392,6 +2392,20 @@ def sample_captures_by_nth(
yield from qs[::nth]


@final
class SourceImageThumbnail(BaseModel):
"""A thumbnail cache of a SourceImage"""

path = models.CharField(max_length=255, blank=True)
label = models.CharField(max_length=255, blank=True, null=True)
width = models.IntegerField(null=True, blank=True)
height = models.IntegerField(null=True, blank=True)
size = models.BigIntegerField(null=True, blank=True)
last_modified = models.DateTimeField(null=True, blank=True, auto_now_add=True)

source_image = models.ForeignKey(SourceImage, on_delete=models.SET_NULL, null=True, related_name="thumbnails")

Comment thread
loppear marked this conversation as resolved.

# @final
# class IdentificationHistory(BaseModel):
# """A history of identifications for an occurrence."""
Expand Down
106 changes: 105 additions & 1 deletion ami/main/tests.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import copy
import datetime
import logging
import typing
from io import BytesIO

from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.files.uploadedfile import SimpleUploadedFile
from django.db import connection, models
Expand Down Expand Up @@ -38,7 +40,13 @@
from ami.ml.models.pipeline import Pipeline
from ami.ml.models.processing_service import ProcessingService
from ami.ml.models.project_pipeline_config import ProjectPipelineConfig
from ami.tests.fixtures.main import create_captures, create_occurrences, create_taxa, setup_test_project
from ami.tests.fixtures.main import (
create_captures,
create_captures_from_files,
create_occurrences,
create_taxa,
setup_test_project,
)
from ami.tests.fixtures.storage import populate_bucket
from ami.users.models import User
from ami.users.roles import BasicMember, Identifier, MLDataManager, ProjectManager, create_roles_for_project
Expand Down Expand Up @@ -210,6 +218,102 @@ def test_existing_processing_service_new_project(self):
)


NEW_THUMBNAIL_SETTINGS = copy.deepcopy(settings.THUMBNAILS)
NEW_THUMBNAIL_SETTINGS["SIZES"]["small"]["width"] = 300


class TestImageThumbnailViews(TestCase):
base_url = "http://testserver/api/v2/captures/thumbnails/"

def setUp(self) -> None:
self.project, self.deployment = setup_test_project()

self.captures = create_captures_from_files(deployment=self.deployment)
self.first_capture = self.captures[0][0]

return super().setUp()

def test_thumbnail_no_list(self):
response = self.client.get(f"/api/v2/captures/thumbnails/")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove the no-op f-string to fix Ruff F541.

Line 237 uses an f-string without placeholders, which triggers Ruff F541 and can fail CI.

🔧 Proposed fix
-        response = self.client.get(f"/api/v2/captures/thumbnails/")
+        response = self.client.get("/api/v2/captures/thumbnails/")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
response = self.client.get(f"/api/v2/captures/thumbnails/")
response = self.client.get("/api/v2/captures/thumbnails/")
🧰 Tools
🪛 Ruff (0.15.12)

[error] 237-237: f-string without any placeholders

Remove extraneous f prefix

(F541)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ami/main/tests.py` at line 237, The test uses a no-op f-string in the GET
request (response = self.client.get(f"/api/v2/captures/thumbnails/")) which
triggers Ruff F541; change it to a plain string by removing the leading f
(response = self.client.get("/api/v2/captures/thumbnails/")) to eliminate the
warning and keep behavior identical.

self.assertEqual(response.status_code, 404)

def test_thumbnail_new(self):
response = self.client.get(f"/api/v2/captures/thumbnails/{self.first_capture.pk}/")
self.assertEqual(response.status_code, 301)
thumb = self.first_capture.thumbnails.get(label="small")
self.assertEqual(thumb.width, 240)
self.assertEqual(thumb.height, 180)
self.assertEqual(response.headers["Location"], f"/media/{thumb.path}")

def test_thumbnail_new_with_size(self):
response = self.client.get(f"/api/v2/captures/thumbnails/{self.first_capture.pk}/?label=medium")
self.assertEqual(response.status_code, 301)
thumb = self.first_capture.thumbnails.get(label="medium")
self.assertEqual(thumb.width, 1024)
self.assertEqual(thumb.height, 768)
self.assertEqual(response.headers["Location"], f"/media/{thumb.path}")

def test_thumbnail_new_with_invalid_size(self):
response = self.client.get(f"/api/v2/captures/thumbnails/{self.first_capture.pk}/?label=typo")
self.assertEqual(response.status_code, 400)

def test_thumbnail_exists(self):
response = self.client.get(f"/api/v2/captures/thumbnails/{self.first_capture.pk}/")
self.assertEqual(response.status_code, 301)
t_id = self.first_capture.thumbnails.first().pk
response = self.client.get(f"/api/v2/captures/thumbnails/{self.first_capture.pk}/")
self.assertEqual(self.first_capture.thumbnails.count(), 1)
self.assertEqual(self.first_capture.thumbnails.first().pk, t_id)
thumb = self.first_capture.thumbnails.get(label="small")
self.assertEqual(response.status_code, 301)
self.assertEqual(response.headers["Location"], f"/media/{thumb.path}")

def test_thumbnail_exists_newer_modified_source(self):
response = self.client.get(f"/api/v2/captures/thumbnails/{self.first_capture.pk}/")
self.assertEqual(response.status_code, 301)
thumb = self.first_capture.thumbnails.first()
self.first_capture.last_modified = datetime.datetime.now()
self.first_capture.save()
self.assertTrue(self.first_capture.last_modified > thumb.last_modified)
response = self.client.get(f"/api/v2/captures/thumbnails/{self.first_capture.pk}/")
self.assertEqual(self.first_capture.thumbnails.count(), 1)
self.assertNotEqual(self.first_capture.thumbnails.first().pk, thumb.pk)
thumb = self.first_capture.thumbnails.get(label="small")
self.assertEqual(response.status_code, 301)
self.assertEqual(response.headers["Location"], f"/media/{thumb.path}")

@override_settings(THUMBNAILS=NEW_THUMBNAIL_SETTINGS)
def test_thumbnail_settings_change_regenerates(self):
# A pre-existing different size thumb
self.first_capture.thumbnails.create(path="thumbs/test", label="small", width=240, height=180, size=0)
response = self.client.get(f"/api/v2/captures/thumbnails/{self.first_capture.pk}/")
self.assertEqual(self.first_capture.thumbnails.count(), 1)
thumb = self.first_capture.thumbnails.get(label="small")
self.assertEqual(thumb.width, 300)
self.assertEqual(response.status_code, 301)
self.assertEqual(response.headers["Location"], f"/media/{thumb.path}")
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def test_captures_response_includes_thumbnail_urls(self):
response = self.client.get(f"/api/v2/captures/{self.first_capture.pk}/?project_id={self.project.pk}")
self.assertEqual(response.status_code, 200)
rec = response.json()
self.assertIn("thumbnails", rec)
self.assertURLEqual(rec["thumbnails"]["small"], f"{self.base_url}{self.first_capture.pk}/?label=small")
self.assertURLEqual(rec["thumbnails"]["medium"], f"{self.base_url}{self.first_capture.pk}/?label=medium")

def test_captures_list_response_includes_thumbnail_urls(self):
response = self.client.get(f"/api/v2/captures/?project_id={self.project.pk}")
self.assertEqual(response.status_code, 200)
capture_json = response.json()["results"][0]
self.assertIn("thumbnails", capture_json)
self.assertURLEqual(
capture_json["thumbnails"]["small"], f"{self.base_url}{self.first_capture.pk}/?label=small"
)
self.assertURLEqual(
capture_json["thumbnails"]["medium"], f"{self.base_url}{self.first_capture.pk}/?label=medium"
)


class TestImageGrouping(TestCase):
def setUp(self) -> None:
print(f"Currently active database: {connection.settings_dict}")
Expand Down
1 change: 1 addition & 0 deletions config/api_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
router.register(r"deployments", views.DeploymentViewSet)
router.register(r"events", views.EventViewSet)
router.register(r"exports", export_views.ExportViewSet)
router.register(r"captures/thumbnails", views.SourceImageThumbnailViewSet, basename="sourceimagethumbnail")
router.register(r"captures/collections", views.SourceImageCollectionViewSet)
router.register(r"captures/upload", views.SourceImageUploadViewSet)
router.register(r"captures", views.SourceImageViewSet)
Expand Down
4 changes: 4 additions & 0 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -587,3 +587,7 @@ def _celery_result_backend_url(redis_url):
# preserves existing behavior; deployments seeing contention can set to False
# until the append-only ``JobLog`` child table (PR #1259) is in place.
JOB_LOG_PERSIST_ENABLED = env.bool("JOB_LOG_PERSIST_ENABLED", default=True) # type: ignore[no-untyped-call]


# Sizes for Source Image Thumbnails
THUMBNAILS = {"STORAGE_PREFIX": "thumbnails/", "SIZES": {"small": {"width": 240}, "medium": {"width": 1024}}}
Comment thread
mihow marked this conversation as resolved.
Loading