Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
ModificationViewSet,
NoteViewSet,
RedactionViewSet,
SavedSearchViewSet,
SectionViewSet,
)
from documentcloud.drf_bulk.routers import BulkDefaultRouter, BulkRouterMixin
Expand Down Expand Up @@ -92,6 +93,7 @@ class BulkNestedDefaultRouter(BulkRouterMixin, NestedDefaultRouter):
sidekick_router = SidekickRouter(router, "projects", lookup="project")
sidekick_router.register("sidekick", SidekickViewSet)

router.register("documents/search/saved", SavedSearchViewSet, basename="saved_search")

urlpatterns = [
path("", RedirectView.as_view(url="/api/"), name="index"),
Expand Down
6 changes: 6 additions & 0 deletions documentcloud/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from documentcloud.documents.tests.factories import (
DocumentFactory,
NoteFactory,
SavedSearchFactory,
SectionFactory,
)
from documentcloud.entities.tests.factories import (
Expand Down Expand Up @@ -76,6 +77,11 @@ def pro_organization():
return ProfessionalOrganizationFactory()


@pytest.fixture
def saved_search():
return SavedSearchFactory()


@pytest.fixture
def entity():
return EntityFactory()
Expand Down
88 changes: 88 additions & 0 deletions documentcloud/documents/migrations/0054_savedsearch.py
Original file line number Diff line number Diff line change
@@ -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"],
},
),
]
1 change: 1 addition & 0 deletions documentcloud/documents/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
49 changes: 49 additions & 0 deletions documentcloud/documents/models/saved_search.py
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions documentcloud/documents/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
LegacyEntity,
Note,
Revision,
SavedSearch,
Section,
)
from documentcloud.drf_bulk.serializers import BulkListSerializer
Expand Down Expand Up @@ -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},
}
9 changes: 9 additions & 0 deletions documentcloud/documents/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
127 changes: 127 additions & 0 deletions documentcloud/documents/tests/test_saved_searches.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions documentcloud/documents/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
EntityOccurrence,
LegacyEntity,
Note,
SavedSearch,
Section,
)
from documentcloud.documents.search import SOLR, search
Expand All @@ -68,6 +69,7 @@
NoteSerializer,
ProcessDocumentSerializer,
RedactionSerializer,
SavedSearchSerializer,
SectionSerializer,
)
from documentcloud.documents.tasks import (
Expand Down Expand Up @@ -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)
Loading