Skip to content

Disable thumbnailing in private/draft projects for the time being#1340

Merged
mihow merged 5 commits into
mainfrom
fix/thumbnail-draft-projects
Jun 18, 2026
Merged

Disable thumbnailing in private/draft projects for the time being#1340
mihow merged 5 commits into
mainfrom
fix/thumbnail-draft-projects

Conversation

@mihow

@mihow mihow commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

Images in draft (non-public) projects have been rendering broken since the server-side thumbnail layer (#1306) shipped. This restores them.

The thumbnail endpoint is authentication-gated, but the frontend loads thumbnails through an anonymous <img> tag, which cannot send an Authorization header. For a draft project the anonymous request is denied (401), so every capture image appears broken. Public projects were unaffected, because anonymous read passes their permission check.

The fix is deliberately small and safe: for draft projects the API no longer hands back thumbnail-endpoint URLs, so the frontend falls back to the capture's source URL (capture.url) — exactly how images were served before the thumbnail layer, bypassing the auth-gated endpoint. Public projects keep server-generated thumbnails and their performance benefit.

(Note: project visibility is independent of storage. Whether the source URL is presigned depends on the capture's bucket, not on whether the project is draft — a draft project may live on a public bucket and a public project on a private one. The determinant here is the draft flag / anonymous-retrieve permission, not bucket type.)

This is an interim fix. The longer-term plan (self-authenticating signed-token thumbnail URLs, or writing thumbnails to each project's own object store) lets draft/private projects benefit from thumbnails too; it is tracked in #1341. We chose not to simply open the endpoint to anonymous access, because generated thumbnails are written to public default storage where they would be world-readable and enumerable by capture id — a privacy regression for restricted imagery.

List of Changes

# Change (user-facing effect) How (implementation)
1 Draft-project capture images render again instead of showing broken thumbnails. New Project.thumbnails_enabled property (= not draft) names the policy on the model; SourceImageThumbnailSerializer.get_thumbnails returns None when it is false, and the frontend already falls back to the source URL when no thumbnails are present (ui/.../capture.ts).
2 No new per-row database load from the draft check on capture lists. The captures list queryset select_related("project"), so the serializer reads obj.project.thumbnails_enabled straight off the main JOIN — one query for the whole page, no per-instance memoization.
3 Regression coverage for the bug class that slipped through. TestThumbnailDraftProjectVisibility: public project returns thumbnail URLs; draft project omits them (and still returns the source url for fallback); and a query-capture test proves the list path does no standalone per-row project fetch (the select_related guarantee). The existing thumbnail tests only covered a public project, which is why this regressed silently.

Detailed Description

Root cause, empirical confirmation, the <img>-cannot-authenticate constraint, and the longer-term options are tracked in #1341. Context also posted on #1306.

Follow-up to #1306; interacts with #1331 (direct thumbnail URLs), which does not on its own fix draft projects. The durable fix is tracked in #1341.

Draft (non-public) projects stopped displaying capture images after the
server-side thumbnail layer (PR #1306) shipped. The thumbnail endpoint is
authentication-gated, but the frontend loads thumbnails via an anonymous
<img> tag, which cannot send an Authorization header. For a draft project the
anonymous request is denied (401) and the image renders broken; public
projects were unaffected because anonymous read passes their permission check.

Interim fix: SourceImageThumbnailSerializer.get_thumbnails returns None for
captures in draft projects, so the frontend falls back to the capture's
presigned source URL (the pre-thumbnail-layer behavior, which authenticates
via its own signature and stays private). Public projects keep server
thumbnails. The draft lookup is memoized per project on the serializer
instance to avoid an N+1, since the captures queryset does not select_related
the SourceImage.project FK.

A signed-token or per-project-storage thumbnail URL is the longer-term fix so
draft projects also benefit from thumbnails; this change just unbreaks them
without exposing any imagery in public default storage.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 17, 2026 23:31
@netlify

netlify Bot commented Jun 17, 2026

Copy link
Copy Markdown

Deploy Preview for antenna-ssec canceled.

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

@netlify

netlify Bot commented Jun 17, 2026

Copy link
Copy Markdown

Deploy Preview for antenna-preview canceled.

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

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds Project.thumbnails_enabled (returns not self.draft) and uses it as an early-return guard in SourceImageThumbnailSerializer.get_thumbnails, which returns None for draft projects. SourceImageViewSet.get_queryset adds project to select_related to prevent N+1 queries. Tests and a planning document are included.

Changes

Thumbnail gating for draft/private projects

Layer / File(s) Summary
Project.thumbnails_enabled property
ami/main/models.py
Adds a boolean property returning not self.draft, with a docstring explaining the anonymous-<img>/401 behavior for restricted projects.
Serializer guard and queryset prefetch
ami/main/api/serializers.py, ami/main/api/views.py
get_thumbnails returns None when thumbnails_enabled is false; SourceImageViewSet adds project to select_related so the property is read without extra queries.
Visibility and N+1 regression tests
ami/main/tests.py
TestThumbnailDraftProjectVisibility asserts draft captures return thumbnails: null and public captures return size keys; a CaptureQueriesContext test guards against per-row main_project lookups.
Root-cause and planning doc
docs/claude/planning/2026-06-17-thumbnail-auth-private-projects.md
Documents the incident, confirmed root cause, why PR #1331 is insufficient, a privacy risk from direct unsigned warm-thumbnail URLs, and proposed long-term fix directions.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related issues

Possibly related PRs

  • RolnickLab/antenna#1306: Introduced the thumbnails field and URL plumbing in SourceImageThumbnailSerializer that this PR now gates on thumbnails_enabled.

Suggested reviewers

  • annavik

Poem

🐇 Hoppity-hop, the draft is a lock,
No thumbnail links when the project's blocked.
select_related keeps queries lean and bright,
The presigned URL shines as fallback light.
One property guards what the <img> can't fetch —
A tidy little fix without too much stretch! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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
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.
Description check ✅ Passed The pull request description is comprehensive and well-structured, following the template with all major sections completed.
Title check ✅ Passed The title accurately summarizes the main change: disabling thumbnail generation for draft/private projects to work around broken image rendering caused by auth-gated thumbnail endpoints.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/thumbnail-draft-projects

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.

Copilot AI left a comment

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.

Pull request overview

Restores capture image rendering for draft (non-public) projects by preventing the API from returning auth-gated thumbnail endpoint URLs for those projects, allowing the frontend to fall back to the capture’s presigned source URL (as before the thumbnail layer).

Changes:

  • Omit thumbnails URLs in capture serialization when the capture’s project is draft=True (public projects unchanged).
  • Add per-serializer memoization for the draft-project check to avoid per-capture DB queries.
  • Add regression tests and a planning write-up documenting root cause and longer-term options.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
docs/claude/planning/2026-06-17-thumbnail-auth-private-projects.md Adds decision record / root-cause analysis for the thumbnail auth issue on restricted projects.
ami/main/tests.py Adds regression tests for draft vs public thumbnail serialization and a query-count guard.
ami/main/api/serializers.py Omits thumbnail endpoint URLs for draft projects with a memoized draft lookup.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread ami/main/tests.py Outdated
Comment thread ami/main/tests.py Outdated
Comment thread ami/main/api/serializers.py Outdated
@mihow mihow changed the title Restore draft-project image rendering broken by the thumbnail layer Temporary fix for rendering thumbnails in private/draft projects Jun 17, 2026
- Make the N+1 guard reliable: disable query caching and clear the cache so
  the captured query count reflects real DB hits, assert the draft lookup runs
  exactly once across a multi-capture response, and match the existence probe
  case-insensitively without quoted identifiers (not tied to one DB backend).
- Call super().setUp() before authenticating the test client.
- Reword the serializer comment to state what the code enforces (omitting
  thumbnail URLs so the UI falls back to the presigned source URL); note that
  endpoint-level enforcement and per-project thumbnail storage are follow-ups.

Co-Authored-By: Claude <noreply@anthropic.com>
@mihow

mihow commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review - safe to merge?

@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Get the PR diff
gh pr view 1340 --repo RolnickLab/antenna --json files,additions,deletions,changedFiles
gh pr diff 1340 --repo RolnickLab/antenna

Length of output: 18633


🏁 Script executed:

#!/bin/bash
# Look at the SourceImageThumbnailSerializer class and surrounding context in serializers.py
rg -n "SourceImageThumbnailSerializer|from ami|import Project|project_id|draft" ami/main/api/serializers.py | head -80

echo "=== Class definition context around get_thumbnails ==="
rg -n -B 40 "def get_thumbnails" ami/main/api/serializers.py | head -80

echo "=== Imports at top of serializers.py ==="
head -50 ami/main/api/serializers.py

echo "=== Project model draft field ==="
rg -n "draft" ami/main/models.py | grep -i "project\|draft" | head -20

echo "=== SourceImage.project_id field ==="
rg -n "project" ami/main/models.py | grep "SourceImage\|project_id\|ForeignKey.*Project" | head -20

echo "=== check test helpers used (setup_test_project, create_captures_from_files) ==="
rg -n "def setup_test_project|def create_captures_from_files" ami/main/tests.py | head -10

echo "=== existing TestSourceImageThumbnail context ==="
rg -n -A5 -B5 "class TestSourceImageThumbnail" ami/main/tests.py | head -30

echo "=== override_settings import in tests.py ==="
rg -n "override_settings|from django" ami/main/tests.py | head -20

Length of output: 9530


🏁 Script executed:

#!/bin/bash
echo "=== Find setup_test_project and create_captures_from_files definitions ==="
rg -rn "def setup_test_project|def create_captures_from_files" ami/

echo "=== APITestCase import in tests.py ==="
rg -n "APITestCase" ami/main/tests.py | head -10

echo "=== Check SourceImage.project FK on_delete and null ==="
rg -n -A3 "project = models.ForeignKey" ami/main/models.py | grep -A3 "captures\|SourceImage" | head -20

echo "=== Check subclasses that inherit SourceImageThumbnailSerializer ==="
rg -n "SourceImageThumbnailSerializer" ami/main/api/serializers.py

echo "=== Check if _draft_project_cache initialized anywhere ==="
rg -n "_draft_project_cache" ami/

echo "=== Check the captures list endpoint to understand project_id query param ==="
rg -n "project_id" ami/main/api/views.py | grep -i "filter\|param\|capture" | head -20

echo "=== Check existing thumbnail test class ==="
rg -n -A 5 "class TestSourceImageThumbnail" ami/main/tests.py | head -30

echo "=== Look at create_captures_from_files call in existing tests ==="
rg -n "create_captures_from_files\|setup_test_project" ami/main/tests.py | head -20

Length of output: 4101


🏁 Script executed:

#!/bin/bash
echo "=== Look at imports section of ami/main/tests.py ==="
head -30 ami/main/tests.py

echo "=== Search for setup_test_project anywhere in tests.py ==="
grep -n "setup_test_project\|create_captures_from_files" ami/main/tests.py

echo "=== What's in ami/tests/fixtures/main.py ==="
cat ami/tests/fixtures/main.py | head -80

echo "=== Check if there is a conftest or fixture import that brings these in ==="
rg -rn "create_captures_from_files\|setup_test_project" ami/main/tests.py ami/tests/

echo "=== Look at the full new test class as it appears in the file ==="
grep -n -A 80 "class TestThumbnailDraftProjectVisibility" ami/main/tests.py

Length of output: 10615



@mihow — yes, this is safe to merge. Here's my full review:


Review Summary: ✅ Safe to merge

Serializer fix (ami/main/api/serializers.py)

Check Result
Project already imported at module level
obj.project_id is the integer FK column (no extra query)
project_id is None guard handles SET_NULL FK correctly
Memoization via getattr/dict is correct and thread-safe for this lifecycle
All three subclasses inherit the fix: ExampleSourceImageNestedSerializer, SourceImageListSerializer, EventCaptureNestedSerializer
Public projects unaffected

Tests (ami/main/tests.py)

Check Result
setup_test_project / create_captures_from_files imported at lines 50 / 46
APITestCase imported from rest_framework.test
All three test scenarios covered (public→thumbnails, draft→null, memoization)
@override_settings(CACHALOT_ENABLED=False) correctly prevents cachalot from masking a real N+1
SQL pattern match uses .lower() — reasonably backend-agnostic

Two minor observations (non-blocking)

  1. _draft_project_cache not initialized in __init__: The lazy getattr pattern works correctly, but initializing self._draft_project_cache: dict[int, bool] = {} in SourceImageThumbnailSerializer.__init__ would make the intent more visible to future readers.

  2. Weak fallback URL assertion: self.assertTrue(rec["url"]) in test_draft_project_omits_thumbnail_urls only checks for truthiness. A stronger assertion like self.assertIn("http", rec["url"]) would be more explicit — but this is a cosmetic point.

Acknowledged known gap

The code comment correctly flags that an authenticated API caller can still trigger thumbnail generation for draft projects into public default storage. This is a deliberate, well-documented deferral — not a regression introduced here — and the planning doc maps out the right long-term fix directions. No action needed in this PR.

The fix is deliberate, minimal, well-commented, and comes with solid regression tests that fill the gap (#1306 only tested public projects). Good to go. 🐇

Rework of the interim draft-project thumbnail fix. Replaces the per-row
Project.objects.filter(...).exists() lookup inside the serializer (and the
per-instance memoization cache it required) with a Project.thumbnails_enabled
property and select_related("project") on the captures list queryset.

- Project.thumbnails_enabled (= not draft) names the policy as domain logic on
  the model, and is a proxy for "anonymous can retrieve captures"; revisit if
  visibility ever decouples from the draft flag.
- SourceImageThumbnailSerializer.get_thumbnails reads obj.project.thumbnails_enabled
  instead of issuing its own query, so the FK comes from the main JOIN and there
  is no N+1 and no memoization to maintain. ~17 lines of comment collapse to a
  short note pointing at the tracking issue.
- The N+1 regression test now asserts no standalone per-row project fetch occurs
  (the select_related guarantee), rather than that a custom cache ran once.
- Inline comment and planning doc now reference the dedicated tracking issue
  (#1341) for the real fix (self-authenticating / per-project-storage thumbnail
  URLs) instead of inlining the rationale.

Co-Authored-By: Claude <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (1)
docs/claude/planning/2026-06-17-thumbnail-auth-private-projects.md (1)

121-132: 💤 Low value

Escape the #1331 references.

Those inline issue references can be parsed as headings in Markdown, which breaks the intended prose rendering in this doc. Use backticks or escape the # so the text stays inline.

🤖 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 `@docs/claude/planning/2026-06-17-thumbnail-auth-private-projects.md` around
lines 121 - 132, The inline issue references `#1331` in the markdown document
are being parsed as Markdown headings, which breaks the prose rendering. To fix
this, escape or wrap all instances of the `#1331` references in the document by
either using backticks around them (like `#1331`) or by escaping the hash
character with a backslash (like \`#1331`). This will preserve the inline
formatting while preventing Markdown parsers from treating them as heading
syntax.

Source: Linters/SAST tools

🤖 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.

Nitpick comments:
In `@docs/claude/planning/2026-06-17-thumbnail-auth-private-projects.md`:
- Around line 121-132: The inline issue references `#1331` in the markdown
document are being parsed as Markdown headings, which breaks the prose
rendering. To fix this, escape or wrap all instances of the `#1331` references
in the document by either using backticks around them (like `#1331`) or by
escaping the hash character with a backslash (like \`#1331`). This will preserve
the inline formatting while preventing Markdown parsers from treating them as
heading syntax.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 906e36b1-92ea-498e-84e1-d08b8ae3d9e3

📥 Commits

Reviewing files that changed from the base of the PR and between 040eae0 and 2579f3f.

📒 Files selected for processing (5)
  • ami/main/api/serializers.py
  • ami/main/api/views.py
  • ami/main/models.py
  • ami/main/tests.py
  • docs/claude/planning/2026-06-17-thumbnail-auth-private-projects.md

The rationale (anonymous <img> can't authenticate the gated endpoint, interim
fix, #1341 tracking) already lives in Project.thumbnails_enabled. Leave only a
one-line pointer at the call site.

Co-Authored-By: Claude <noreply@anthropic.com>
@mihow mihow force-pushed the fix/thumbnail-draft-projects branch from efe7350 to 8baab3c Compare June 18, 2026 01:05
The policy is self-documenting via Project.thumbnails_enabled, so the call-site
comment is removed. Bucket type / presigned URLs are independent of project
visibility: a private (draft) project may live on a public bucket and a public
project on a private one. The fallback is just the capture's source URL
(capture.url), which is presigned only when its bucket is private — so the tests
no longer call it "presigned".

Co-Authored-By: Claude <noreply@anthropic.com>
@mihow mihow changed the title Temporary fix for rendering thumbnails in private/draft projects Disable thumbnailing in private/draft projects for the time being Jun 18, 2026
@mihow mihow merged commit 2f58ad1 into main Jun 18, 2026
7 checks passed
@mihow mihow deleted the fix/thumbnail-draft-projects branch June 18, 2026 01:27
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