Skip to content
Open
Show file tree
Hide file tree
Changes from 60 commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
fff2f04
Model and API view for SourceImageThumbnail
loppear May 14, 2026
a2c26f6
Thumbnails: add field to captures serializer
loppear May 14, 2026
8077c2a
With internal prefix
loppear May 14, 2026
d11cfa3
black postcommit
loppear May 14, 2026
33f42df
black?
loppear May 14, 2026
04cb3b3
black settings
loppear May 14, 2026
e9abc52
Better handling for thumbnail invalidation and deletion.
loppear May 14, 2026
1a150a2
Merge remote-tracking branch 'origin/main' into feat/thumbnail-source…
loppear May 14, 2026
f94b5c8
Thumbnail lookup by label and check for changed settings width
loppear May 14, 2026
c7484ab
Move thumbnail generation to method on SourceImage
loppear May 15, 2026
182c035
Thumbnails: handle uploaded source images (without datasource), 302 r…
loppear May 15, 2026
a606ace
Merge remote-tracking branch 'origin/main' into feat/thumbnail-source…
loppear May 26, 2026
3e7739f
merge migrations
loppear May 26, 2026
36c14d6
UI capture data: add and use thumbnail property accessors
loppear May 26, 2026
ec4747b
Add thumbnails to api responses for events with captures
loppear May 26, 2026
9c3d349
revert UI capture.src property, only use thumbnails explicitly
loppear May 26, 2026
72dd63d
fix: prioritize API response size before natural size
annavik May 27, 2026
f18e374
Merge branch 'main' into feat/thumbnail-source-images
loppear May 28, 2026
a6b8cd1
Merge migrations
loppear May 28, 2026
b1ffd8d
Merge origin/main
loppear Jun 3, 2026
e9c302f
Rebase thumbnail migrations
loppear Jun 3, 2026
df98379
ami.utils.media.fetch_image_content for thumbnail image source
loppear Jun 3, 2026
4400a61
Merge branch 'main' into feat/thumbnail-source-images
mihow Jun 4, 2026
ecfec9b
fix(thumbnails): convert non-RGB source images to RGB before JPEG encode
mihow Jun 4, 2026
0c13f73
fix(thumbnails): handle SourceImage.last_modified=None on regen check
mihow Jun 4, 2026
138cfb5
fix(thumbnails): drop no-op f-strings; return 405 on list
mihow Jun 4, 2026
9fca069
fix(thumbnails): guard empty THUMBNAILS['SIZES'] config
mihow Jun 4, 2026
fff3ffa
fix(media): add finite default timeout to fetch_image_content
mihow Jun 4, 2026
6426c4d
fix(thumbnails): use upsert + drop pre-save delete-loop to fix concur…
mihow Jun 4, 2026
84f70ea
fix(thumbnails): CASCADE source_image FK and clean storage blob on ro…
mihow Jun 4, 2026
6903c58
chore: black formatting
mihow Jun 4, 2026
e1e422d
chore(ui): prettier strip trailing whitespace in capture.ts
mihow Jun 4, 2026
c3c70ea
docs(sourceimage): help_text for storage-derived fields; admin read-only
mihow Jun 4, 2026
25e5101
chore(migrations): collapse 0089 SET_NULL + 0090 CASCADE into single …
mihow Jun 4, 2026
22045e0
chore(migrations): drop over-defensive comment from collapsed 0089
mihow Jun 5, 2026
545873a
perf(thumbnails): serve direct storage URLs on warm cache; drop per-r…
mihow Jun 8, 2026
aeb46c0
fix(thumbnails): tolerate PIL aspect-ratio rounding in width predicate
mihow Jun 8, 2026
0d05fb4
Merge remote-tracking branch 'origin/main' into feat/thumbnail-source…
mihow Jun 9, 2026
209a1c7
Merge remote-tracking branch 'origin/feat/thumbnail-source-images' in…
mihow Jun 9, 2026
1b54cc8
refactor(thumbnails): push staleness into prefetch annotation; review…
mihow Jun 9, 2026
b52ab69
fix(ui): session list uses capture thumbnails instead of full-size or…
mihow Jun 9, 2026
576c93e
Merge remote-tracking branch 'origin/feat/thumbnail-source-images' in…
mihow Jun 9, 2026
287de34
Merge remote-tracking branch 'origin/main' into HEAD
mihow Jun 10, 2026
547e39d
fix(migrations): renumber thumbnail migrations after main merge
mihow Jun 10, 2026
d6e96ef
Merge branch 'feat/thumbnail-source-images' (migration renumber after…
mihow Jun 10, 2026
7899bec
fix(tests): use real taxon pk in role-permission test data
mihow Jun 10, 2026
88a65d6
Merge branch 'feat/thumbnail-source-images' (role-permission test dat…
mihow Jun 10, 2026
012efbc
fix(thumbnails): record requested spec width on thumbnail rows
mihow Jun 11, 2026
de5b21c
perf(thumbnails): trust the cached row without a storage HEAD per req…
mihow Jun 11, 2026
cb83de3
feat(thumbnails): encode thumbnails as progressive JPEGs
mihow Jun 11, 2026
de78202
perf(thumbnails): let browsers cache the thumbnail redirect
mihow Jun 11, 2026
ec5cb93
fix(thumbnails): regenerate when the cached row has no stored path
mihow Jun 11, 2026
4292ee4
Merge branch 'feat/thumbnail-source-images' (spec width, no storage H…
mihow Jun 11, 2026
56bc46a
refactor(thumbnails): drop width tolerance now that rows store the sp…
mihow Jun 11, 2026
ea4f0bd
test(thumbnails): drop progressive-JPEG encoding assertion
mihow Jun 11, 2026
61aa16c
refactor(thumbnails): trim review-narration comments to load-bearing …
mihow Jun 11, 2026
2afc0d7
Merge branch 'feat/thumbnail-source-images' (comment trim) into feat/…
mihow Jun 11, 2026
586ec18
refactor(thumbnails): trim docstrings and comments to load-bearing facts
mihow Jun 11, 2026
63d103f
refactor(thumbnails): trim remaining narration comments found in full…
mihow Jun 11, 2026
5b8d1d3
Merge branch 'feat/thumbnail-source-images' (comment trim round 2) in…
mihow Jun 11, 2026
15677a2
refactor(thumbnails): single-source the thumbnail validity check + en…
mihow Jun 16, 2026
28e8018
Merge remote-tracking branch 'origin/main' into feat/thumbnails-seria…
mihow Jun 16, 2026
f8a8e72
fix(thumbnails): fall back to route URLs when thumbnails unprefetched
mihow Jun 16, 2026
dd40bae
test(thumbnails): pin warm-list prefetch contract; prune redundant wi…
mihow Jun 16, 2026
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
16 changes: 16 additions & 0 deletions ami/main/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
Site,
SourceImage,
SourceImageCollection,
SourceImageThumbnail,
Tag,
TaxaList,
Taxon,
Expand Down Expand Up @@ -327,6 +328,10 @@ class SourceImageAdmin(AdminBase):
"path",
)

# Populated from the source file during sync/upload; read-only here so
# operators don't clobber them. The API stays writable for fixups.
readonly_fields = ("size", "last_modified", "checksum", "checksum_algorithm")

def get_queryset(self, request: HttpRequest) -> QuerySet[Any]:
return (
super()
Expand All @@ -336,6 +341,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
20 changes: 16 additions & 4 deletions ami/main/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,19 @@ class Meta:
]


class SourceImageThumbnailSerializer(DefaultSerializer):
"""Adds a ``thumbnails`` field via :meth:`SourceImage.thumbnail_urls`.
Viewsets must apply :meth:`SourceImageQuerySet.with_thumbnails`.
"""

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["thumbnails"] = serializers.SerializerMethodField()

def get_thumbnails(self, obj: SourceImage) -> dict[str, str]:
return obj.thumbnail_urls(request=self.context.get("request"))


class SourceImageNestedSerializer(DefaultSerializer):
event_id = serializers.PrimaryKeyRelatedField(source="event", read_only=True)

Expand All @@ -90,7 +103,7 @@ class Meta:
]


class ExampleSourceImageNestedSerializer(DefaultSerializer):
class ExampleSourceImageNestedSerializer(SourceImageThumbnailSerializer):
class Meta:
model = SourceImage
fields = [
Expand Down Expand Up @@ -1092,7 +1105,7 @@ class Meta:
]


class SourceImageListSerializer(DefaultSerializer):
class SourceImageListSerializer(SourceImageThumbnailSerializer):
detections_count = serializers.IntegerField(read_only=True)
detections = CaptureDetectionsSerializer(many=True, read_only=True, source="filtered_detections")
deployment = DeploymentNestedSerializer(read_only=True)
Expand All @@ -1111,7 +1124,6 @@ class Meta:
"event",
"url",
"path",
# "thumbnail",
"timestamp",
"width",
"height",
Expand Down Expand Up @@ -1459,7 +1471,7 @@ class Meta:
]


class EventCaptureNestedSerializer(DefaultSerializer):
class EventCaptureNestedSerializer(SourceImageThumbnailSerializer):
"""
Load the first capture for an event. Or @TODO a single capture from the URL params.
"""
Expand Down
62 changes: 53 additions & 9 deletions ami/main/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
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 OuterRef, Prefetch, Q, Subquery
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 Down Expand Up @@ -449,9 +451,10 @@ def get_queryset(self) -> QuerySet:
Prefetch(
"captures",
queryset=SourceImage.objects.order_by("-size").select_related(
"deployment",
"deployment__data_source",
)[:num_example_captures],
"deployment", "deployment__data_source"
)
# Required by SourceImage.thumbnail_urls in the nested serializer.
.with_thumbnails()[:num_example_captures],
to_attr="example_captures",
)
)
Expand Down Expand Up @@ -630,11 +633,11 @@ def get_queryset(self) -> QuerySet:
self.require_project = True
project = self.get_active_project()

queryset = queryset.select_related(
"event",
"deployment",
"deployment__data_source",
).order_by("timestamp")
queryset = (
queryset.select_related("event", "deployment", "deployment__data_source")
# Required by SourceImage.thumbnail_urls in SourceImageThumbnailSerializer.
.with_thumbnails().order_by("timestamp")
)

if self.action == "list":
# It's cumbersome to override the default list view, so customize the queryset here
Expand Down Expand Up @@ -851,6 +854,47 @@ 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):
# Only ``/captures/thumbnails/<pk>/?label=...`` is defined; listing has no
# meaning here (which capture's thumbnails?), so 405 rather than a fake 404.
raise api_exceptions.MethodNotAllowed(
method="GET", detail="Listing thumbnails is not supported; request a single capture's thumbnail by pk."
)

def retrieve(self, request, pk=None):
_sizes = settings.THUMBNAILS["SIZES"]
if not _sizes:
# Empty THUMBNAILS['SIZES'] is a misconfiguration — clear API error, not a 500.
raise api_exceptions.NotFound(detail="No thumbnail sizes are configured (settings.THUMBNAILS['SIZES']).")

label = self.request.query_params.get("label") or next(iter(_sizes))
size = _sizes.get(label, None)
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:
thumb = obj.find_or_generate_thumbnail_for_label(label)
except exceptions.ObjectDoesNotExist as e:
raise api_exceptions.NotFound(detail=f"{e}")
response = redirect(default_storage.url(thumb.path))
# Redirects aren't browser-cached by default. max-age stays well below the
# presigned-URL lifetime (AWS_QUERYSTRING_EXPIRE default 3600s) so a cached
# redirect never points at an expired signature.
response["Cache-Control"] = "private, max-age=300"
return response


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

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


class Migration(migrations.Migration):
dependencies = [
("main", "0090_session_time_gap_seconds_help_text"),
]

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(max_length=255)),
("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(auto_now_add=True, null=True)),
(
"source_image",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="thumbnails",
to="main.sourceimage",
),
),
],
options={
"indexes": [models.Index(fields=["source_image", "label"], name="main_source_source__b0d4cd_idx")],
},
),
migrations.AddConstraint(
model_name="sourceimagethumbnail",
constraint=models.UniqueConstraint(
fields=("source_image", "label"), name="unique_source_image_thumbnail_label"
),
),
]
47 changes: 47 additions & 0 deletions ami/main/migrations/0092_alter_sourceimage_checksum_and_more.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Generated by Django 4.2.10 on 2026-06-04 19:24

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("main", "0091_sourceimagethumbnail_and_more"),
]

operations = [
migrations.AlterField(
model_name="sourceimage",
name="checksum",
field=models.CharField(
blank=True,
help_text="Checksum of the image file, read from the source file in image storage.",
max_length=255,
null=True,
),
),
migrations.AlterField(
model_name="sourceimage",
name="checksum_algorithm",
field=models.CharField(
blank=True, help_text="Algorithm used for the checksum (e.g. MD5, SHA256).", max_length=255, null=True
),
),
migrations.AlterField(
model_name="sourceimage",
name="last_modified",
field=models.DateTimeField(
blank=True,
help_text="When the image file was last modified, read from the source file in image storage.",
null=True,
),
),
migrations.AlterField(
model_name="sourceimage",
name="size",
field=models.BigIntegerField(
blank=True,
help_text="Size of the image file in bytes, read from the source file in image storage.",
null=True,
),
),
]
Loading