diff --git a/documentcloud/addons/migrations/0028_addonevent_addonevent_param_site_idx.py b/documentcloud/addons/migrations/0028_addonevent_addonevent_param_site_idx.py new file mode 100644 index 00000000..c3194242 --- /dev/null +++ b/documentcloud/addons/migrations/0028_addonevent_addonevent_param_site_idx.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.2 on 2026-04-29 16:43 + +from django.contrib.postgres.operations import AddIndexConcurrently +from django.db import migrations, models + + +class Migration(migrations.Migration): + + atomic = False + + dependencies = [ + ("addons", "0027_visualaddon"), + ] + + operations = [ + AddIndexConcurrently( + model_name="addonevent", + index=models.Index( + models.F("parameters__site"), + condition=models.Q(("parameters__has_key", "site")), + name="addonevent_param_site_idx", + ), + ), + ] diff --git a/documentcloud/addons/models.py b/documentcloud/addons/models.py index 7bdc70e1..eeb2937d 100644 --- a/documentcloud/addons/models.py +++ b/documentcloud/addons/models.py @@ -2,6 +2,7 @@ from django.conf import settings from django.core.cache import cache from django.db import models, transaction +from django.db.models import F, Q from django.utils.translation import gettext_lazy as _ # Standard Library @@ -568,6 +569,15 @@ class AddOnEvent(models.Model): help_text=_("Timestamp of when the add-on event was last updated"), ) + class Meta: + indexes = [ + models.Index( + F("parameters__site"), + name="addonevent_param_site_idx", + condition=Q(parameters__has_key="site"), + ), + ] + def __str__(self): return f"Event: {self.addon_id} - {self.event}" diff --git a/documentcloud/addons/tests/factories.py b/documentcloud/addons/tests/factories.py index 546c9e46..42fb920a 100644 --- a/documentcloud/addons/tests/factories.py +++ b/documentcloud/addons/tests/factories.py @@ -1,6 +1,9 @@ # Third Party import factory +# DocumentCloud +from documentcloud.addons.choices import Event + class AddOnFactory(factory.django.DjangoModelFactory): name = factory.Sequence(lambda n: f"Add-On {n}") @@ -34,6 +37,16 @@ class Meta: model = "addons.AddOnRun" +class AddOnEventFactory(factory.django.DjangoModelFactory): + addon = factory.SubFactory("documentcloud.addons.tests.factories.AddOnFactory") + user = factory.SubFactory("documentcloud.users.tests.factories.UserFactory") + event = Event.disabled + parameters = {} + + class Meta: + model = "addons.AddOnEvent" + + class GitHubAccountFactory(factory.django.DjangoModelFactory): user = factory.SubFactory("documentcloud.users.tests.factories.UserFactory") diff --git a/documentcloud/addons/tests/test_views.py b/documentcloud/addons/tests/test_views.py index cb5c98b7..34a4a888 100644 --- a/documentcloud/addons/tests/test_views.py +++ b/documentcloud/addons/tests/test_views.py @@ -11,7 +11,11 @@ # DocumentCloud from documentcloud.addons.models import AddOn, AddOnRun from documentcloud.addons.serializers import AddOnRunSerializer, AddOnSerializer -from documentcloud.addons.tests.factories import AddOnFactory, AddOnRunFactory +from documentcloud.addons.tests.factories import ( + AddOnEventFactory, + AddOnFactory, + AddOnRunFactory, +) from documentcloud.documents.choices import Access from documentcloud.users.tests.factories import UserFactory @@ -268,3 +272,81 @@ def test_destroy(self, client, mocker): response = client.delete(f"/api/addon_runs/{run.uuid}/") assert response.status_code == status.HTTP_204_NO_CONTENT assert cancel.called_once() + + def test_filter_site(self, client): + """Filter runs by event parameters.site""" + user = UserFactory() + site = "https://www.example.com" + matching_event = AddOnEventFactory( + user=user, parameters={"site": site, "selector": "*"} + ) + other_event = AddOnEventFactory( + user=user, parameters={"site": "https://www.other.com"} + ) + no_site_event = AddOnEventFactory(user=user, parameters={"selector": "*"}) + matching_run = AddOnRunFactory(user=user, event=matching_event) + AddOnRunFactory(user=user, event=other_event) + AddOnRunFactory(user=user, event=no_site_event) + AddOnRunFactory(user=user, event=None) + client.force_authenticate(user=user) + response = client.get("/api/addon_runs/", {"site": site}) + assert response.status_code == status.HTTP_200_OK + uuids = [r["uuid"] for r in response.json()["results"]] + assert uuids == [str(matching_run.uuid)] + + def test_filter_site_absent_is_noop(self, client): + """Omitting the site filter returns all viewable runs""" + user = UserFactory() + with_site = AddOnEventFactory( + user=user, parameters={"site": "https://www.example.com"} + ) + without_site = AddOnEventFactory(user=user, parameters={}) + AddOnRunFactory(user=user, event=with_site) + AddOnRunFactory(user=user, event=without_site) + AddOnRunFactory(user=user, event=None) + client.force_authenticate(user=user) + response = client.get("/api/addon_runs/") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["results"]) == 3 + + +@pytest.mark.django_db() +class TestAddOnEventAPI: + def test_filter_site(self, client): + """Filter events by parameters.site""" + user = UserFactory() + site = "https://www.example.com" + matching = AddOnEventFactory( + user=user, parameters={"site": site, "selector": "*"} + ) + AddOnEventFactory( + user=user, parameters={"site": "https://www.other.com", "selector": "*"} + ) + AddOnEventFactory(user=user, parameters={"selector": "*"}) + client.force_authenticate(user=user) + response = client.get("/api/addon_events/", {"site": site}) + assert response.status_code == status.HTTP_200_OK + ids = [r["id"] for r in response.json()["results"]] + assert ids == [matching.pk] + + def test_filter_site_absent_is_noop(self, client): + """Omitting the site filter returns all viewable events""" + user = UserFactory() + AddOnEventFactory(user=user, parameters={"site": "https://www.example.com"}) + AddOnEventFactory(user=user, parameters={}) + client.force_authenticate(user=user) + response = client.get("/api/addon_events/") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["results"]) == 2 + + def test_filter_site_no_match(self, client): + """A site filter that matches nothing returns an empty list""" + user = UserFactory() + AddOnEventFactory(user=user, parameters={"site": "https://www.example.com"}) + AddOnEventFactory(user=user, parameters={}) + client.force_authenticate(user=user) + response = client.get( + "/api/addon_events/", {"site": "https://nope.example.com"} + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["results"] == [] diff --git a/documentcloud/addons/views.py b/documentcloud/addons/views.py index 5247e263..809d752b 100644 --- a/documentcloud/addons/views.py +++ b/documentcloud/addons/views.py @@ -8,7 +8,6 @@ from django.db.models import Q from django.db.models.aggregates import Count from django.db.models.expressions import Case, Exists, F, OuterRef, Value, When -from django.db.models.fields.related import ForeignKey from django.db.models.functions.text import Concat from django.http.response import ( Http404, @@ -741,6 +740,11 @@ class Filter(django_filters.FilterSet): model=AddOn, help_text="Filter runs by a specific add-on ID." ) dismissed = django_filters.BooleanFilter(help_text="Was this run dismissed?") + site = django_filters.CharFilter( + field_name="event__parameters__site", + lookup_expr="exact", + help_text="Filter runs by the `site` value in the event's parameters.", + ) class Meta: model = AddOnRun @@ -971,6 +975,11 @@ class Filter(django_filters.FilterSet): lookup_expr="exact", help_text="Filter events by a specific add-on ID.", ) + site = django_filters.CharFilter( + field_name="parameters__site", + lookup_expr="exact", + help_text="Filter events by the `site` value in their parameters.", + ) class Meta: model = AddOnEvent