diff --git a/cl/api/templates/pacer-api-docs-vlatest.html b/cl/api/templates/pacer-api-docs-vlatest.html index b4fbc8d9ce..720a2c25bf 100644 --- a/cl/api/templates/pacer-api-docs-vlatest.html +++ b/cl/api/templates/pacer-api-docs-vlatest.html @@ -201,7 +201,8 @@
{% url "party-list" version=ve
This API can be filtered by docket ID to show all the parties for a particular case.
- Listen Up: Filters apply to this endpoint, not the data nested within it. Therefore, each party returned by this API will list all the attorneys that have represented them in any case, even if the parties themselves are filtered to a particular case.
+
Listen Up: Filters applied to this endpoint only affect the top-level data, not the data nested records within it. Therefore, each party returned by this API will list all the attorneys that have represented them in any case, even if the parties themselves are filtered to a particular case.
+ To filter the nested attorney data for each party, include the filter_nested_results=True
parameter in your API request.
For example, this query returns the parties for docket number 123
:
curl -v \
@@ -281,6 +282,8 @@ Attorneys — {% url "attorney-list" ve
To look up field descriptions or options for filtering, ordering, or rendering, complete an HTTP OPTIONS request.
Like docket entries and parties, attorneys can be filtered to a particular docket. For example:
+ Listen Up: Like the parties endpoint, filters applied to this endpoint only affect the top-level data. To filter the nested data for each attorney, include the filter_nested_results=True
parameter in your API request URL.
+
curl -v \
--header 'Authorization: Token {% if user.is_authenticated %}{{ user.auth_token }}{% else %}<your-token-here>{% endif %}' \
"{% get_full_host %}{% url "attorney-list" version=version %}?docket=4214664"
diff --git a/cl/api/tests.py b/cl/api/tests.py
index e523d46a80..574125443a 100644
--- a/cl/api/tests.py
+++ b/cl/api/tests.py
@@ -64,7 +64,13 @@
SchoolViewSet,
SourceViewSet,
)
-from cl.people_db.factories import PartyFactory, PartyTypeFactory
+from cl.people_db.factories import (
+ AttorneyFactory,
+ AttorneyOrganizationFactory,
+ PartyFactory,
+ PartyTypeFactory,
+ RoleFactory,
+)
from cl.people_db.models import Attorney
from cl.recap.factories import ProcessingQueueFactory
from cl.recap.views import (
@@ -1110,10 +1116,7 @@ async def test_exclusion_filters(self) -> None:
class DRFRecapApiFilterTests(TestCase, FilteringCountTestCase):
- fixtures = [
- "recap_docs.json",
- "attorney_party.json",
- ]
+ fixtures = ["recap_docs.json"]
@classmethod
def setUpTestData(cls) -> None:
@@ -1125,6 +1128,40 @@ def setUpTestData(cls) -> None:
ps = Permission.objects.filter(codename="has_recap_api_access")
up.user.user_permissions.add(*ps)
+ cls.docket = Docket.objects.get(pk=1)
+ cls.docket_2 = DocketFactory()
+ firm = AttorneyOrganizationFactory(
+ name="Lawyers LLP", lookup_key="6201in816"
+ )
+ cls.attorney = AttorneyFactory(
+ name="Juneau",
+ contact_raw="Juneau\r\nAlaska",
+ phone="555-555-5555",
+ fax="555-555-5555",
+ email="j@me.com",
+ date_created="2017-04-25T23:52:43.497Z",
+ date_modified="2017-04-25T23:52:43.497Z",
+ organizations=[firm],
+ docket=cls.docket,
+ )
+ cls.attorney_2 = AttorneyFactory(
+ organizations=[firm], docket=cls.docket_2
+ )
+ cls.party = PartyFactory(
+ name="Honker",
+ extra_info="",
+ docket=cls.docket,
+ attorneys=[cls.attorney],
+ date_created="2017-04-25T23:53:11.745Z",
+ date_modified="2017-04-25T23:53:11.745Z",
+ )
+ PartyTypeFactory.create(
+ party=cls.party,
+ name="Defendant",
+ extra_info="Klaxon",
+ docket=cls.docket,
+ )
+
@async_to_sync
async def setUp(self) -> None:
self.assertTrue(
@@ -1215,19 +1252,19 @@ async def test_recap_document_filters(self) -> None:
async def test_attorney_filters(self) -> None:
self.path = reverse("attorney-list", kwargs={"version": "v3"})
- self.q["id"] = 1
+ self.q["id"] = self.attorney.pk
+ await self.assertCountInResults(1)
+ self.q["id"] = self.attorney_2.pk
await self.assertCountInResults(1)
- self.q["id"] = 2
- await self.assertCountInResults(0)
- self.q = {"docket__id": 1}
+ self.q = {"docket__id": self.docket.pk}
await self.assertCountInResults(1)
- self.q = {"docket__id": 2}
+ self.q = {"docket__id": self.docket_2.pk}
await self.assertCountInResults(0)
- self.q = {"parties_represented__id": 1}
+ self.q = {"parties_represented__id": self.party.pk}
await self.assertCountInResults(1)
- self.q = {"parties_represented__id": 2}
+ self.q = {"parties_represented__id": 9999}
await self.assertCountInResults(0)
self.q = {"parties_represented__name__contains": "Honker"}
await self.assertCountInResults(1)
@@ -1237,40 +1274,119 @@ async def test_attorney_filters(self) -> None:
# Adds extra role to the existing attorney
docket = await Docket.objects.afirst()
attorney = await Attorney.objects.afirst()
+ party = await sync_to_async(PartyFactory)(
+ docket=self.docket_2,
+ attorneys=[attorney],
+ )
await sync_to_async(PartyTypeFactory.create)(
- party=await sync_to_async(PartyFactory)(
- docket=docket,
- attorneys=[attorney],
- ),
- docket=docket,
+ party=party,
+ docket=self.docket_2,
)
self.q = {"docket__date_created__range": "2017-04-14,2017-04-15"}
await self.assertCountInResults(1)
self.q = {"docket__date_created__range": "2017-04-15,2017-04-16"}
await self.assertCountInResults(0)
+ # Initial request: Fetch all related records
+ self.q = {"docket__id": self.docket.pk}
+ r = await self.async_client.get(self.path, self.q)
+ results = r.data["results"]
+ self.assertEqual(
+ len(results),
+ 1,
+ msg=f"Expected 1, but got {len(results)}.\n\nr.data was: {r.data}",
+ )
+ # Verify record has expected number of parties
+ self.assertEqual(
+ len(results[0]["parties_represented"]),
+ 2,
+ msg=f"Expected 2, but got {len(results[0]['parties_represented'])}.\n\nr.data was: {r.data}",
+ )
+
+ # Fetch attorney records for docket (repeat request with "filter_nested_results")
+ self.q = {"docket__id": self.docket.pk, "filter_nested_results": True}
+ r = await self.async_client.get(self.path, self.q)
+ results = r.data["results"]
+ self.assertEqual(
+ len(results),
+ 1,
+ msg=f"Expected 1, but got {len(results)}.\n\nr.data was: {r.data}",
+ )
+ # Verify top-level record has single party (due to filter)
+ self.assertEqual(
+ len(results[0]["parties_represented"]),
+ 1,
+ msg=f"Expected 1, but got {len(results[0]['parties_represented'])}.\n\nr.data was: {r.data}",
+ )
+ # Verify expected party is present in the parties_represented list
+ self.assertIn(
+ str(self.party.pk), results[0]["parties_represented"][0]["party"]
+ )
+
+ # Request using parties_represented lookup: Fetch all related records
+ self.q = {"parties_represented__docket__id": self.docket_2.pk}
+ r = await self.async_client.get(self.path, self.q)
+ results = r.data["results"]
+ self.assertEqual(
+ len(results),
+ 1,
+ msg=f"Expected 1, but got {len(results)}.\n\nr.data was: {r.data}",
+ )
+ # Verify record has expected number of parties
+ self.assertEqual(
+ len(results[0]["parties_represented"]),
+ 2,
+ msg=f"Expected 2, but got {len(results[0]['parties_represented'])}.\n\nr.data was: {r.data}",
+ )
+
+ # Fetch attorney records for parties associated with docket_2 (repeat with "filter_nested_results")
+ self.q = {
+ "parties_represented__docket__id": self.docket_2.pk,
+ "filter_nested_results": True,
+ }
+ r = await self.async_client.get(self.path, self.q)
+ results = r.data["results"]
+ self.assertEqual(
+ len(results),
+ 1,
+ msg=f"Expected 1, but got {len(results)}.\n\nr.data was: {r.data}",
+ )
+ # Verify record has expected number of parties
+ self.assertEqual(
+ len(results[0]["parties_represented"]),
+ 1,
+ msg=f"Expected 1, but got {len(results[0]['parties_represented'])}.\n\nr.data was: {r.data}",
+ )
+ # Verify expected party is present in the parties_represented list
+ self.assertIn(
+ str(party.pk), results[0]["parties_represented"][0]["party"]
+ )
+
async def test_party_filters(self) -> None:
self.path = reverse("party-list", kwargs={"version": "v3"})
- self.q["id"] = 1
+ self.q["id"] = self.party.pk
await self.assertCountInResults(1)
- self.q["id"] = 2
+ self.q["id"] = 999_999
await self.assertCountInResults(0)
# This represents dockets that the party was a part of.
- self.q = {"docket__id": 1}
+ self.q = {"docket__id": self.docket.id}
await self.assertCountInResults(1)
- self.q = {"docket__id": 2}
+ self.q = {"docket__id": self.docket_2.id}
await self.assertCountInResults(0)
# Contrasted with this, which joins based on their attorney.
- self.q = {"attorney__docket__id": 1}
+ self.q = {"attorney__docket__id": self.docket.pk}
await self.assertCountInResults(1)
- self.q = {"attorney__docket__id": 2}
+ self.q = {"attorney__docket__id": self.docket_2.pk}
await self.assertCountInResults(0)
self.q = {"name": "Honker"}
await self.assertCountInResults(1)
+ self.q = {"name__icontains": "Honk"}
+ await self.assertCountInResults(1)
+
self.q = {"name": "Cardinal Bonds"}
await self.assertCountInResults(0)
@@ -1279,6 +1395,137 @@ async def test_party_filters(self) -> None:
self.q = {"attorney__name__icontains": "Juno"}
await self.assertCountInResults(0)
+ # Add another attorney to the party record but linked to another docket
+ await sync_to_async(RoleFactory.create)(
+ party=self.party, docket=self.docket_2, attorney=self.attorney_2
+ )
+
+ # Fetch all party records for docket
+ self.q = {"docket__id": self.docket.pk}
+ r = await self.async_client.get(self.path, self.q)
+ results = r.data["results"]
+ self.assertEqual(
+ len(results),
+ 1,
+ msg=f"Expected 1, but got {len(results)}.\n\nr.data was: {r.data}",
+ )
+ # Verify record has expected number of attorneys
+ self.assertEqual(
+ len(results[0]["attorneys"]),
+ 2,
+ msg=f"Expected 2, but got {len(results[0]['attorneys'])}.\n\nr.data was: {r.data}",
+ )
+
+ # Fetch top-level record for docket (repeat with "filter_nested_results")
+ self.q = {"docket__id": self.docket.pk, "filter_nested_results": True}
+ r = await self.async_client.get(self.path, self.q)
+ results = r.data["results"]
+ self.assertEqual(
+ len(results),
+ 1,
+ msg=f"Expected 1, but got {len(results)}.\n\nr.data was: {r.data}",
+ )
+ # Verify the record has only one attorney (due to filter)
+ self.assertEqual(
+ len(results[0]["attorneys"]),
+ 1,
+ msg=f"Expected 1, but got {len(results[0]['attorneys'])}.\n\nr.data was: {r.data}",
+ )
+ # Check if retrieved attorney matches expected record
+ self.assertEqual(
+ results[0]["attorneys"][0]["attorney_id"], self.attorney.pk
+ )
+
+ # Add another party type to the party record but linked to a different docket
+ await sync_to_async(PartyTypeFactory.create)(
+ party=self.party, docket=self.docket_2
+ )
+
+ # Fetch all party records for docket
+ self.q = {"docket__id": self.docket.pk}
+ r = await self.async_client.get(self.path, self.q)
+ results = r.data["results"]
+ self.assertEqual(
+ len(results),
+ 1,
+ msg=f"Expected 1, but got {len(results)}.\n\nr.data was: {r.data}",
+ )
+ # Verify record has expected number of attorneys
+ self.assertEqual(
+ len(results[0]["attorneys"]),
+ 2,
+ msg=f"Expected 2, but got {len(results[0]['attorneys'])}.\n\nr.data was: {r.data}",
+ )
+ # Verify record has expected number of party types
+ self.assertEqual(
+ len(results[0]["party_types"]),
+ 2,
+ msg=f"Expected 2, but got {len(results[0]['party_types'])}.\n\nr.data was: {r.data}",
+ )
+
+ # Fetch top-level record for docket (repeat with "filter_nested_results")
+ self.q = {"docket__id": self.docket.pk, "filter_nested_results": True}
+ r = await self.async_client.get(self.path, self.q)
+ results = r.data["results"]
+ self.assertEqual(
+ len(results),
+ 1,
+ msg=f"Expected 1, but got {len(results)}.\n\nr.data was: {r.data}",
+ )
+ # Verify the record has only one attorney and one party type (due to filter)
+ self.assertEqual(
+ len(results[0]["attorneys"]),
+ 1,
+ msg=f"Expected 1, but got {len(results[0]['attorneys'])}.\n\nr.data was: {r.data}",
+ )
+ self.assertEqual(
+ len(results[0]["party_types"]),
+ 1,
+ msg=f"Expected 1, but got {len(results[0]['party_types'])}.\n\nr.data was: {r.data}",
+ )
+ # Check if retrieved party type matches expected record
+ self.assertNotEqual(
+ results[0]["party_types"][0]["docket_id"], self.docket_2.pk
+ )
+
+ # Fetch party details based on attorney lookup
+ self.q = {"attorney__docket__id": self.docket_2.pk}
+ r = await self.async_client.get(self.path, self.q)
+ results = r.data["results"]
+ self.assertEqual(
+ len(results),
+ 1,
+ msg=f"Expected 1, but got {len(results)}.\n\nr.data was: {r.data}",
+ )
+ # Verify record has expected number of attorneys
+ self.assertEqual(
+ len(results[0]["attorneys"]),
+ 2,
+ msg=f"Expected 2, but got {len(results[0]['attorneys'])}.\n\nr.data was: {r.data}",
+ )
+
+ # Apply additional filter to refine previous request
+ self.q = {
+ "attorney__docket__id": self.docket_2.pk,
+ "filter_nested_results": True,
+ }
+ r = await self.async_client.get(self.path, self.q)
+ results = r.data["results"]
+ self.assertEqual(
+ len(results),
+ 1,
+ msg=f"Expected 1, but got {len(results)}.\n\nr.data was: {r.data}",
+ )
+ # Ensure only attorney_2 is associated with the filtered party
+ self.assertEqual(
+ len(results[0]["attorneys"]),
+ 1,
+ msg=f"Expected 1, but got {len(results[0]['attorneys'])}.\n\nr.data was: {r.data}",
+ )
+ self.assertEqual(
+ results[0]["attorneys"][0]["attorney_id"], self.attorney_2.pk
+ )
+
class DRFSearchAppAndAudioAppApiFilterTest(
TestCase, AudioTestCase, FilteringCountTestCase
diff --git a/cl/api/utils.py b/cl/api/utils.py
index 45188fbbfe..570c48c769 100644
--- a/cl/api/utils.py
+++ b/cl/api/utils.py
@@ -1,7 +1,7 @@
import logging
from collections import OrderedDict, defaultdict
from datetime import date, datetime, timedelta, timezone
-from typing import Dict, List, Set, TypedDict, Union
+from typing import Any, Dict, List, Set, TypedDict, Union
import eyecite
from dateutil import parser
@@ -10,6 +10,7 @@
from django.contrib.auth.models import User
from django.contrib.humanize.templatetags.humanize import intcomma, ordinal
from django.db.models import F
+from django.db.models.constants import LOOKUP_SEP
from django.urls import resolve
from django.utils.decorators import method_decorator
from django.utils.encoding import force_str
@@ -27,6 +28,7 @@
from rest_framework.throttling import UserRateThrottle
from rest_framework_filters import FilterSet, RelatedFilter
from rest_framework_filters.backends import RestFrameworkFilterBackend
+from rest_framework_filters.filterset import related
from cl.api.models import (
WEBHOOK_EVENT_STATUS,
@@ -91,6 +93,133 @@ def to_html(self, request, queryset, view):
return ""
+class FilterManyToManyMixin:
+ """
+ Mixin for filtering nested many-to-many relationships.
+
+ Provides helper methods to efficiently filter nested querysets when using
+ `RelatedFilter` classes in filtersets. This is particularly useful for
+ scenarios where you need to filter on attributes of related models through
+ many-to-many relationships.
+
+ **Required Properties:**
+ - **`join_table_cleanup_mapping`**: A dictionary mapping the field_name
+ or custom labels used for `RelatedFilter` fields to the corresponding
+ field names in the join table. This mapping is essential for correct
+ filtering.
+ """
+
+ join_table_cleanup_mapping: dict[str, str] = {}
+
+ def _get_filter_label(self: FilterSet, field_name: str) -> str:
+ """
+ Maps a filter field name to its corresponding label.
+
+ When defining filters(Declarative or using the `fields` attribute) in a
+ filterset, the field name used internally might not directly match the
+ the label used in the request. This method helps resolve this
+ discrepancy by mapping the given `field_name` to its correct label.
+
+ This is particularly useful for custom filter methods where only the
+ field name is available, and obtaining the triggering label is not
+ straightforward.
+
+ Args:
+ field_name (str): The field name as used within the filterset.
+
+ Returns:
+ str: The corresponding label for the given field name.
+ """
+
+ FIELD_NAME_LABEL_MAPPING = {
+ filter_class.field_name: label
+ for label, filter_class in self.filters.items()
+ }
+ return FIELD_NAME_LABEL_MAPPING[field_name]
+
+ def _clean_join_table_key(self, key: str) -> str:
+ """
+ Cleans and adjusts a given key for compatibility with prefetch queries.
+
+ This method modifies specific lookups within the `key` to ensure
+ correct filtering when used in prefetch queries. It iterates over a
+ mapping of URL keys to new keys, replacing instances of URL keys with
+ their corresponding new keys.
+
+ Args:
+ key (str): The original key to be cleaned.
+
+ Returns:
+ str: The cleaned key, adjusted for prefetch query compatibility.
+ """
+ join_table_key = key
+ for url_key, new_key in self.join_table_cleanup_mapping.items():
+ join_table_key = join_table_key.replace(url_key, new_key, 1)
+ return join_table_key
+
+ def get_filters_for_join_table(self: FilterSet) -> dict[str, Any]:
+ """
+ Processes request filters for use in a join table query.
+
+ Iterates through the request filters, cleaning and transforming them to
+ be suitable for applying filtering conditions to a join table. Returns
+ a dictionary containing the filtered criteria to be applied to the join
+ table query.
+
+ Args:
+ name: The name of label used to trigger the custom filtering method
+
+ Returns:
+ dict: A dictionary containing the filtered criteria for the join
+ table query.
+ """
+ filters: dict[str, Any] = {}
+ # Iterate over related filtersets
+ for related_name, related_filterset in self.related_filtersets.items():
+ prefix = f"{related(self, related_name)}{LOOKUP_SEP}"
+ # Check if the related filterset has data to apply
+ if not any(value.startswith(prefix) for value in self.data):
+ # Skip processing if no parameter starts with the prefix
+ continue
+
+ # Extract and clean the field name to be used as a filter.
+ #
+ # We start with the field name from the `filters` dictionary,
+ # which is associated with the `related_name`.
+ #
+ # The `_clean_join_table_key` method is used to ensure
+ # compatibility with prefetch queries. The cleaned field name is
+ # then used to construct a lookup expression that will perform
+ # an `IN` query. This approach is efficient for filtering multiple
+ # values.
+ clean_field_name = self._clean_join_table_key(
+ self.filters[related_name].field_name
+ )
+ lookup_expr = LOOKUP_SEP.join([clean_field_name, "in"])
+
+ # Extract the field name to retrieve values from the subquery.
+ #
+ # This field is determined by the `to_field_name` attribute of
+ # the related filterset's field. If not specified, the default `pk`
+ # (primary key) is used.
+ #
+ # The subquery is constructed using the underlying form's
+ # `cleaned_data` to ensure that invalid lookups in the request are
+ # gracefully ignored.
+ to_field_name = (
+ getattr(
+ self.filters[related_name].field, "to_field_name", "pk"
+ )
+ or "pk"
+ )
+ subquery = related_filterset.qs.values(to_field_name)
+
+ # Merge the current lookup expression into the existing filter set.
+ filters = filters | {lookup_expr: subquery}
+
+ return filters
+
+
class NoEmptyFilterSet(FilterSet):
"""A custom filterset to ensure we don't get empty filter parameters."""
diff --git a/cl/people_db/api_serializers.py b/cl/people_db/api_serializers.py
index e9639d91f9..055f72a7ec 100644
--- a/cl/people_db/api_serializers.py
+++ b/cl/people_db/api_serializers.py
@@ -256,8 +256,28 @@ class Meta:
class PartySerializer(DynamicFieldsMixin, HyperlinkedModelSerializerWithId):
- attorneys = AttorneyRoleSerializer(source="roles", many=True)
- party_types = PartyTypeSerializer(many=True)
+ attorneys = serializers.SerializerMethodField()
+ party_types = serializers.SerializerMethodField()
+
+ def get_attorneys(self, obj: Party):
+ if hasattr(obj, "filtered_roles"):
+ data = obj.filtered_roles
+ else:
+ data = obj.roles
+
+ return AttorneyRoleSerializer(
+ data, many=True, context={"request": self.context["request"]}
+ ).data
+
+ def get_party_types(self, obj: Party):
+ if hasattr(obj, "filtered_party_types"):
+ data = obj.filtered_party_types
+ else:
+ data = obj.party_types
+
+ return PartyTypeSerializer(
+ data, many=True, context={"request": self.context["request"]}
+ ).data
class Meta:
model = Party
@@ -265,7 +285,17 @@ class Meta:
class AttorneySerializer(DynamicFieldsMixin, HyperlinkedModelSerializerWithId):
- parties_represented = PartyRoleSerializer(source="roles", many=True)
+ parties_represented = serializers.SerializerMethodField()
+
+ def get_parties_represented(self, obj: Attorney):
+ if hasattr(obj, "filtered_roles"):
+ data = obj.filtered_roles
+ else:
+ data = obj.roles
+
+ return PartyRoleSerializer(
+ data, many=True, context={"request": self.context["request"]}
+ ).data
class Meta:
model = Attorney
diff --git a/cl/people_db/filters.py b/cl/people_db/filters.py
index 8f6b30388e..8974dadb8c 100644
--- a/cl/people_db/filters.py
+++ b/cl/people_db/filters.py
@@ -1,5 +1,7 @@
+from typing import Any
+
import rest_framework_filters as filters
-from django.db.models import QuerySet
+from django.db.models import Prefetch, QuerySet
from cl.api.utils import (
ALL_TEXT_LOOKUPS,
@@ -7,6 +9,7 @@
DATE_LOOKUPS,
DATETIME_LOOKUPS,
INTEGER_LOOKUPS,
+ FilterManyToManyMixin,
NoEmptyFilterSet,
)
from cl.people_db.lookup_utils import lookup_judge_by_name_components
@@ -15,11 +18,13 @@
Attorney,
Education,
Party,
+ PartyType,
Person,
PoliticalAffiliation,
Position,
Race,
RetentionEvent,
+ Role,
School,
Source,
)
@@ -249,17 +254,31 @@ class Meta:
}
-class PartyFilter(NoEmptyFilterSet):
+class PartyFilter(NoEmptyFilterSet, FilterManyToManyMixin):
docket = filters.RelatedFilter(
"cl.search.filters.DocketFilter",
field_name="dockets",
queryset=Docket.objects.all(),
+ distinct=True,
)
attorney = filters.RelatedFilter(
"cl.people_db.filters.AttorneyFilter",
field_name="attorneys",
queryset=Attorney.objects.all(),
+ distinct=True,
)
+ filter_nested_results = filters.BooleanFilter(
+ field_name="roles", method="filter_join_tables"
+ )
+
+ # Attributes for the mixin
+ # **Important:** Keep this mapping up-to-date with any changes to
+ # RelatedFilters or custom labels in this class to avoid unexpected
+ # behavior.
+ join_table_cleanup_mapping = {
+ "dockets": "docket",
+ "attorneys": "attorney",
+ }
class Meta:
model = Party
@@ -270,8 +289,49 @@ class Meta:
"name": ALL_TEXT_LOOKUPS,
}
-
-class AttorneyFilter(NoEmptyFilterSet):
+ def filter_join_tables(
+ self, qs: QuerySet, name: str, value: bool
+ ) -> QuerySet:
+ """
+ Filters a QuerySet based on a many-to-many relationship involving the
+ `Role` and `PartyType` model.
+
+ Args:
+ qs: The original QuerySet to be filtered.
+ name: The name of the field to filter on.
+ value: The value of the request filter.
+
+ Returns:
+ The filtered QuerySet, prefetched with the filtered many-to-many
+ relationship.
+ """
+ if not value:
+ return qs
+
+ filters = self.get_filters_for_join_table()
+ if not filters:
+ return qs
+
+ prefetch_roles = Prefetch(
+ name,
+ queryset=Role.objects.filter(**filters),
+ to_attr=f"filtered_roles",
+ )
+ prefetch_party_types = Prefetch(
+ name,
+ queryset=PartyType.objects.filter(
+ **{
+ key: value
+ for key, value in filters.items()
+ if key.startswith("docket")
+ }
+ ),
+ to_attr=f"filtered_party_types",
+ )
+ return qs.prefetch_related(prefetch_roles, prefetch_party_types)
+
+
+class AttorneyFilter(NoEmptyFilterSet, FilterManyToManyMixin):
docket = filters.RelatedFilter(
"cl.search.filters.DocketFilter",
field_name="roles__docket",
@@ -284,6 +344,18 @@ class AttorneyFilter(NoEmptyFilterSet):
queryset=Party.objects.all(),
distinct=True,
)
+ filter_nested_results = filters.BooleanFilter(
+ field_name="roles", method="filter_roles"
+ )
+
+ # Attributes for the mixin
+ # **Important:** Keep this mapping up-to-date with any changes to
+ # RelatedFilters or custom labels in this class to avoid unexpected
+ # behavior.
+ join_table_cleanup_mapping = {
+ "roles__docket": "docket",
+ "roles__party": "party",
+ }
class Meta:
model = Attorney
@@ -293,3 +365,31 @@ class Meta:
"date_modified": DATETIME_LOOKUPS,
"name": ALL_TEXT_LOOKUPS,
}
+
+ def filter_roles(self, qs: QuerySet, name: str, value: bool) -> QuerySet:
+ """
+ Filters a QuerySet based on a many-to-many relationship involving the
+ `Role` model.
+
+ Args:
+ qs: The original QuerySet to be filtered.
+ name: The name of the many-to-many field to filter on.
+ value: The value to filter the many-to-many relationship with.
+
+ Returns:
+ The filtered QuerySet, prefetched with the filtered many-to-many
+ relationship.
+ """
+ if not value:
+ return qs
+
+ role_filters = self.get_filters_for_join_table()
+ if not role_filters:
+ return qs
+
+ prefetch = Prefetch(
+ name,
+ queryset=Role.objects.filter(**role_filters),
+ to_attr=f"filtered_roles",
+ )
+ return qs.prefetch_related(prefetch)