Skip to content

Thumbnails API for SourceImages#1306

Open
loppear wants to merge 9 commits into
mainfrom
feat/thumbnail-source-images
Open

Thumbnails API for SourceImages#1306
loppear wants to merge 9 commits into
mainfrom
feat/thumbnail-source-images

Conversation

@loppear
Copy link
Copy Markdown
Collaborator

@loppear loppear commented May 14, 2026

Summary

Provide thumbnails for SourceImages, stored in default_storage, generated on image request and paths "cached" in new table SourceImageThumbnail.

List of Changes

  • Settings THUMBNAILS to define storage prefix and pre-defined sizes.
  • New model SourceImageThumbnail
  • New API read-only Viewset at /api/v2/captures/thumbnails/<source_image_id> (that only implements detail) for proxying image requests and perform thumbnailing
    • If a cached record for the requested size (requested by label, queried by dimension) does not exist, retrieves SourceImage from deployment datasource and resizes to save to default storage, returning a redirect to the media URL.
  • Modifies captures API responses to include a thumbnails property with urls keyed by thumbnail size label, e.g. .thumbnails.small.

Related Issues

Implements #236

Detailed Description

Thumbnails are not generated during capture requests, but delayed until frontend requests image, at which point we check if there is a valid thumbnail already or need to generate.

This does NOT implement front-end changes to use this thumbnails property of captures. Happy to include a first stab at that for the captures list gallery before this is accepted.

Other considerations implicated:

  • Monitoring default media storage usage, as this is the first larger use of it?
  • Monitoring response time and django worker availability in performing thumbnailing in-request.

How to Test the Changes

Adds tests (manage.py test -k thumbnail) for the new API view and modifications to serializing captures.

Deployment Notes

Adds database migration for new SourceImageThumbnail table

Checklist

  • [ x ] I have tested these changes appropriately.
  • [ x ] I have added and/or modified relevant tests.
  • [ x ] I updated relevant documentation or comments.
  • [ x ] I have verified that this PR follows the project's coding standards.
  • [ x ] Any dependent changes have already been merged to main.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added thumbnail generation for source images with configurable size options (small and medium).
    • Introduced a new thumbnail retrieval endpoint in the API for accessing generated thumbnails.
    • Thumbnails are automatically cached and intelligently regenerated when source images are updated or size configurations change.
  • Tests

    • Added comprehensive test coverage for thumbnail generation, retrieval, and caching functionality.

Review Change Stack

@netlify
Copy link
Copy Markdown

netlify Bot commented May 14, 2026

Deploy Preview for antenna-ssec canceled.

Name Link
🔨 Latest commit f94b5c8
🔍 Latest deploy log https://app.netlify.com/projects/antenna-ssec/deploys/6a0630a9b17d4e0008727782

@netlify
Copy link
Copy Markdown

netlify Bot commented May 14, 2026

Deploy Preview for antenna-preview canceled.

Name Link
🔨 Latest commit f94b5c8
🔍 Latest deploy log https://app.netlify.com/projects/antenna-preview/deploys/6a0630a8b17d4e000872777d

@loppear loppear requested a review from mihow May 14, 2026 16:41
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 14, 2026

Warning

Rate limit exceeded

@loppear has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 36 minutes and 7 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fe28e64e-2fbd-4710-aaeb-764a1994c670

📥 Commits

Reviewing files that changed from the base of the PR and between 1a150a2 and f94b5c8.

📒 Files selected for processing (1)
  • ami/main/api/views.py
📝 Walkthrough

Walkthrough

This change registers a single API route for thumbnail endpoints. The SourceImageThumbnailViewSet is wired into the DRF router at captures/thumbnails with an explicit basename for URL naming.

Changes

Thumbnail API Routing

Layer / File(s) Summary
Thumbnail endpoint registration
config/api_router.py
Registers SourceImageThumbnailViewSet at captures/thumbnails with basename="sourceimagethumbnail" to wire the thumbnail retrieval endpoint into the API.

Estimated code review effort

🎯 1 (Trivial) | ⏱️ ~2 minutes

Poem

A route finds its way,

Small yet precise today,

Thumbnails now served up with care! 🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.79% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Thumbnails API for SourceImages' accurately summarizes the main change: adding a new API for generating and serving thumbnails for SourceImage objects.
Description check ✅ Passed The description covers all required template sections: Summary, List of Changes, Related Issues, Detailed Description, How to Test, Deployment Notes, and Checklist with completed items.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/thumbnail-source-images

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@loppear loppear requested a review from annavik May 14, 2026 16:41
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (8)
ami/main/models.py (2)

2401-2401: ⚖️ Poor tradeoff

Consider CASCADE instead of SET_NULL for the source_image foreign key.

The current on_delete=models.SET_NULL with null=True will orphan thumbnail records when the parent SourceImage is deleted. Since thumbnails are derived artifacts with no value without their source image, on_delete=models.CASCADE would be more appropriate to automatically clean up thumbnails when their source is deleted.

♻️ Proposed fix
-    source_image = models.ForeignKey(SourceImage, on_delete=models.SET_NULL, null=True, related_name="thumbnails")
+    source_image = models.ForeignKey(SourceImage, on_delete=models.CASCADE, related_name="thumbnails")

Note: This would require a new migration and removing null=True may require a two-step migration if there are existing NULL records.

🤖 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/models.py` at line 2401, Change the Thumbnail model's source_image
ForeignKey to use on_delete=models.CASCADE instead of SET_NULL and remove
null=True (i.e., update the source_image field referencing SourceImage and the
related_name "thumbnails"); then create the Django migration(s) to apply this
change and, if there are existing NULL source_image rows, perform a two-step
migration (first backfill or delete orphaned thumbnails, then make the field
non-nullable) so the DB transition is safe.

2390-2402: ⚡ Quick win

Add unique constraint and index for (source_image, label).

The model allows duplicate thumbnail records for the same source_image and label combination. Consider adding a unique constraint to prevent duplicates and an index to optimize thumbnail lookups by the ViewSet.

♻️ Proposed fix
 `@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)
 
     source_image = models.ForeignKey(SourceImage, on_delete=models.SET_NULL, null=True, related_name="thumbnails")
+
+    class Meta:
+        constraints = [
+            models.UniqueConstraint(
+                fields=["source_image", "label"],
+                name="unique_source_image_thumbnail"
+            ),
+        ]
+        indexes = [
+            models.Index(fields=["source_image", "label"]),
+        ]
🤖 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/models.py` around lines 2390 - 2402, The SourceImageThumbnail model
allows duplicates for the (source_image, label) pair; add a database-level
uniqueness constraint and an index to speed lookups by that pair: update the
SourceImageThumbnail class to include a Meta with a
UniqueConstraint(fields=["source_image","label"], name="uniq_sourceimage_label")
and an Index(fields=["source_image","label"], name="idx_sourceimage_label"),
then create and run the Django migration so the DB enforces uniqueness and
provides the lookup index.
config/settings/base.py (1)

592-603: ⚡ Quick win

Consider adding explicit format and quality settings.

The configuration only specifies width for each size preset. Consider adding explicit settings for image format (JPEG/PNG/WebP) and quality/compression to make the thumbnail generation behavior more predictable and configurable.

💡 Example enhancement
 # Sizes for Source Image Thumbnails
 THUMBNAILS = {
     "STORAGE_PREFIX": "thumbnails/",
+    "FORMAT": "JPEG",  # or "WebP" for better compression
+    "QUALITY": 85,  # JPEG quality 1-100
     "SIZES": {
         "small": {
-            "width": 240
+            "width": 240,
+            # height calculated to preserve aspect ratio
         },
         "medium": {
-            "width": 1024
+            "width": 1024,
         }
     }
 }
🤖 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 `@config/settings/base.py` around lines 592 - 603, The THUMBNAILS config
currently only defines width for each preset (THUMBNAILS -> SIZES ->
"small"/"medium") which makes output format and compression unpredictable;
extend each preset to include explicit format (e.g., "format":
"jpeg"|"png"|"webp") and quality (e.g., "quality": 75) keys, and add top-level
defaults under THUMBNAILS (e.g., "DEFAULT_FORMAT" and "DEFAULT_QUALITY") so
thumbnail generation code can read format/quality from THUMBNAILS or fall back
to defaults; update any thumbnail generator (references: THUMBNAILS, SIZES,
"small", "medium") to use these new keys when producing images.
ami/main/tests.py (1)

226-226: 💤 Low value

Remove unnecessary f-string prefix.

The f-string on line 226 contains no placeholders and should use a regular string literal.

♻️ Proposed fix
     def test_thumbnail_no_list(self):
-        response = self.client.get(f"/api/v2/captures/thumbnails/")
+        response = self.client.get("/api/v2/captures/thumbnails/")
         self.assertEqual(response.status_code, 404)

As per coding guidelines: Ruff static analysis detected F541 (f-string without placeholders).

🤖 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 226, The test uses an unnecessary f-string when
calling self.client.get(f"/api/v2/captures/thumbnails/"); change it to a regular
string literal by removing the f-prefix so the call becomes
self.client.get("/api/v2/captures/thumbnails/") to eliminate the F541 warning
and satisfy Ruff static analysis.
ami/main/api/serializers.py (1)

1103-1112: ⚡ Quick win

Add error handling for THUMBNAILS settings access.

The get_thumbnails method accesses settings.THUMBNAILS["SIZES"] without checking if the setting exists. If THUMBNAILS is not configured (e.g., in a test environment or during migration), this will raise a KeyError during serialization, breaking the entire captures API response.

Consider adding a guard to return None or an empty dict if the setting is missing:

🛡️ Proposed fix
     def get_thumbnails(self, obj: SourceImage) -> dict | None:
+        if not hasattr(settings, 'THUMBNAILS') or 'SIZES' not in settings.THUMBNAILS:
+            return None
         return {
             label: reverse_with_params(
                 "sourceimagethumbnail-detail",
🤖 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/api/serializers.py` around lines 1103 - 1112, The get_thumbnails
serializer method currently assumes settings.THUMBNAILS["SIZES"] exists and will
raise if THUMBNAILS is missing; modify get_thumbnails to guard access to
settings.THUMBNAILS (and/or the "SIZES" key) and return None or an empty dict
when the config is absent, e.g., check for getattr(settings, "THUMBNAILS", None)
and/or use .get("SIZES") before iterating; keep the existing reverse_with_params
logic for when sizes are present so callers still receive the thumbnail URLs.
ami/main/api/views.py (3)

719-719: 💤 Low value

Clarify queryset model choice or update naming.

The queryset is SourceImage.objects.all() but the viewset is named SourceImageThumbnailViewSet and registered with basename sourceimagethumbnail. This means the URL PK parameter refers to a SourceImage ID (not a thumbnail ID), which may confuse API consumers who expect /thumbnails/<thumbnail_id>.

If this design is intentional (thumbnails accessed by source image ID + label), consider adding a docstring clarifying that the PK is a source image ID, or renaming the viewset to better reflect this pattern.

🤖 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/api/views.py` at line 719, The viewset SourceImageThumbnailViewSet
currently uses queryset = SourceImage.objects.all() while being registered under
basename "sourceimagethumbnail", which makes the URL PK refer to a SourceImage
ID rather than a thumbnail ID; either update the viewset name/registration to
reflect it operates on SourceImage (e.g., rename to
SourceImageThumbnailProxyViewSet or change basename to "sourceimage") or keep
the name and add a clear docstring on SourceImageThumbnailViewSet stating that
the PK is a SourceImage ID and that thumbnails are accessed by source image ID +
label; update any related API docs/registration to match the chosen naming so
consumers aren’t confused.

771-771: 💤 Low value

Verify deletion strategy for existing thumbnails.

Line 771 deletes all prior thumbnails matching the label before creating a new one. This prevents stale thumbnails when settings change (e.g., dimensions updated), but creates a small window where no thumbnail exists if the subsequent create fails. Consider whether this is acceptable or if the deletion should happen after successful creation.

🤖 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/api/views.py` at line 771, Current code deletes existing thumbnails
via obj.thumbnails.filter(label=label).delete() before creating a new one,
creating a window with no thumbnail if creation fails; change the flow to delete
only after a successful creation by first creating/saving the new thumbnail (the
block that constructs/saves the new thumbnail object where save() or create(...)
is called) and then calling
obj.thumbnails.filter(label=label).exclude(pk=new_thumb.pk).delete(), or wrap
creation + swap in a transaction (transaction.atomic) so deletion happens only
on success; ensure you reference obj.thumbnails.filter(label=label) and the new
thumbnail instance (new_thumb) when implementing this change.

727-727: 💤 Low value

Remove unnecessary f-string prefix.

The string has no placeholders, so the f prefix is redundant.

🔧 Suggested fix
-        raise api_exceptions.NotFound(detail=f"No collection of thumbnails")
+        raise api_exceptions.NotFound(detail="No collection of thumbnails")
🤖 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/api/views.py` at line 727, The raise statement uses an unnecessary
f-string; replace the f-prefixed string in the api_exceptions.NotFound call so
the detail argument is a normal string (change
api_exceptions.NotFound(detail=f"No collection of thumbnails") to
api_exceptions.NotFound(detail="No collection of thumbnails")), leaving the
exception type and detail parameter unchanged.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@ami/main/admin.py`:
- Around line 314-319: get_queryset is calling select_related with an invalid
path "deployment__data_source" which causes a FieldError because
SourceImageThumbnail has no direct deployment FK; change the select_related call
in get_queryset to traverse via source_image (e.g. use "source_image",
"source_image__deployment", and "source_image__deployment__data_source" or
combine into a single list without the incorrect "deployment__data_source") so
the relationships are resolved through source_image__deployment__data_source.

In `@ami/main/api/views.py`:
- Around line 759-766: The BytesIO buffer is left at EOF after img.save so
default_storage.save reads nothing; before calling default_storage.save (where
thumbnail_key and thumbnail_path are used) reset the buffer position to the
start (seek to 0) or pass the earlier captured contents instead so the saved
file contains the JPEG data; update the code around buffer, img.save and
default_storage.save to ensure buffer.seek(0) is called (or use contents) prior
to saving.
- Around line 746-748: Replace the unconventional except clause that references
ami.utils.s3.botocore.exceptions.ClientError with the standard import pattern:
import botocore.exceptions (or from botocore.exceptions import ClientError) at
the top of views.py, catch botocore.exceptions.ClientError (or ClientError) in
the except block that currently logs via logger.error for obj.path, and re-raise
the api_exceptions.NotFound using "raise ... from e" to preserve the original
exception context while keeping the existing logger.error and NotFound detail
message.

In `@ami/main/tests.py`:
- Around line 260-269: The test test_thumbnail_settings_change_regenerates
mutates settings.THUMBNAILS["SIZES"]["small"]["width"] directly which can
pollute other tests; change it to save the original value before mutation and
restore it after the test (either by restoring in tearDown of the TestCase that
contains test_thumbnail_settings_change_regenerates or by using a
try/finally/context-manager inside the test) so the original settings are always
reinstated; reference the test method name
test_thumbnail_settings_change_regenerates and the settings key
THUMBNAILS["SIZES"]["small"]["width"] when implementing the save/restore logic.

---

Nitpick comments:
In `@ami/main/api/serializers.py`:
- Around line 1103-1112: The get_thumbnails serializer method currently assumes
settings.THUMBNAILS["SIZES"] exists and will raise if THUMBNAILS is missing;
modify get_thumbnails to guard access to settings.THUMBNAILS (and/or the "SIZES"
key) and return None or an empty dict when the config is absent, e.g., check for
getattr(settings, "THUMBNAILS", None) and/or use .get("SIZES") before iterating;
keep the existing reverse_with_params logic for when sizes are present so
callers still receive the thumbnail URLs.

In `@ami/main/api/views.py`:
- Line 719: The viewset SourceImageThumbnailViewSet currently uses queryset =
SourceImage.objects.all() while being registered under basename
"sourceimagethumbnail", which makes the URL PK refer to a SourceImage ID rather
than a thumbnail ID; either update the viewset name/registration to reflect it
operates on SourceImage (e.g., rename to SourceImageThumbnailProxyViewSet or
change basename to "sourceimage") or keep the name and add a clear docstring on
SourceImageThumbnailViewSet stating that the PK is a SourceImage ID and that
thumbnails are accessed by source image ID + label; update any related API
docs/registration to match the chosen naming so consumers aren’t confused.
- Line 771: Current code deletes existing thumbnails via
obj.thumbnails.filter(label=label).delete() before creating a new one, creating
a window with no thumbnail if creation fails; change the flow to delete only
after a successful creation by first creating/saving the new thumbnail (the
block that constructs/saves the new thumbnail object where save() or create(...)
is called) and then calling
obj.thumbnails.filter(label=label).exclude(pk=new_thumb.pk).delete(), or wrap
creation + swap in a transaction (transaction.atomic) so deletion happens only
on success; ensure you reference obj.thumbnails.filter(label=label) and the new
thumbnail instance (new_thumb) when implementing this change.
- Line 727: The raise statement uses an unnecessary f-string; replace the
f-prefixed string in the api_exceptions.NotFound call so the detail argument is
a normal string (change api_exceptions.NotFound(detail=f"No collection of
thumbnails") to api_exceptions.NotFound(detail="No collection of thumbnails")),
leaving the exception type and detail parameter unchanged.

In `@ami/main/models.py`:
- Line 2401: Change the Thumbnail model's source_image ForeignKey to use
on_delete=models.CASCADE instead of SET_NULL and remove null=True (i.e., update
the source_image field referencing SourceImage and the related_name
"thumbnails"); then create the Django migration(s) to apply this change and, if
there are existing NULL source_image rows, perform a two-step migration (first
backfill or delete orphaned thumbnails, then make the field non-nullable) so the
DB transition is safe.
- Around line 2390-2402: The SourceImageThumbnail model allows duplicates for
the (source_image, label) pair; add a database-level uniqueness constraint and
an index to speed lookups by that pair: update the SourceImageThumbnail class to
include a Meta with a UniqueConstraint(fields=["source_image","label"],
name="uniq_sourceimage_label") and an Index(fields=["source_image","label"],
name="idx_sourceimage_label"), then create and run the Django migration so the
DB enforces uniqueness and provides the lookup index.

In `@ami/main/tests.py`:
- Line 226: The test uses an unnecessary f-string when calling
self.client.get(f"/api/v2/captures/thumbnails/"); change it to a regular string
literal by removing the f-prefix so the call becomes
self.client.get("/api/v2/captures/thumbnails/") to eliminate the F541 warning
and satisfy Ruff static analysis.

In `@config/settings/base.py`:
- Around line 592-603: The THUMBNAILS config currently only defines width for
each preset (THUMBNAILS -> SIZES -> "small"/"medium") which makes output format
and compression unpredictable; extend each preset to include explicit format
(e.g., "format": "jpeg"|"png"|"webp") and quality (e.g., "quality": 75) keys,
and add top-level defaults under THUMBNAILS (e.g., "DEFAULT_FORMAT" and
"DEFAULT_QUALITY") so thumbnail generation code can read format/quality from
THUMBNAILS or fall back to defaults; update any thumbnail generator (references:
THUMBNAILS, SIZES, "small", "medium") to use these new keys when producing
images.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4960fc79-aeb3-4154-9954-83da5c3b9892

📥 Commits

Reviewing files that changed from the base of the PR and between aeb57c1 and 8077c2a.

📒 Files selected for processing (9)
  • ami/main/admin.py
  • ami/main/api/serializers.py
  • ami/main/api/views.py
  • ami/main/migrations/0085_sourceimagethumbnail.py
  • ami/main/models.py
  • ami/main/tests.py
  • ami/utils/s3.py
  • config/api_router.py
  • config/settings/base.py
💤 Files with no reviewable changes (1)
  • ami/utils/s3.py

Comment thread ami/main/admin.py Outdated
Comment thread ami/main/api/views.py
Comment thread ami/main/api/views.py
Comment thread ami/main/tests.py Outdated
"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

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (1)
ami/main/tests.py (1)

238-238: 💤 Low value

Remove unnecessary f-string prefix.

The f-string has no placeholders.

📝 Suggested change
-        response = self.client.get(f"/api/v2/captures/thumbnails/")
+        response = self.client.get("/api/v2/captures/thumbnails/")
🤖 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 238, In tests.py replace the unnecessary f-string
used in the self.client.get call by removing the "f" prefix so the literal URL
string passed to self.client.get("/api/v2/captures/thumbnails/") is a normal
string; locate the call to self.client.get(f"/api/v2/captures/thumbnails/") (the
assignment to response) and change it to use a plain string.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@ami/main/api/views.py`:
- Around line 771-775: The thumbnail regeneration only deletes DB rows
(obj.thumbnails.filter(label=label).delete()) but not the underlying storage
blob; update the logic in the thumbnail creation flow (the block around
obj.thumbnails.filter(label=label).delete() and obj.thumbnails.create(...)) to
first iterate existing thumbnail instances for that label, call
default_storage.delete(existing.path) for each existing.path (checking
storage.exists if desired), then remove the DB rows and create the new thumbnail
via obj.thumbnails.create(path=thumbnail_path, ...). Ensure you use the same
identifiers: default_storage, obj.thumbnails.filter(label=label), existing.path,
and thumbnail_path so old media files are removed from storage when
regenerating.
- Around line 741-742: The thumbnail cache currently ignores source changes;
modify the thumbnail lookup in the view (where it checks thumb and
default_storage.exists(thumb.path) and in the similar block at the other
occurrence) to also compare the cached row's stored last_modified (or size)
against obj.last_modified (which is updated by sync) and treat the cache as a
miss if they differ; persist obj.last_modified into the thumbnail/cache model
when creating/updating a thumbnail so future requests can compare and invalidate
stale thumbs, and on mismatch either delete the stored thumbnail
(default_storage.delete(thumb.path)) or skip returning the redirect so a fresh
thumbnail is generated.
- Around line 753-758: The code assigns new_size only inside the "if not height"
branch causing an UnboundLocalError when size includes height; to fix it, always
build new_size: retrieve width = size["width"] and height = size.get("height",
None), compute height = int(orig_height * (width / float(orig_width))) only when
height is falsy, then set new_size = (width, height) outside/after that
conditional so img.thumbnail(new_size) always has a defined tuple (refer to
variables width, height, new_size, orig_width, orig_height and the img.thumbnail
call).
- Around line 723-724: _class attributes `_sizes` and `_prefix` are read at
import time and must be changed to per-request properties that read
settings.THUMBNAILS each access so runtime changes (e.g. via override_settings)
take effect; replace the static attributes `_sizes` and `_prefix` with `@property`
methods (or equivalent instance properties) that return
settings.THUMBNAILS["SIZES"] and settings.THUMBNAILS["STORAGE_PREFIX"]
respectively, update any references in the class (e.g., inside the thumbnail
generation methods) to use self._sizes and self._prefix so the view (tested by
test_thumbnail_settings_change_regenerates) reads fresh values on every request.

---

Nitpick comments:
In `@ami/main/tests.py`:
- Line 238: In tests.py replace the unnecessary f-string used in the
self.client.get call by removing the "f" prefix so the literal URL string passed
to self.client.get("/api/v2/captures/thumbnails/") is a normal string; locate
the call to self.client.get(f"/api/v2/captures/thumbnails/") (the assignment to
response) and change it to use a plain string.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 841ee2b6-dc36-4a0e-94fd-826bffbf030b

📥 Commits

Reviewing files that changed from the base of the PR and between 8077c2a and 04cb3b3.

📒 Files selected for processing (6)
  • ami/main/admin.py
  • ami/main/api/serializers.py
  • ami/main/api/views.py
  • ami/main/models.py
  • ami/main/tests.py
  • config/settings/base.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • config/settings/base.py
  • ami/main/api/serializers.py

Comment thread ami/main/api/views.py Outdated
Comment thread ami/main/api/views.py Outdated
Comment thread ami/main/api/views.py
Comment thread ami/main/api/views.py
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with 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.

Inline comments:
In `@ami/main/api/views.py`:
- Around line 740-744: Change the thumbnail cache lookup so it doesn't filter by
configured dimensions (obj.thumbnails.get(**size))—instead fetch the thumbnail
by its label (e.g. obj.thumbnails.get(label=label) or
filter(label=label).first()) and then treat a width/height mismatch as a cache
miss in the same check that uses thumb.last_modified and default_storage.exists;
specifically compare thumb.width (and thumb.height when height is explicitly
configured in the SIZES entry) against the requested size and proceed to
delete/recreate only when dimensions differ or when last_modified/exists
indicate staleness. Ensure you still handle SourceImageThumbnail.DoesNotExist
and preserve the existing delete/upload flow.

In `@ami/main/migrations/0085_sourceimagethumbnail.py`:
- Around line 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.

In `@ami/main/tests.py`:
- 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f36689e1-7ffe-40f8-9061-c2db396a8a2d

📥 Commits

Reviewing files that changed from the base of the PR and between 04cb3b3 and 1a150a2.

📒 Files selected for processing (7)
  • ami/main/admin.py
  • ami/main/api/serializers.py
  • ami/main/api/views.py
  • ami/main/migrations/0085_sourceimagethumbnail.py
  • ami/main/models.py
  • ami/main/tests.py
  • config/api_router.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • ami/main/admin.py
  • ami/main/models.py
  • ami/main/api/serializers.py

Comment thread ami/main/api/views.py Outdated
Comment on lines +25 to +33
(
"source_image",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="thumbnails",
to="main.sourceimage",
),
),
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.

Comment thread ami/main/tests.py
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.

Comment thread config/settings/base.py


# Sizes for Source Image Thumbnails
THUMBNAILS = {"STORAGE_PREFIX": "thumbnails/", "SIZES": {"small": {"width": 240}, "medium": {"width": 1024}}}
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.

My instinct is to namespace this, SOURCE_IMAGE_THUMBNAILS or CAPTURE_THUMBNAILS, but I actually we could use these sizes for other images, like the deployment images or device type thumbnails, which use the same image thumb UI component in the list views.

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.

Oh if you are fighting with the python formatter, we should make sure that vscode is picking up the right black settings for you, so you don't have to wait for the github tests to fail. Or are you using another IDE?

Comment thread ami/main/api/views.py
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.

Comment thread ami/main/api/views.py
config = obj.deployment.data_source.config
# Get the file
try:
img = ami.utils.s3.read_image(config=config, key=obj.path)
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.

Could we move the core thumbnailing logic to it's own home? And then import in the view? That would be helpful if we want to trigger it from a management command or a test, or another method.

"taxa_count",
"detections",
"project",
"thumbnails",
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 believe this will trigger the thumbnail creation for all captures in the list view response, before responding. You proposed an alternative method in one of our chats that I liked:

  • Return the thumbnail URLs in the list view before they exist, so the list view endpoint returns immediately
  • The thumbnail URLs look like images (/api/captures/21/thumbnail/small/project-18-capture-21-small.jpg) etc. and they have an image mimetype BUT they are actually dynamic Django routes.
  • When the frontend is ready to load each image, each image URL gets hit, that image is generated and redirected to the media URL in the storage (or bytes served directly with some passthrough server trick)

Then each image generation happens in it's own request, and only when it's truly demanded.

If you ignore my other comments, I think this is the most important one!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants