diff --git a/config/settings/base.py b/config/settings/base.py index 7fe88c78..3596681e 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -592,6 +592,10 @@ OEMBED_CACHE_AGE = env.int("OEMBED_CACHE_AGE", default=300) CACHE_CONTROL_MAX_AGE = env.int("CACHE_CONTROL_MAX_AGE", default=300) +# CDN TTL for anonymous document 404s; 0 disables caching them +CACHE_CONTROL_NOT_FOUND_MAX_AGE = env.int( + "CACHE_CONTROL_NOT_FOUND_MAX_AGE", default=300 +) OEMBED_URL_REGEX = env("OEMBED_URL_REGEX", default="") diff --git a/documentcloud/addons/serializers.py b/documentcloud/addons/serializers.py index e46c6750..5676faf5 100644 --- a/documentcloud/addons/serializers.py +++ b/documentcloud/addons/serializers.py @@ -172,7 +172,10 @@ class Meta: } expandable_fields = { "addon": ("documentcloud.addons.AddOnSerializer", {}), - "event": ("documentcloud.addons.AddOnEventSerializer", {}), + "event": ( + "documentcloud.addons.AddOnEventSerializer", + {"omit": ["scratch"]}, + ), } def __init__(self, *args, **kwargs): @@ -244,7 +247,6 @@ class Meta: "user", "parameters", "event", - "parameters", "scratch", "created_at", "updated_at", @@ -262,5 +264,8 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) context = kwargs.get("context", {}) request = context.get("request") + view = self.context.get("view") if request and request.user: self.fields["addon"].queryset = AddOn.objects.get_viewable(request.user) + if view and view.action == "list": + self.fields.pop("scratch", None) diff --git a/documentcloud/addons/tests/test_views.py b/documentcloud/addons/tests/test_views.py index 5afac632..3b51f5ce 100644 --- a/documentcloud/addons/tests/test_views.py +++ b/documentcloud/addons/tests/test_views.py @@ -1,4 +1,6 @@ # Django +from django.db import connection +from django.test.utils import CaptureQueriesContext from django.utils import timezone from rest_framework import status @@ -19,6 +21,16 @@ from documentcloud.documents.choices import Access from documentcloud.users.tests.factories import UserFactory +SCRATCH = {"seen": ["a", "b", "c"]} + + +def selects_scratch(queries): + """Whether any captured query read the add-on event scratch column""" + for query in queries: + if '"addons_addonevent"."scratch"' in query["sql"]: + return True + return False + @pytest.mark.django_db() class TestAddOnAPI: @@ -538,3 +550,73 @@ def test_filter_dismissed_absent_is_noop(self, client): response = client.get("/api/addon_events/") assert response.status_code == status.HTTP_200_OK assert len(response.json()["results"]) == 2 + + +@pytest.mark.django_db() +class TestAddOnEventScratch: + """Scratch is only returned and loaded by the add-on event detail endpoint""" + + @pytest.mark.parametrize("expand", ["event", "addon,event", "~all"]) + def test_run_list_expand_event_omits_scratch(self, client, expand): + """Expanded events on listed runs neither return nor load scratch""" + user = UserFactory() + event = AddOnEventFactory(user=user, scratch=SCRATCH) + AddOnRunFactory(user=user, event=event) + client.force_authenticate(user=user) + with CaptureQueriesContext(connection) as context: + response = client.get("/api/addon_runs/", {"expand": expand}) + assert response.status_code == status.HTTP_200_OK + results = response.json()["results"] + assert len(results) == 1 + assert "scratch" not in results[0]["event"] + assert not selects_scratch(context.captured_queries) + + def test_run_retrieve_expand_event_omits_scratch(self, client): + """An expanded event on a single run neither returns nor loads scratch""" + user = UserFactory() + event = AddOnEventFactory(user=user, scratch=SCRATCH) + run = AddOnRunFactory(user=user, event=event) + client.force_authenticate(user=user) + with CaptureQueriesContext(connection) as context: + response = client.get(f"/api/addon_runs/{run.uuid}/", {"expand": "event"}) + assert response.status_code == status.HTTP_200_OK + assert "scratch" not in response.json()["event"] + assert not selects_scratch(context.captured_queries) + + def test_event_list_omits_scratch(self, client): + """The events list neither returns nor loads scratch""" + user = UserFactory() + AddOnEventFactory(user=user, scratch=SCRATCH) + client.force_authenticate(user=user) + with CaptureQueriesContext(connection) as context: + response = client.get("/api/addon_events/") + assert response.status_code == status.HTTP_200_OK + results = response.json()["results"] + assert len(results) == 1 + assert "scratch" not in results[0] + assert not selects_scratch(context.captured_queries) + + def test_event_retrieve_returns_scratch(self, client): + """The event detail endpoint is the only one that returns and loads scratch""" + event = AddOnEventFactory(scratch=SCRATCH) + client.force_authenticate(user=event.user) + with CaptureQueriesContext(connection) as context: + response = client.get(f"/api/addon_events/{event.pk}/") + assert response.status_code == status.HTTP_200_OK + assert response.json()["scratch"] == SCRATCH + assert selects_scratch(context.captured_queries) + + def test_event_list_query_count(self, client): + """Listing events must not run a query per event to load scratch""" + user = UserFactory() + client.force_authenticate(user=user) + AddOnEventFactory(user=user, scratch=SCRATCH) + client.get("/api/addon_events/") # Warm up one-time queries before measuring + with CaptureQueriesContext(connection) as context: + client.get("/api/addon_events/") + baseline = len(context.captured_queries) + AddOnEventFactory.create_batch(4, user=user, scratch=SCRATCH) + with CaptureQueriesContext(connection) as context: + response = client.get("/api/addon_events/") + assert len(response.json()["results"]) == 5 + assert len(context.captured_queries) == baseline diff --git a/documentcloud/addons/views.py b/documentcloud/addons/views.py index 95cd4c43..6b37954a 100644 --- a/documentcloud/addons/views.py +++ b/documentcloud/addons/views.py @@ -737,7 +737,7 @@ def get_queryset(self): if is_expanded(self.request, "addon"): queryset = queryset.select_related("addon", "addon__github_account") if is_expanded(self.request, "event"): - queryset = queryset.select_related("event") + queryset = queryset.select_related("event").defer("event__scratch") return queryset def perform_create(self, serializer): @@ -849,7 +849,6 @@ class AddOnEventViewSet(FlexFieldsModelViewSet): "selector": "*", }, "event": 0, - "scratch": {"timestamp": "20230703130357"}, "created_at": "2023-07-03T01:02:09.025856Z", "updated_at": "2023-07-15T06:20:35.502166Z", }, @@ -862,7 +861,6 @@ class AddOnEventViewSet(FlexFieldsModelViewSet): "selector": "*", }, "event": 0, - "scratch": {}, "created_at": "2023-07-03T02:14:13.954076Z", "updated_at": "2023-07-03T04:02:16.471265Z", }, @@ -1024,6 +1022,8 @@ def get_queryset(self): queryset = AddOnEvent.objects.get_viewable(self.request.user).order_by("-pk") if is_expanded(self.request, "addon"): queryset = queryset.select_related("addon") + if self.action == "list": + queryset = queryset.defer("scratch") return queryset def perform_create(self, serializer): diff --git a/documentcloud/documents/admin.py b/documentcloud/documents/admin.py index f0dd3306..e3bf272f 100644 --- a/documentcloud/documents/admin.py +++ b/documentcloud/documents/admin.py @@ -5,6 +5,7 @@ # DocumentCloud from documentcloud.core.pagination import LargeTablePaginator from documentcloud.documents.models import Document +from documentcloud.documents.tasks import invalidate_cache @admin.register(Document) @@ -65,6 +66,8 @@ def save_model(self, request, obj, form, change): field_updates.pop("admin_noindex") field_updates["noindex"] = "set" obj.index_on_commit(field_updates=field_updates) + # admin can change `access` directly, bypassing `update_access` + transaction.on_commit(lambda: invalidate_cache.delay(obj.pk)) def delete_model(self, request, obj): obj.destroy() diff --git a/documentcloud/documents/cache.py b/documentcloud/documents/cache.py index e8c1a0e2..5c514781 100644 --- a/documentcloud/documents/cache.py +++ b/documentcloud/documents/cache.py @@ -85,6 +85,35 @@ def tiered_cache_control(updated_at, now=None): return ", ".join(directives) +def cache_tag(document_pk): + """The Cloudflare Cache-Tag for a document's cached API responses.""" + return f"doc-{document_pk}" + + +def set_not_found_cache_control(request, response, document_pk): + """Let the CDN briefly hold an anonymous 404 for a document. + + Leaves anything other than an anonymous GET/HEAD 404 for an integer id + untouched. `max-age=0` because a purge cannot reach a browser's copy. + """ + has_auth_token = getattr(request, "auth", None) is not None + if ( + response.status_code != 404 + or request.method not in ("GET", "HEAD") + or has_auth_token + or request.user.is_authenticated + ): + return + try: + document_pk = int(document_pk) + except (TypeError, ValueError): + return + response["Cache-Control"] = ( + f"public, max-age=0, s-maxage={settings.CACHE_CONTROL_NOT_FOUND_MAX_AGE}" + ) + response["Cache-Tag"] = cache_tag(document_pk) + + class CloudflarePurgeError(requests.RequestException): """A Cloudflare purge request was rejected. diff --git a/documentcloud/documents/migrations/0057_document_documents_org_id_idx.py b/documentcloud/documents/migrations/0057_document_documents_org_id_idx.py new file mode 100644 index 00000000..bb812537 --- /dev/null +++ b/documentcloud/documents/migrations/0057_document_documents_org_id_idx.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.15 on 2026-10-16 20:12 + +from django.contrib.postgres.operations import AddIndexConcurrently +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + atomic = False + dependencies = [ + ("documents", "0056_alter_document_language_alter_document_status_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + migrations.swappable_dependency(settings.SQUARELET_ORGANIZATION_MODEL), + ] + + operations = [ + AddIndexConcurrently( + model_name="document", + index=models.Index( + fields=["organization", "id"], name="documents_org_id_idx" + ), + ), + ] diff --git a/documentcloud/documents/models/document.py b/documentcloud/documents/models/document.py index 34d1b328..1a7aaeeb 100644 --- a/documentcloud/documents/models/document.py +++ b/documentcloud/documents/models/document.py @@ -25,6 +25,7 @@ from documentcloud.core.choices import Language from documentcloud.core.fields import AutoCreatedField, AutoLastModifiedField from documentcloud.core.utils import format_date, slugify +from documentcloud.documents.cache import cache_tag from documentcloud.documents.choices import Access, Status from documentcloud.documents.querysets import DocumentQuerySet @@ -267,6 +268,10 @@ class Meta: condition=Q(access=Access.public), name="documents_user_public_idx", ), + models.Index( + fields=["organization", "id"], + name="documents_org_id_idx", + ), ] def __str__(self): @@ -726,7 +731,7 @@ def cache_tag(self): every `?expand=…` / per-`Origin` variant at once - the key spaces a URL purge can't enumerate. """ - return f"doc-{self.pk}" + return cache_tag(self.pk) def index_on_commit(self, **kwargs): """Index the document in Solr on tranasction commit""" diff --git a/documentcloud/documents/tasks.py b/documentcloud/documents/tasks.py index 9465ae98..27523f7f 100644 --- a/documentcloud/documents/tasks.py +++ b/documentcloud/documents/tasks.py @@ -270,6 +270,8 @@ def update_access(document_pk, status, access, marker=None): # update the document status and re-index into solr Document.objects.filter(pk=document_pk).update(status=status, **kwargs) document.index_on_commit(field_updates=field_updates) + # otherwise an anonymous 404 cached since the request hides it + transaction.on_commit(lambda: invalidate_cache.delay(document_pk)) @shared_task( @@ -302,6 +304,9 @@ def set_page_text(document_pk, page_text_infos): document.save(update_fields=["status", "solr_dirty"]) kwargs["field_updates"]["status"] = "set" document.index_on_commit(**kwargs) + # otherwise an anonymous 404 cached while pending hides it + if document.cache_dirty or document.public: + transaction.on_commit(lambda: invalidate_cache.delay(document_pk)) # new solr @@ -439,6 +444,9 @@ def invalidate_cache(*document_pks): def post_process(document_pk, modification_data): document = Document.objects.get(pk=document_pk) modifications.post_process(document, modification_data) + # otherwise an anonymous 404 cached while pending hides it + if document.cache_dirty or document.public: + transaction.on_commit(lambda: invalidate_cache.delay(document_pk)) # notes diff --git a/documentcloud/documents/tests/test_admin.py b/documentcloud/documents/tests/test_admin.py new file mode 100644 index 00000000..bf9c4aff --- /dev/null +++ b/documentcloud/documents/tests/test_admin.py @@ -0,0 +1,34 @@ +# Django +from django.contrib.admin.sites import AdminSite + +# Standard Library +from unittest.mock import Mock + +# Third Party +import pytest + +# DocumentCloud +from documentcloud.core.tests import run_commit_hooks +from documentcloud.documents.admin import DocumentAdmin +from documentcloud.documents.choices import Access +from documentcloud.documents.models import Document +from documentcloud.documents.tests.factories import DocumentFactory + + +@pytest.mark.django_db() +class TestDocumentAdmin: + def test_admin_edit_purges_cache(self, mocker): + """Admin can make a document public without `update_access`.""" + mock_invalidate = mocker.patch( + "documentcloud.documents.tasks.invalidate_cache.delay" + ) + document = DocumentFactory(access=Access.private) + document.access = Access.public + form = Mock(changed_data=["access"]) + + DocumentAdmin(Document, AdminSite()).save_model( + Mock(), document, form, change=True + ) + run_commit_hooks() + + mock_invalidate.assert_called_once_with(document.pk) diff --git a/documentcloud/documents/tests/test_tasks.py b/documentcloud/documents/tests/test_tasks.py index f1da3773..7566deda 100644 --- a/documentcloud/documents/tests/test_tasks.py +++ b/documentcloud/documents/tests/test_tasks.py @@ -1,4 +1,9 @@ +# Django +from django.test.utils import override_settings +from django.utils import timezone + # Standard Library +from datetime import timedelta from unittest.mock import patch # Third Party @@ -7,12 +12,16 @@ from requests.exceptions import HTTPError # DocumentCloud -from documentcloud.documents.choices import Status +from documentcloud.core.tests import run_commit_hooks +from documentcloud.documents.choices import Access, Status from documentcloud.documents.models import Document from documentcloud.documents.tasks import ( fetch_file_url, invalidate_cache, + post_process, + publish_scheduled_documents, set_page_text, + update_access, ) from documentcloud.documents.tests.factories import DocumentFactory @@ -146,3 +155,73 @@ def fake_set_page_text(self, *args, **kwargs): doc.refresh_from_db() assert doc.data == {"_tag": "applied"} # survives with the fix assert doc.status == Status.success + + +@pytest.mark.django_db() +class TestPurgeOnBecomingVisible: + """Becoming visible to anonymous users purges any cached 404.""" + + @pytest.fixture(autouse=True) + def mock_invalidate(self, mocker): + return mocker.patch("documentcloud.documents.tasks.invalidate_cache.delay") + + @pytest.fixture() + def mock_storage(self, mocker): + return mocker.patch("documentcloud.documents.tasks.storage") + + @override_settings(UPDATE_ACCESS_CHUNK_SIZE=2) + def test_update_access_final_chunk_purges(self, mock_storage, mock_invalidate): + """The final chunk is when `access` actually flips to public.""" + document = DocumentFactory(access=Access.private, status=Status.readable) + mock_storage.list.return_value = ["a"] + + update_access(document.pk, Status.success, Access.public) + run_commit_hooks() + + document.refresh_from_db() + assert document.access == Access.public + mock_invalidate.assert_called_once_with(document.pk) + + @override_settings(UPDATE_ACCESS_CHUNK_SIZE=2) + def test_publish_scheduled_documents_purges( + self, mocker, mock_storage, mock_invalidate + ): + """Scheduled publishing purges once `update_access` finishes.""" + mock_update_access = mocker.patch( + "documentcloud.documents.tasks.update_access.delay" + ) + document = DocumentFactory( + access=Access.private, + status=Status.success, + publish_at=timezone.now() - timedelta(minutes=1), + ) + mock_storage.list.return_value = ["a"] + + publish_scheduled_documents() + run_commit_hooks() + mock_update_access.assert_called_once() + update_access(*mock_update_access.call_args[0]) + run_commit_hooks() + + document.refresh_from_db() + assert document.access == Access.public + mock_invalidate.assert_called_once_with(document.pk) + + def test_set_page_text_public_purges(self, mock_invalidate): + """`set_page_text` moves a public upload out of `pending`.""" + document = DocumentFactory(access=Access.public, status=Status.pending) + + with patch.object(Document, "set_page_text", return_value={}): + set_page_text(document.pk, [{"page_number": 0, "text": "hi"}]) + run_commit_hooks() + + mock_invalidate.assert_called_once_with(document.pk) + + def test_post_process_public_purges(self, mock_invalidate): + """Modifications finish here, outside the view's completion path.""" + document = DocumentFactory(access=Access.public, status=Status.pending) + + post_process(document.pk, {"modifications": [], "pagespec": ""}) + run_commit_hooks() + + mock_invalidate.assert_called_once_with(document.pk) diff --git a/documentcloud/documents/tests/test_views.py b/documentcloud/documents/tests/test_views.py index 02a1c11e..7c21cbbb 100644 --- a/documentcloud/documents/tests/test_views.py +++ b/documentcloud/documents/tests/test_views.py @@ -568,6 +568,48 @@ def test_retrieve_cache_control_auth_still_private(self, client, document): assert "max-age" not in directives assert "s-maxage" not in directives + @pytest.mark.parametrize( + "hidden", + [ + {"access": Access.private}, + {"access": Access.public, "status": Status.pending}, + "missing", + ], + ids=["private", "processing", "missing"], + ) + def test_retrieve_anonymous_not_found_is_cacheable(self, client, hidden): + """The CDN may hold an anonymous 404, tagged so a purge clears it.""" + if hidden == "missing": + pk = DocumentFactory().pk + 1 + else: + pk = DocumentFactory(**hidden).pk + response = client.get(f"/api/documents/{pk}/") + assert response.status_code == status.HTTP_404_NOT_FOUND + directives = self._directives(response) + assert directives["public"] is True + assert directives["max-age"] == "0" + assert directives["s-maxage"] == str(settings.CACHE_CONTROL_NOT_FOUND_MAX_AGE) + assert response["Cache-Tag"] == f"doc-{pk}" + + @pytest.mark.parametrize( + "token", [None, {"permissions": []}], ids=["session", "token"] + ) + def test_retrieve_authenticated_not_found_stays_private(self, client, token): + """A cached 404 must never reach a user who could see the document.""" + document = DocumentFactory(access=Access.private) + client.force_authenticate(user=UserFactory(), token=token) + response = client.get(f"/api/documents/{document.pk}/") + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "public" not in self._directives(response) + assert "Cache-Tag" not in response + + @override_settings(CACHE_CONTROL_NOT_FOUND_MAX_AGE=42) + def test_retrieve_anonymous_not_found_uses_setting(self, client): + """The 404 TTL is tunable without a deploy.""" + document = DocumentFactory(access=Access.private) + response = client.get(f"/api/documents/{document.pk}/") + assert self._directives(response)["s-maxage"] == "42" + def test_retrieve_not_modified_cache_control(self, client, document): """The 304 branch carries the tier too. Without it a conditional hit would fall back to whatever default the dispatch decorator applies, @@ -1295,6 +1337,51 @@ def test_modification_marks_cache_dirty(self, client, mocker): document.refresh_from_db() assert document.cache_dirty is True + def _finish_processing(self, client, document): + """Send the processing callback that marks a document as done""" + client.force_authenticate(user=None) + return client.patch( + f"/api/documents/{document.pk}/", + {"status": "success"}, + HTTP_AUTHORIZATION=f"processing-token {settings.PROCESSING_TOKEN}", + ) + + def test_finish_processing_public_upload_purges(self, client, mocker): + """A public upload purges on completion without `cache_dirty`.""" + mock_invalidate = mocker.patch( + "documentcloud.documents.tasks.invalidate_cache.delay" + ) + document = DocumentFactory(access=Access.public, status=Status.pending) + assert document.cache_dirty is False + + response = self._finish_processing(client, document) + run_commit_hooks() + + assert response.status_code == status.HTTP_200_OK + mock_invalidate.assert_called_once_with(document.pk) + + def test_finish_reprocessing_public_purges(self, client, mocker): + """Reprocessing hides a public document until it finishes.""" + mocker.patch( + "documentcloud.common.environment.storage.exists", return_value=True + ) + mock_invalidate = mocker.patch( + "documentcloud.documents.tasks.invalidate_cache.delay" + ) + document = DocumentFactory(access=Access.public) + client.force_authenticate(user=document.user) + response = client.post(f"/api/documents/{document.pk}/process/") + assert response.status_code == status.HTTP_200_OK + document.refresh_from_db() + assert document.status == Status.pending + assert document.cache_dirty is False + + response = self._finish_processing(client, document) + run_commit_hooks() + + assert response.status_code == status.HTTP_200_OK + mock_invalidate.assert_called_once_with(document.pk) + def test_process(self, client, document, mocker): """Test processing a document""" # pretend the file exists @@ -1653,6 +1740,27 @@ def test_list(self, client, document): response_json = response.json() assert len(response_json["results"]) == size + def test_list_anonymous_not_found_is_cacheable(self, client): + """The CDN may hold an anonymous 404, tagged with the document's id.""" + document = DocumentFactory(access=Access.private) + response = client.get(f"/api/documents/{document.pk}/notes/") + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "public" in response["Cache-Control"] + assert "max-age=0" in response["Cache-Control"] + assert response["Cache-Tag"] == f"doc-{document.pk}" + + @pytest.mark.parametrize( + "token", [None, {"permissions": []}], ids=["session", "token"] + ) + def test_list_authenticated_not_found_stays_private(self, client, token): + """A cached 404 must never reach a user who could see the document.""" + document = DocumentFactory(access=Access.private) + client.force_authenticate(user=UserFactory(), token=token) + response = client.get(f"/api/documents/{document.pk}/notes/") + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "public" not in response["Cache-Control"] + assert "Cache-Tag" not in response + @pytest.mark.parametrize("expand", ["", "~all", "user.organization,organization"]) @override_settings(DEBUG=True) def test_list_queries(self, client, document, expand): diff --git a/documentcloud/documents/views.py b/documentcloud/documents/views.py index b537a790..7ac9639a 100644 --- a/documentcloud/documents/views.py +++ b/documentcloud/documents/views.py @@ -44,7 +44,10 @@ ProcessingTokenAuthenticationScheme, record_uploads, ) -from documentcloud.documents.cache import tiered_cache_control +from documentcloud.documents.cache import ( + set_not_found_cache_control, + tiered_cache_control, +) from documentcloud.documents.choices import Access, EntityKind, OccurrenceKind, Status from documentcloud.documents.constants import DATA_KEY_REGEX from documentcloud.documents.decorators import ( @@ -680,6 +683,12 @@ def retrieve(self, request, *args, **kwargs): self._set_retrieve_cache_control(request, response, last_modified) return response + def handle_exception(self, exc): + response = super().handle_exception(exc) + if self.action == "retrieve": + set_not_found_cache_control(self.request, response, self.kwargs.get("pk")) + return response + def _set_retrieve_cache_control(self, request, response, last_modified): """Set `Cache-Control` on a retrieve response, tiered by age. @@ -1218,9 +1227,13 @@ def _update_solr(self, document, old_processing, old_data_keys, validated_data): document.index_on_commit(**kwargs) def _update_cache(self, document, old_processing): - """Invalidate the cache when finished processing a detructive operation""" + """Invalidate the cache when a dirty or public document finishes processing""" logger.info("[DOC UPDATE] update cache %s", document.pk) - if old_processing and not document.processing and document.cache_dirty: + if ( + old_processing + and not document.processing + and (document.cache_dirty or document.public) + ): transaction.on_commit(lambda pk=document.pk: invalidate_cache.delay(pk)) def _invalidate_edited_cache(self, instances, old_processings): @@ -1507,6 +1520,14 @@ class NoteViewSet(FlexFieldsModelViewSet): def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) + def handle_exception(self, exc): + response = super().handle_exception(exc) + if self.action == "list": + set_not_found_cache_control( + self.request, response, self.kwargs.get("document_pk") + ) + return response + @extend_schema(tags=["document_notes"]) def create(self, request, *args, **kwargs): return super().create(request, *args, **kwargs)