From e2d71c11afa4040a2ff133680c7d374cfd40e636 Mon Sep 17 00:00:00 2001 From: Allan Lasser Date: Thu, 19 Mar 2026 11:02:36 -0400 Subject: [PATCH 1/2] Adds saved searches for users --- config/urls.py | 15 +++ documentcloud/conftest.py | 6 + .../documents/migrations/0054_savedsearch.py | 88 ++++++++++++ documentcloud/documents/models/__init__.py | 1 + .../documents/models/saved_search.py | 49 +++++++ documentcloud/documents/serializers.py | 12 ++ documentcloud/documents/tests/factories.py | 9 ++ .../documents/tests/test_saved_searches.py | 127 ++++++++++++++++++ documentcloud/documents/views.py | 14 ++ 9 files changed, 321 insertions(+) create mode 100644 documentcloud/documents/migrations/0054_savedsearch.py create mode 100644 documentcloud/documents/models/saved_search.py create mode 100644 documentcloud/documents/tests/test_saved_searches.py diff --git a/config/urls.py b/config/urls.py index f52b3dec..0b6263d2 100644 --- a/config/urls.py +++ b/config/urls.py @@ -37,6 +37,7 @@ ModificationViewSet, NoteViewSet, RedactionViewSet, + SavedSearchViewSet, SectionViewSet, ) from documentcloud.drf_bulk.routers import BulkDefaultRouter, BulkRouterMixin @@ -92,6 +93,10 @@ class BulkNestedDefaultRouter(BulkRouterMixin, NestedDefaultRouter): sidekick_router = SidekickRouter(router, "projects", lookup="project") sidekick_router.register("sidekick", SidekickViewSet) +saved_search_list = SavedSearchViewSet.as_view({"get": "list", "post": "create"}) +saved_search_detail = SavedSearchViewSet.as_view( + {"get": "retrieve", "patch": "partial_update", "put": "update", "delete": "destroy"} +) urlpatterns = [ path("", RedirectView.as_view(url="/api/"), name="index"), @@ -106,6 +111,16 @@ class BulkNestedDefaultRouter(BulkRouterMixin, NestedDefaultRouter): SpectacularRedocView.as_view(url_name="schema"), name="redoc", ), + path( + "api/documents/search/saved/", + saved_search_list, + name="savedsearch-list", + ), + path( + "api/documents/search/saved//", + saved_search_detail, + name="savedsearch-detail", + ), path("api/", include("documentcloud.oembed.urls")), path("api/messages/", MessageView.as_view(), name="message-create"), # Social Django diff --git a/documentcloud/conftest.py b/documentcloud/conftest.py index 89231d8b..b2c7962f 100644 --- a/documentcloud/conftest.py +++ b/documentcloud/conftest.py @@ -9,6 +9,7 @@ from documentcloud.documents.tests.factories import ( DocumentFactory, NoteFactory, + SavedSearchFactory, SectionFactory, ) from documentcloud.entities.tests.factories import ( @@ -76,6 +77,11 @@ def pro_organization(): return ProfessionalOrganizationFactory() +@pytest.fixture +def saved_search(): + return SavedSearchFactory() + + @pytest.fixture def entity(): return EntityFactory() diff --git a/documentcloud/documents/migrations/0054_savedsearch.py b/documentcloud/documents/migrations/0054_savedsearch.py new file mode 100644 index 00000000..0d2256cb --- /dev/null +++ b/documentcloud/documents/migrations/0054_savedsearch.py @@ -0,0 +1,88 @@ +# Generated by Django 3.2.9 on 2026-03-17 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import documentcloud.core.fields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("documents", "0053_auto_20230622_1623"), + ] + + operations = [ + migrations.CreateModel( + name="SavedSearch", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "uuid", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + help_text="Unique identifier for the saved search", + unique=True, + ), + ), + ( + "name", + models.CharField( + help_text="A name for the saved search", + max_length=255, + verbose_name="name", + ), + ), + ( + "query", + models.TextField( + help_text="The search query string", + verbose_name="query", + ), + ), + ( + "created_at", + documentcloud.core.fields.AutoCreatedField( + editable=False, + help_text="Timestamp of when the saved search was created", + verbose_name="created at", + ), + ), + ( + "updated_at", + documentcloud.core.fields.AutoLastModifiedField( + editable=False, + help_text="Timestamp of when the saved search was last updated", + verbose_name="updated at", + ), + ), + ( + "user", + models.ForeignKey( + help_text="The user who owns this saved search", + on_delete=django.db.models.deletion.CASCADE, + related_name="saved_searches", + to=settings.AUTH_USER_MODEL, + verbose_name="user", + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + ] diff --git a/documentcloud/documents/models/__init__.py b/documentcloud/documents/models/__init__.py index 95ce06bc..8891fdcb 100644 --- a/documentcloud/documents/models/__init__.py +++ b/documentcloud/documents/models/__init__.py @@ -2,3 +2,4 @@ from documentcloud.documents.models.document import * from documentcloud.documents.models.entity import * from documentcloud.documents.models.note import * +from documentcloud.documents.models.saved_search import * diff --git a/documentcloud/documents/models/saved_search.py b/documentcloud/documents/models/saved_search.py new file mode 100644 index 00000000..b5850230 --- /dev/null +++ b/documentcloud/documents/models/saved_search.py @@ -0,0 +1,49 @@ +# Standard Library +# Django +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from uuid import uuid4 + +# DocumentCloud +from documentcloud.core.fields import AutoCreatedField, AutoLastModifiedField + + +class SavedSearch(models.Model): + uuid = models.UUIDField( + unique=True, + editable=False, + default=uuid4, + db_index=True, + help_text=_("Unique identifier for the saved search"), + ) + user = models.ForeignKey( + verbose_name=_("user"), + to="users.User", + on_delete=models.CASCADE, + related_name="saved_searches", + help_text=_("The user who owns this saved search"), + ) + name = models.CharField( + _("name"), + max_length=255, + help_text=_("A name for the saved search"), + ) + query = models.TextField( + _("query"), + help_text=_("The search query string"), + ) + created_at = AutoCreatedField( + _("created at"), + help_text=_("Timestamp of when the saved search was created"), + ) + updated_at = AutoLastModifiedField( + _("updated at"), + help_text=_("Timestamp of when the saved search was last updated"), + ) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return self.name diff --git a/documentcloud/documents/serializers.py b/documentcloud/documents/serializers.py index ea8c152f..bd936e6f 100644 --- a/documentcloud/documents/serializers.py +++ b/documentcloud/documents/serializers.py @@ -31,6 +31,7 @@ LegacyEntity, Note, Revision, + SavedSearch, Section, ) from documentcloud.drf_bulk.serializers import BulkListSerializer @@ -880,3 +881,14 @@ def get_url(self, obj): f"{settings.DOCCLOUD_API_URL}/files/documents/{obj.document.pk}/" f"revisions/{obj.version:04d}-{obj.document.slug}.{extension}" ) + + +class SavedSearchSerializer(serializers.ModelSerializer): + class Meta: + model = SavedSearch + fields = ["uuid", "name", "query", "created_at", "updated_at"] + extra_kwargs = { + "uuid": {"read_only": True}, + "created_at": {"read_only": True}, + "updated_at": {"read_only": True}, + } diff --git a/documentcloud/documents/tests/factories.py b/documentcloud/documents/tests/factories.py index a56128df..1395aac2 100644 --- a/documentcloud/documents/tests/factories.py +++ b/documentcloud/documents/tests/factories.py @@ -123,3 +123,12 @@ class EntityOccurrenceFactory(factory.django.DjangoModelFactory): class Meta: model = "documents.EntityOccurrence" + + +class SavedSearchFactory(factory.django.DjangoModelFactory): + user = factory.SubFactory("documentcloud.users.tests.factories.UserFactory") + name = factory.Sequence(lambda n: f"Saved Search {n}") + query = factory.Faker("sentence") + + class Meta: + model = "documents.SavedSearch" diff --git a/documentcloud/documents/tests/test_saved_searches.py b/documentcloud/documents/tests/test_saved_searches.py new file mode 100644 index 00000000..5e7100c3 --- /dev/null +++ b/documentcloud/documents/tests/test_saved_searches.py @@ -0,0 +1,127 @@ +# Third Party +# Django +from rest_framework import status + +import pytest + +# DocumentCloud +from documentcloud.documents.tests.factories import SavedSearchFactory + + +@pytest.mark.django_db() +class TestSavedSearchAPI: + base_url = "/api/documents/search/saved/" + + def test_list_unauthenticated(self, client): + """Anonymous GET returns 403""" + response = client.get(self.base_url) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_create_unauthenticated(self, client): + """Anonymous POST returns 403""" + response = client.post(self.base_url, {"name": "Test", "query": "test"}) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_delete_unauthenticated(self, client): + """Anonymous DELETE returns 403""" + saved_search = SavedSearchFactory() + response = client.delete(f"{self.base_url}{saved_search.uuid}/") + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_retrieve_unauthenticated(self, client): + """Anonymous GET for a single search returns 403""" + saved_search = SavedSearchFactory() + response = client.get(f"{self.base_url}{saved_search.uuid}/") + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_list(self, client, user): + """Authenticated user sees only their own saved searches""" + client.force_authenticate(user=user) + SavedSearchFactory.create_batch(2) # other user's searches + owned = SavedSearchFactory.create_batch(3, user=user) + response = client.get(self.base_url) + assert response.status_code == status.HTTP_200_OK + results = response.json()["results"] + result_uuids = {r["uuid"] for r in results} + expected_uuids = {str(s.uuid) for s in owned} + assert result_uuids == expected_uuids + + def test_create(self, client, user): + """POST with name + query creates a saved search for the user""" + client.force_authenticate(user=user) + data = {"name": "My Search", "query": "title:test"} + response = client.post(self.base_url, data) + assert response.status_code == status.HTTP_201_CREATED + result = response.json() + assert result["name"] == "My Search" + assert result["query"] == "title:test" + assert "uuid" in result + assert "created_at" in result + assert "updated_at" in result + + def test_retrieve(self, client, user): + """GET a single saved search by UUID""" + client.force_authenticate(user=user) + saved_search = SavedSearchFactory(user=user) + response = client.get(f"{self.base_url}{saved_search.uuid}/") + assert response.status_code == status.HTTP_200_OK + assert response.json()["name"] == saved_search.name + + def test_retrieve_other_user(self, client, user): + """Can't see another user's saved search (404)""" + client.force_authenticate(user=user) + saved_search = SavedSearchFactory() # different user + response = client.get(f"{self.base_url}{saved_search.uuid}/") + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_update_name(self, client, user): + """PATCH to update name""" + client.force_authenticate(user=user) + saved_search = SavedSearchFactory(user=user) + response = client.patch( + f"{self.base_url}{saved_search.uuid}/", + {"name": "Updated Name"}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["name"] == "Updated Name" + + def test_update_query(self, client, user): + """PATCH to update query""" + client.force_authenticate(user=user) + saved_search = SavedSearchFactory(user=user) + response = client.patch( + f"{self.base_url}{saved_search.uuid}/", + {"query": "new query"}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["query"] == "new query" + + def test_update_other_user(self, client, user): + """Can't update another user's saved search (404)""" + client.force_authenticate(user=user) + saved_search = SavedSearchFactory() # different user + response = client.patch( + f"{self.base_url}{saved_search.uuid}/", + {"name": "Hacked"}, + format="json", + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_delete(self, client, user): + """DELETE removes the saved search""" + client.force_authenticate(user=user) + saved_search = SavedSearchFactory(user=user) + response = client.delete(f"{self.base_url}{saved_search.uuid}/") + assert response.status_code == status.HTTP_204_NO_CONTENT + # Verify it's gone + response = client.get(f"{self.base_url}{saved_search.uuid}/") + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_delete_other_user(self, client, user): + """Can't delete another user's saved search (404)""" + client.force_authenticate(user=user) + saved_search = SavedSearchFactory() # different user + response = client.delete(f"{self.base_url}{saved_search.uuid}/") + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/documentcloud/documents/views.py b/documentcloud/documents/views.py index 2fdc9324..dd8c9254 100644 --- a/documentcloud/documents/views.py +++ b/documentcloud/documents/views.py @@ -53,6 +53,7 @@ EntityOccurrence, LegacyEntity, Note, + SavedSearch, Section, ) from documentcloud.documents.search import SOLR, search @@ -68,6 +69,7 @@ NoteSerializer, ProcessDocumentSerializer, RedactionSerializer, + SavedSearchSerializer, SectionSerializer, ) from documentcloud.documents.tasks import ( @@ -1802,3 +1804,15 @@ def post_process(self, request, document_pk=None): post_process.delay(document_pk, request.data) return Response("OK", status=status.HTTP_200_OK) + + +class SavedSearchViewSet(viewsets.ModelViewSet): + serializer_class = SavedSearchSerializer + permission_classes = (IsAuthenticated,) + lookup_field = "uuid" + + def get_queryset(self): + return SavedSearch.objects.filter(user=self.request.user) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) From 6d7c95f6c366bd7c5d2f94cc85a88c13382ce26f Mon Sep 17 00:00:00 2001 From: Allan Lasser Date: Tue, 24 Mar 2026 14:29:10 -0400 Subject: [PATCH 2/2] Register saved search route with router --- config/urls.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/config/urls.py b/config/urls.py index 0b6263d2..37ebacc1 100644 --- a/config/urls.py +++ b/config/urls.py @@ -93,10 +93,7 @@ class BulkNestedDefaultRouter(BulkRouterMixin, NestedDefaultRouter): sidekick_router = SidekickRouter(router, "projects", lookup="project") sidekick_router.register("sidekick", SidekickViewSet) -saved_search_list = SavedSearchViewSet.as_view({"get": "list", "post": "create"}) -saved_search_detail = SavedSearchViewSet.as_view( - {"get": "retrieve", "patch": "partial_update", "put": "update", "delete": "destroy"} -) +router.register("documents/search/saved", SavedSearchViewSet, basename="saved_search") urlpatterns = [ path("", RedirectView.as_view(url="/api/"), name="index"), @@ -111,16 +108,6 @@ class BulkNestedDefaultRouter(BulkRouterMixin, NestedDefaultRouter): SpectacularRedocView.as_view(url_name="schema"), name="redoc", ), - path( - "api/documents/search/saved/", - saved_search_list, - name="savedsearch-list", - ), - path( - "api/documents/search/saved//", - saved_search_detail, - name="savedsearch-detail", - ), path("api/", include("documentcloud.oembed.urls")), path("api/messages/", MessageView.as_view(), name="message-create"), # Social Django