diff --git a/src/documents/conditionals.py b/src/documents/conditionals.py index fa10ff58a..82114e8d6 100644 --- a/src/documents/conditionals.py +++ b/src/documents/conditionals.py @@ -117,6 +117,17 @@ def preview_last_modified(request, pk: int) -> datetime | None: return doc.modified +def thumbnail_etag(request: Any, pk: int) -> str | None: + """ + Thumbnails are version-dependent, so use the effective document checksum as + the ETag to invalidate cache when the latest version changes. + """ + doc = resolve_effective_document_by_pk(pk, request).document + if doc is None: + return None + return doc.checksum + + def thumbnail_last_modified(request: Any, pk: int) -> datetime | None: """ Returns the filesystem last modified either from cache or from filesystem. diff --git a/src/documents/tests/test_api_document_versions.py b/src/documents/tests/test_api_document_versions.py index 848c6ec21..a81b9d545 100644 --- a/src/documents/tests/test_api_document_versions.py +++ b/src/documents/tests/test_api_document_versions.py @@ -464,6 +464,40 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase): self.assertEqual(resp.status_code, status.HTTP_200_OK) self.assertEqual(read_streaming_response(resp), b"thumb") + def test_thumb_etag_changes_when_latest_version_is_deleted(self) -> None: + root = self._create_pdf(title="root", checksum="root") + v1 = self._create_pdf( + title="v1", + checksum="v1", + root_document=root, + ) + v2 = self._create_pdf( + title="v2", + checksum="v2", + root_document=root, + ) + self._write_file(v1.thumbnail_path, b"thumb-v1") + self._write_file(v2.thumbnail_path, b"thumb-v2") + + resp = self.client.get(f"/api/documents/{root.id}/thumb/") + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(read_streaming_response(resp), b"thumb-v2") + self.assertEqual(resp.headers["ETag"], '"v2"') + + with mock.patch("documents.search.get_backend"): + delete_resp = self.client.delete( + f"/api/documents/{root.id}/versions/{v2.id}/", + ) + self.assertEqual(delete_resp.status_code, status.HTTP_200_OK) + + resp = self.client.get( + f"/api/documents/{root.id}/thumb/", + HTTP_IF_NONE_MATCH='"v2"', + ) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.headers["ETag"], '"v1"') + self.assertEqual(read_streaming_response(resp), b"thumb-v1") + def test_metadata_version_param_uses_version(self) -> None: root = Document.objects.create( title="root", diff --git a/src/documents/tests/test_version_conditionals.py b/src/documents/tests/test_version_conditionals.py index fd24a2a51..81218ed4c 100644 --- a/src/documents/tests/test_version_conditionals.py +++ b/src/documents/tests/test_version_conditionals.py @@ -5,6 +5,7 @@ from django.test import TestCase from documents.conditionals import metadata_etag from documents.conditionals import preview_etag +from documents.conditionals import thumbnail_etag from documents.conditionals import thumbnail_last_modified from documents.models import Document from documents.tests.utils import DirectoriesMixin @@ -30,6 +31,7 @@ class TestConditionals(DirectoriesMixin, TestCase): self.assertEqual(metadata_etag(request, root.id), latest.checksum) self.assertEqual(preview_etag(request, root.id), latest.archive_checksum) + self.assertEqual(thumbnail_etag(request, root.id), latest.checksum) def test_resolve_effective_doc_returns_none_for_invalid_or_unrelated_version( self, diff --git a/src/documents/views.py b/src/documents/views.py index fde9a9106..1ff11f617 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -67,7 +67,6 @@ from django.views import View from django.views.decorators.cache import cache_control from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.http import condition -from django.views.decorators.http import last_modified from django.views.generic import TemplateView from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.openapi import AutoSchema @@ -124,6 +123,7 @@ from documents.conditionals import preview_etag from documents.conditionals import preview_last_modified from documents.conditionals import suggestions_etag from documents.conditionals import suggestions_last_modified +from documents.conditionals import thumbnail_etag from documents.conditionals import thumbnail_last_modified from documents.data_models import ConsumableDocument from documents.data_models import DocumentMetadataOverrides @@ -1564,7 +1564,12 @@ class DocumentViewSet( @action(methods=["get"], detail=True, filter_backends=[]) @method_decorator(cache_control(no_cache=True)) - @method_decorator(last_modified(thumbnail_last_modified)) + @method_decorator( + condition( + etag_func=thumbnail_etag, + last_modified_func=thumbnail_last_modified, + ), + ) def thumb(self, request, pk=None): resolved = self._resolve_request_and_root_doc(pk, request) if isinstance(resolved, HttpResponseForbidden):