From 30743d5e7f07c06a7b42ef0ffd528ba2119e9e75 Mon Sep 17 00:00:00 2001
From: Eduardo Rosendo Parties —
{% 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 apply 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"From 69abe4af69651f91932e4a58b65d4a1bf11cd22b Mon Sep 17 00:00:00 2001 From: Eduardo RosendoDate: Tue, 26 Nov 2024 11:19:36 -0400 Subject: [PATCH 08/18] docs(api): Fix typo in Party endpoint warning --- cl/api/templates/pacer-api-docs-vlatest.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cl/api/templates/pacer-api-docs-vlatest.html b/cl/api/templates/pacer-api-docs-vlatest.html index a925f198b8..720a2c25bf 100644 --- a/cl/api/templates/pacer-api-docs-vlatest.html +++ b/cl/api/templates/pacer-api-docs-vlatest.html @@ -201,7 +201,7 @@ Parties —
{% 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 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.
+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 thefilter_nested_results=True
parameter in your API request.For example, this query returns the parties for docket number
From af69b2d526f73ae9e1f85f861aaee489f3877213 Mon Sep 17 00:00:00 2001 From: Eduardo Rosendo123
:Date: Tue, 26 Nov 2024 14:30:21 -0400 Subject: [PATCH 09/18] feat(api): Implements new mixin for filtering to-many relationships --- cl/api/utils.py | 93 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/cl/api/utils.py b/cl/api/utils.py index 45188fbbfe..1b81928880 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 @@ -91,6 +92,96 @@ 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 specific lookups + 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 get_filters_for_join_table( + self: FilterSet, name: str + ) -> 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 = {} + filter_label = self._get_filter_label(name) + base_model_prefix = self._meta.model._meta.model_name + for filter_key, value in self.data.items(): + # Skip custom filtering options triggered by the user + if filter_key.startswith(filter_label): + continue + + cleaned_key = filter_key + # Add base model prefix for fields in Meta class + for basic_field, _ in self._meta.fields.items(): + if cleaned_key.startswith(basic_field): + cleaned_key = ( + f"{base_model_prefix}{LOOKUP_SEP}{filter_key}" + ) + + # Adjust specific lookups for prefetch query compatibility + # + # To ensure correct filtering, we need to modify these lookups to + # reference the appropriate table and field names. + for url_key, new_key in self.join_table_cleanup_mapping.items(): + cleaned_key = cleaned_key.replace(url_key, new_key, 1) + + filters[cleaned_key] = value + + return filters + + class NoEmptyFilterSet(FilterSet): """A custom filterset to ensure we don't get empty filter parameters.""" From ce32722e512550d172841dca0863501edf7d0531 Mon Sep 17 00:00:00 2001 From: Eduardo Rosendo Date: Tue, 26 Nov 2024 14:33:52 -0400 Subject: [PATCH 10/18] refactor(people): Implements mixin for parties and attorneys --- cl/people_db/filters.py | 85 ++++++++++++----------------------------- 1 file changed, 24 insertions(+), 61 deletions(-) diff --git a/cl/people_db/filters.py b/cl/people_db/filters.py index e95d7bf4c1..749ed78cda 100644 --- a/cl/people_db/filters.py +++ b/cl/people_db/filters.py @@ -1,6 +1,5 @@ import rest_framework_filters as filters from django.db.models import Prefetch, QuerySet -from django.db.models.constants import LOOKUP_SEP from cl.api.utils import ( ALL_TEXT_LOOKUPS, @@ -8,6 +7,7 @@ DATE_LOOKUPS, DATETIME_LOOKUPS, INTEGER_LOOKUPS, + FilterManyToManyMixin, NoEmptyFilterSet, ) from cl.people_db.lookup_utils import lookup_judge_by_name_components @@ -251,7 +251,7 @@ class Meta: } -class PartyFilter(NoEmptyFilterSet): +class PartyFilter(NoEmptyFilterSet, FilterManyToManyMixin): docket = filters.RelatedFilter( "cl.search.filters.DocketFilter", field_name="dockets", @@ -268,6 +268,15 @@ class PartyFilter(NoEmptyFilterSet): 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 = { + "attorney__docket": "docket", + "attorney__parties_represented": "party", + } + class Meta: model = Party fields = { @@ -281,32 +290,7 @@ def filter_roles(self, qs, name, value): if not value: return qs - role_filters = {} - for filter_key, value in self.data.items(): - # Skip custom filtering options triggered by the user - if filter_key.startswith("filter_nested_results"): - continue - - cleaned_key = filter_key - # Add "party" prefix for fields in Meta class - for basic_field, _ in self._meta.fields.items(): - if cleaned_key.startswith(basic_field): - cleaned_key = f"party{LOOKUP_SEP}{filter_key}" - - # Adjust specific lookups for prefetch query compatibility - # - # The `AttorneyFilter` class is designed to work with the `roles` - # table. However, the `attorney__docket` and `attorney__parties_represented` - # lookups reference the `roles__docket` and `roles__party` fields, - # respectively. - # - # To ensure correct filtering, we need to modify these lookups to - # reference the appropriate table and field names. - cleaned_key = cleaned_key.replace("attorney__docket", "docket", 1) - cleaned_key = cleaned_key.replace( - "attorney__parties_represented", "party", 1 - ) - role_filters[cleaned_key] = value + role_filters = self.get_filters_for_join_table(name) prefetch = Prefetch( name, @@ -316,7 +300,7 @@ def filter_roles(self, qs, name, value): return qs.prefetch_related(prefetch) -class AttorneyFilter(NoEmptyFilterSet): +class AttorneyFilter(NoEmptyFilterSet, FilterManyToManyMixin): docket = filters.RelatedFilter( "cl.search.filters.DocketFilter", field_name="roles__docket", @@ -333,6 +317,16 @@ class AttorneyFilter(NoEmptyFilterSet): 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 = { + "parties_represented__docket": "docket", + "parties_represented__attorney": "attorney", + "parties_represented": "parties", + } + class Meta: model = Attorney fields = { @@ -346,38 +340,7 @@ def filter_roles(self, qs, name, value): if not value: return qs - role_filters = {} - for filter_key, value in self.data.items(): - # Skip custom filtering options triggered by the user - if filter_key.startswith("filter_nested_results"): - continue - - cleaned_key = filter_key - # Add "party" prefix for fields in Meta class without lookup separator - # Add "party" prefix for fields in Meta class - for basic_field, _ in self._meta.fields.items(): - if cleaned_key.startswith(basic_field): - cleaned_key = f"attorney{LOOKUP_SEP}{filter_key}" - - # Adjust specific lookups for prefetch query compatibility - # - # The `PartyFilter` class is designed to work with the `roles` - # table. However, the `parties_represented`, `parties_represented__docket` - # and `parties_represented__attorney` lookups reference the `party`, - # `docket` and `attorney` fields, respectively. - # - # To ensure correct filtering, we need to modify these lookups to - # reference the appropriate table and field names. - cleaned_key = cleaned_key.replace( - "parties_represented__docket", "docket", 1 - ) - cleaned_key = cleaned_key.replace( - "parties_represented__attorney", "attorney", 1 - ) - cleaned_key = cleaned_key.replace( - "parties_represented", "party", 1 - ) - role_filters[cleaned_key] = value + role_filters = self.get_filters_for_join_table(name) prefetch = Prefetch( name, From e2b165ddf5f5034275ce5ee7ba69b7280f5864c4 Mon Sep 17 00:00:00 2001 From: Eduardo Rosendo Date: Thu, 28 Nov 2024 15:11:00 -0400 Subject: [PATCH 11/18] fix(parties): Updates mapping to clean up keys --- cl/people_db/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cl/people_db/filters.py b/cl/people_db/filters.py index 749ed78cda..259430313c 100644 --- a/cl/people_db/filters.py +++ b/cl/people_db/filters.py @@ -324,7 +324,7 @@ class AttorneyFilter(NoEmptyFilterSet, FilterManyToManyMixin): join_table_cleanup_mapping = { "parties_represented__docket": "docket", "parties_represented__attorney": "attorney", - "parties_represented": "parties", + "parties_represented": "party", } class Meta: From a3ebe6fcd109a2269b4df3f3a5f0b3b5ff2559d2 Mon Sep 17 00:00:00 2001 From: Eduardo Rosendo Date: Thu, 28 Nov 2024 17:16:51 -0400 Subject: [PATCH 12/18] fix(api): Ignored invalid filters using form.cleaned_data --- cl/api/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cl/api/utils.py b/cl/api/utils.py index 1b81928880..bd55929ed2 100644 --- a/cl/api/utils.py +++ b/cl/api/utils.py @@ -157,7 +157,7 @@ def get_filters_for_join_table( filters = {} filter_label = self._get_filter_label(name) base_model_prefix = self._meta.model._meta.model_name - for filter_key, value in self.data.items(): + for filter_key, value in self.form.cleaned_data.items(): # Skip custom filtering options triggered by the user if filter_key.startswith(filter_label): continue From 3b8f99a04fc86ec1d8c31ff4e2c45f1174ee3b5c Mon Sep 17 00:00:00 2001 From: Eduardo Rosendo Date: Fri, 29 Nov 2024 15:28:24 -0400 Subject: [PATCH 13/18] feat(api): Refine FilterManyToManyMixin to ignore redundant fields This commit refactors the get_filters_for_join_table method to prevent the inclusion of redundant fields from base models. --- cl/api/utils.py | 58 +++++++++++++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/cl/api/utils.py b/cl/api/utils.py index bd55929ed2..266bb98b4b 100644 --- a/cl/api/utils.py +++ b/cl/api/utils.py @@ -28,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, @@ -136,6 +137,26 @@ def _get_filter_label(self: FilterSet, field_name: str) -> str: } 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, name: str ) -> dict[str, Any]: @@ -154,30 +175,25 @@ def get_filters_for_join_table( dict: A dictionary containing the filtered criteria for the join table query. """ - filters = {} + filters: dict[str, Any] = {} filter_label = self._get_filter_label(name) - base_model_prefix = self._meta.model._meta.model_name - for filter_key, value in self.form.cleaned_data.items(): - # Skip custom filtering options triggered by the user - if filter_key.startswith(filter_label): + # 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 - cleaned_key = filter_key - # Add base model prefix for fields in Meta class - for basic_field, _ in self._meta.fields.items(): - if cleaned_key.startswith(basic_field): - cleaned_key = ( - f"{base_model_prefix}{LOOKUP_SEP}{filter_key}" - ) - - # Adjust specific lookups for prefetch query compatibility - # - # To ensure correct filtering, we need to modify these lookups to - # reference the appropriate table and field names. - for url_key, new_key in self.join_table_cleanup_mapping.items(): - cleaned_key = cleaned_key.replace(url_key, new_key, 1) - - filters[cleaned_key] = value + # Create a dictionary to store cleaned keys and values + cleaned_keys = { + self._clean_join_table_key(f"{prefix}{key}"): value + for key, value in related_filterset.form.cleaned_data.items() + # Only include keys with values and not starting with the filter label + if value and not key.startswith(filter_label) + } + # Update the filters with the cleaned keys + filters = filters | cleaned_keys return filters From ef95222b794bf6b72690a703d9879dd611aa7e8f Mon Sep 17 00:00:00 2001 From: Eduardo Rosendo Date: Fri, 29 Nov 2024 16:07:16 -0400 Subject: [PATCH 14/18] feat(people): Extract role filtering logic into RoleFilteringMixin --- cl/people_db/filters.py | 67 ++++++++++++++++++++++++----------------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/cl/people_db/filters.py b/cl/people_db/filters.py index 259430313c..b865fbfc7c 100644 --- a/cl/people_db/filters.py +++ b/cl/people_db/filters.py @@ -1,3 +1,5 @@ +from typing import Any + import rest_framework_filters as filters from django.db.models import Prefetch, QuerySet @@ -251,7 +253,42 @@ class Meta: } -class PartyFilter(NoEmptyFilterSet, FilterManyToManyMixin): +class RoleFilteringMixin(FilterManyToManyMixin): + + def filter_roles( + filterset: FilterManyToManyMixin, qs: QuerySet, name: str, value: Any + ) -> QuerySet: + """ + Filters a QuerySet based on a many-to-many relationship involving the + `Role` model. + + Args: + filterset: An instance of `FilterManyToManyMixin` used to construct + filters. + 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 = filterset.get_filters_for_join_table(name) + if not role_filters: + return qs + + prefetch = Prefetch( + name, + queryset=Role.objects.filter(**role_filters), + to_attr=f"filtered_{name}", + ) + return qs.prefetch_related(prefetch) + + +class PartyFilter(NoEmptyFilterSet, RoleFilteringMixin): docket = filters.RelatedFilter( "cl.search.filters.DocketFilter", field_name="dockets", @@ -286,21 +323,8 @@ class Meta: "name": ALL_TEXT_LOOKUPS, } - def filter_roles(self, qs, name, value): - if not value: - return qs - - role_filters = self.get_filters_for_join_table(name) - prefetch = Prefetch( - name, - queryset=Role.objects.filter(**role_filters), - to_attr=f"filtered_{name}", - ) - return qs.prefetch_related(prefetch) - - -class AttorneyFilter(NoEmptyFilterSet, FilterManyToManyMixin): +class AttorneyFilter(NoEmptyFilterSet, RoleFilteringMixin): docket = filters.RelatedFilter( "cl.search.filters.DocketFilter", field_name="roles__docket", @@ -335,16 +359,3 @@ class Meta: "date_modified": DATETIME_LOOKUPS, "name": ALL_TEXT_LOOKUPS, } - - def filter_roles(self, qs, name, value): - if not value: - return qs - - role_filters = self.get_filters_for_join_table(name) - - prefetch = Prefetch( - name, - queryset=Role.objects.filter(**role_filters), - to_attr=f"filtered_{name}", - ) - return qs.prefetch_related(prefetch) From 58a6e6b56fd9ed0018fe624cf5579d05f1be1a25 Mon Sep 17 00:00:00 2001 From: Eduardo Rosendo Date: Wed, 4 Dec 2024 18:06:10 -0400 Subject: [PATCH 15/18] feat(api): Refines FilterManyToManyMixin for efficient filtering This commit improves the `FilterManyToManyMixin` class by updating the method used to retrieve the set of filters for prefetching middle table relationships. This change enhances performance and reduces complexity. --- cl/api/utils.py | 46 +++++++++++++++++++++++++++++++---------- cl/people_db/filters.py | 9 ++++---- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/cl/api/utils.py b/cl/api/utils.py index 266bb98b4b..b8048bbb66 100644 --- a/cl/api/utils.py +++ b/cl/api/utils.py @@ -103,7 +103,7 @@ class FilterManyToManyMixin: many-to-many relationships. **Required Properties:** - - **`join_table_cleanup_mapping`**: A dictionary mapping specific lookups + - **`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. @@ -176,7 +176,6 @@ def get_filters_for_join_table( table query. """ filters: dict[str, Any] = {} - filter_label = self._get_filter_label(name) # Iterate over related filtersets for related_name, related_filterset in self.related_filtersets.items(): prefix = f"{related(self, related_name)}{LOOKUP_SEP}" @@ -185,15 +184,40 @@ def get_filters_for_join_table( # Skip processing if no parameter starts with the prefix continue - # Create a dictionary to store cleaned keys and values - cleaned_keys = { - self._clean_join_table_key(f"{prefix}{key}"): value - for key, value in related_filterset.form.cleaned_data.items() - # Only include keys with values and not starting with the filter label - if value and not key.startswith(filter_label) - } - # Update the filters with the cleaned keys - filters = filters | cleaned_keys + # 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 diff --git a/cl/people_db/filters.py b/cl/people_db/filters.py index b865fbfc7c..daa3536689 100644 --- a/cl/people_db/filters.py +++ b/cl/people_db/filters.py @@ -310,8 +310,8 @@ class PartyFilter(NoEmptyFilterSet, RoleFilteringMixin): # RelatedFilters or custom labels in this class to avoid unexpected # behavior. join_table_cleanup_mapping = { - "attorney__docket": "docket", - "attorney__parties_represented": "party", + "dockets": "docket", + "attorneys": "attorney", } class Meta: @@ -346,9 +346,8 @@ class AttorneyFilter(NoEmptyFilterSet, RoleFilteringMixin): # RelatedFilters or custom labels in this class to avoid unexpected # behavior. join_table_cleanup_mapping = { - "parties_represented__docket": "docket", - "parties_represented__attorney": "attorney", - "parties_represented": "party", + "roles__docket": "docket", + "roles__party": "party", } class Meta: From 130ab240dfa06769d1ffc944957be9252eabeb17 Mon Sep 17 00:00:00 2001 From: Eduardo Rosendo Date: Wed, 4 Dec 2024 23:32:25 -0400 Subject: [PATCH 16/18] feat(people): Add filtered party types to PartyFilter --- cl/api/tests.py | 52 +++++++++++++++ cl/people_db/api_serializers.py | 12 +++- cl/people_db/filters.py | 111 +++++++++++++++++++++----------- 3 files changed, 136 insertions(+), 39 deletions(-) diff --git a/cl/api/tests.py b/cl/api/tests.py index 716c2c0ff7..574125443a 100644 --- a/cl/api/tests.py +++ b/cl/api/tests.py @@ -1436,6 +1436,58 @@ async def test_party_filters(self) -> None: 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) diff --git a/cl/people_db/api_serializers.py b/cl/people_db/api_serializers.py index a7a45f3449..055f72a7ec 100644 --- a/cl/people_db/api_serializers.py +++ b/cl/people_db/api_serializers.py @@ -257,7 +257,7 @@ class Meta: class PartySerializer(DynamicFieldsMixin, HyperlinkedModelSerializerWithId): attorneys = serializers.SerializerMethodField() - party_types = PartyTypeSerializer(many=True) + party_types = serializers.SerializerMethodField() def get_attorneys(self, obj: Party): if hasattr(obj, "filtered_roles"): @@ -269,6 +269,16 @@ def get_attorneys(self, obj: Party): 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 fields = "__all__" diff --git a/cl/people_db/filters.py b/cl/people_db/filters.py index daa3536689..defbbcdca9 100644 --- a/cl/people_db/filters.py +++ b/cl/people_db/filters.py @@ -18,6 +18,7 @@ Attorney, Education, Party, + PartyType, Person, PoliticalAffiliation, Position, @@ -253,42 +254,7 @@ class Meta: } -class RoleFilteringMixin(FilterManyToManyMixin): - - def filter_roles( - filterset: FilterManyToManyMixin, qs: QuerySet, name: str, value: Any - ) -> QuerySet: - """ - Filters a QuerySet based on a many-to-many relationship involving the - `Role` model. - - Args: - filterset: An instance of `FilterManyToManyMixin` used to construct - filters. - 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 = filterset.get_filters_for_join_table(name) - if not role_filters: - return qs - - prefetch = Prefetch( - name, - queryset=Role.objects.filter(**role_filters), - to_attr=f"filtered_{name}", - ) - return qs.prefetch_related(prefetch) - - -class PartyFilter(NoEmptyFilterSet, RoleFilteringMixin): +class PartyFilter(NoEmptyFilterSet, FilterManyToManyMixin): docket = filters.RelatedFilter( "cl.search.filters.DocketFilter", field_name="dockets", @@ -302,7 +268,7 @@ class PartyFilter(NoEmptyFilterSet, RoleFilteringMixin): distinct=True, ) filter_nested_results = filters.BooleanFilter( - field_name="roles", method="filter_roles" + field_name="roles", method="filter_join_tables" ) # Attributes for the mixin @@ -323,8 +289,49 @@ class Meta: "name": ALL_TEXT_LOOKUPS, } + 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. -class AttorneyFilter(NoEmptyFilterSet, RoleFilteringMixin): + 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(name) + 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", @@ -358,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(name) + if not role_filters: + return qs + + prefetch = Prefetch( + name, + queryset=Role.objects.filter(**role_filters), + to_attr=f"filtered_{name}", + ) + return qs.prefetch_related(prefetch) From a459179a6f4d51b88679a8fdf979b77829a29da2 Mon Sep 17 00:00:00 2001 From: Eduardo Rosendo Date: Fri, 6 Dec 2024 15:23:08 -0400 Subject: [PATCH 17/18] feat(api): Removes unused argument --- cl/api/utils.py | 4 +--- cl/people_db/filters.py | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/cl/api/utils.py b/cl/api/utils.py index b8048bbb66..570c48c769 100644 --- a/cl/api/utils.py +++ b/cl/api/utils.py @@ -157,9 +157,7 @@ def _clean_join_table_key(self, key: str) -> str: join_table_key = join_table_key.replace(url_key, new_key, 1) return join_table_key - def get_filters_for_join_table( - self: FilterSet, name: str - ) -> dict[str, Any]: + def get_filters_for_join_table(self: FilterSet) -> dict[str, Any]: """ Processes request filters for use in a join table query. diff --git a/cl/people_db/filters.py b/cl/people_db/filters.py index defbbcdca9..30a34a1220 100644 --- a/cl/people_db/filters.py +++ b/cl/people_db/filters.py @@ -308,7 +308,7 @@ def filter_join_tables( if not value: return qs - filters = self.get_filters_for_join_table(name) + filters = self.get_filters_for_join_table() if not filters: return qs @@ -383,7 +383,7 @@ def filter_roles(self, qs: QuerySet, name: str, value: bool) -> QuerySet: if not value: return qs - role_filters = self.get_filters_for_join_table(name) + role_filters = self.get_filters_for_join_table() if not role_filters: return qs From 4b686e3f2df3b1c4c8ca1095f1f67329beaba7cf Mon Sep 17 00:00:00 2001 From: Eduardo Rosendo Date: Fri, 6 Dec 2024 17:13:23 -0400 Subject: [PATCH 18/18] feat(people): Use static name for prefetched results --- cl/people_db/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cl/people_db/filters.py b/cl/people_db/filters.py index 30a34a1220..8974dadb8c 100644 --- a/cl/people_db/filters.py +++ b/cl/people_db/filters.py @@ -390,6 +390,6 @@ def filter_roles(self, qs: QuerySet, name: str, value: bool) -> QuerySet: prefetch = Prefetch( name, queryset=Role.objects.filter(**role_filters), - to_attr=f"filtered_{name}", + to_attr=f"filtered_roles", ) return qs.prefetch_related(prefetch)