diff --git a/cl/alerts/api_serializers.py b/cl/alerts/api_serializers.py index acbed74492..5a588dbf86 100644 --- a/cl/alerts/api_serializers.py +++ b/cl/alerts/api_serializers.py @@ -1,6 +1,5 @@ from django.core.exceptions import ValidationError from django.http import QueryDict -from drf_dynamic_fields import DynamicFieldsMixin from rest_framework import serializers from cl.alerts.models import ( @@ -10,7 +9,7 @@ validate_recap_alert_type, ) from cl.alerts.utils import is_match_all_query -from cl.api.utils import HyperlinkedModelSerializerWithId +from cl.api.utils import DynamicFieldsMixin, HyperlinkedModelSerializerWithId from cl.search.models import SEARCH_TYPES diff --git a/cl/api/tests.py b/cl/api/tests.py index 78ad482efe..2827f87167 100644 --- a/cl/api/tests.py +++ b/cl/api/tests.py @@ -17,14 +17,17 @@ from django.core.management import call_command from django.db import connection from django.http import HttpRequest, JsonResponse -from django.test import SimpleTestCase, override_settings +from django.test import RequestFactory, SimpleTestCase, override_settings from django.test.client import AsyncClient, AsyncRequestFactory from django.test.utils import CaptureQueriesContext from django.urls import reverse from django.utils.timezone import now +from rest_framework import status +from rest_framework.authtoken.models import Token from rest_framework.exceptions import NotFound from rest_framework.pagination import Cursor, CursorPagination from rest_framework.request import Request +from rest_framework.response import Response from rest_framework.test import APIRequestFactory from cl.alerts.api_views import DocketAlertViewSet, SearchAlertViewSet @@ -98,9 +101,11 @@ ) from cl.search.factories import ( CourtFactory, + DocketEntryFactory, DocketFactory, OpinionClusterWithChildrenAndParentsFactory, OpinionClusterWithParentsFactory, + RECAPDocumentFactory, ) from cl.search.models import ( PRECEDENTIAL_STATUS, @@ -109,7 +114,9 @@ ClusterRedirection, Court, Docket, + DocketEntry, Opinion, + RECAPDocument, ) from cl.stats.models import Event from cl.tests.cases import ( @@ -3779,6 +3786,254 @@ def test_can_increment_exiting_events(self): self.assertEqual(event_record.value, 4) +class DeferredDocketEntryTestMixin: + def list(self, request, *args, **kwargs): + qs = self.get_queryset() + deferred_or_only_fields, _ = qs.query.deferred_loading + return Response( + { + "deferred_or_only": set(deferred_or_only_fields), + "prefetches": qs._prefetch_related_lookups, + }, + status=status.HTTP_200_OK, + ) + + +class DeferredDocketEntryViewSet( + DeferredDocketEntryTestMixin, DocketEntryViewSet +): + queryset = ( + DocketEntry.objects.select_related( + "docket", + ) + .prefetch_related( + "recap_documents__tags", + "tags", + ) + .defer("recap_sequence_number") + .order_by("-id") + ) + + +class DeferredDocketEntryOnlyViewSet( + DeferredDocketEntryTestMixin, DocketEntryViewSet +): + queryset = ( + DocketEntry.objects.select_related( + "docket", + ) + .prefetch_related( + "recap_documents__tags", + "tags", + ) + .only("id", "recap_sequence_number") + .order_by("-id") + ) + + +class DynamicNestedFieldsMixinTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.factory = RequestFactory() + cls.view = DeferredDocketEntryViewSet.as_view({"get": "list"}) + cls.view_2 = DeferredDocketEntryOnlyViewSet.as_view({"get": "list"}) + + cls.user_1 = UserProfileWithParentsFactory.create( + user__username="recap-user", + user__password=make_password("password"), + ) + ps = Permission.objects.filter(codename="has_recap_api_access") + ps_upload = Permission.objects.filter( + codename="has_recap_upload_access" + ) + cls.user_1.user.user_permissions.add(*ps) + cls.user_1.user.user_permissions.add(*ps_upload) + cls.court = CourtFactory(id="canb", jurisdiction="FB") + + cls.docket = DocketFactory( + source=Docket.RECAP, + court=cls.court, + docket_number="23-4567", + pacer_case_id="104490", + ) + cls.rd = RECAPDocumentFactory( + docket_entry=DocketEntryFactory( + docket=cls.docket, + ), + document_number="1", + is_available=True, + is_free_on_pacer=True, + page_count=17, + pacer_doc_id="17711118263", + document_type=RECAPDocument.PACER_DOCUMENT, + ocr_status=4, + ) + token, _ = Token.objects.get_or_create(user=cls.user_1.user) + cls.token, _ = Token.objects.get_or_create(user=cls.user_1.user) + + async def _api_v4_request(self, endpoint, params): + url = reverse(endpoint, kwargs={"version": "v4"}) + api_client = await sync_to_async(make_client)(self.user_1.user.pk) + return await api_client.get(url, params) + + async def test_omit_nested_field(self) -> None: + """Confirm that specifying 'omit' for parent or nested fields excludes + them from the API response. + """ + response = await self._api_v4_request( + "docketentry-list", + {"omit": "entry_number,recap_documents__plain_text"}, + ) + results = response.json()["results"] + self.assertEqual(len(results), 1) + + # Assert top‐level keys exactly match + self.assertNotIn("entry_number", set(results[0].keys())) + + # Assert nested fields. + self.assertNotIn( + "plain_text", set(results[0]["recap_documents"][0].keys()) + ) + + async def test_allowed_nested_field(self) -> None: + """Confirm that specifying 'fields' for parent or nested fields filters + the API response to include only those fields. + """ + response = await self._api_v4_request( + "docketentry-list", + {"fields": "entry_number,recap_documents__plain_text"}, + ) + results = response.json()["results"] + self.assertEqual(len(results), 1) + + # Assert top‐level keys exactly match + self.assertEqual(len(results[0].keys()), 2) + self.assertIn("entry_number", set(results[0].keys())) + + # Assert nested fields. + self.assertEqual(len(results[0]["recap_documents"][0].keys()), 1) + self.assertIn( + "plain_text", set(results[0]["recap_documents"][0].keys()) + ) + + async def test_fields_all_gone_nested(self): + """If no fields are selected, all fields are omitted, including those + from the nested serializer. + """ + response = await self._api_v4_request( + "docketentry-list", + {"fields": ""}, + ) + results = response.json()["results"] + self.assertEqual(len(results), 1) + + # Assert top‐level keys exactly match + self.assertEqual(len(results[0].keys()), 0) + + async def test_nested_omit_and_fields_used(self): + """Omit and fields can be used together at the nested field level.""" + response = await self._api_v4_request( + "docketentry-list", + { + "fields": "id,entry_number,description,recap_documents__plain_text,recap_documents__id", + "omit": "description,recap_documents__plain_text", + }, + ) + + results = response.json()["results"] + self.assertEqual(len(results), 1) + + # Assert top‐level keys exactly match + self.assertEqual(len(results[0].keys()), 3) + self.assertEqual( + set(results[0].keys()), {"id", "entry_number", "recap_documents"} + ) + + # Assert nested fields. + self.assertEqual(len(results[0]["recap_documents"][0].keys()), 1) + self.assertEqual(set(results[0]["recap_documents"][0].keys()), {"id"}) + + def test_deffer_fields_custom_queryset(self): + """Confirms that the deferring fields logic works correctly with a custom + queryset that uses select_related and prefetch_related. + """ + request = self.factory.get( + "/", + { + "fields": "id__test,id,entry_number,recap_sequence_number,description,recap_documents__plain_text,recap_documents__id", + }, + HTTP_AUTHORIZATION=f"Token {self.token.key}", + ) + response = self.view(request) + self.assertEqual(response.status_code, 200) + + deferred_fields = response.data["deferred_or_only"] + self.assertEqual( + deferred_fields, + { + "date_filed", + "date_created", + "date_modified", + "pacer_sequence_number", + "recap_sequence_number", + "time_filed", + "tags", + }, + ) + + prefetches = response.data["prefetches"] + self.assertEqual(len(prefetches), 3) + self.assertIn("recap_documents__tags", prefetches) + self.assertIn("tags", prefetches) + + def test_deffer_omit_fields_custom_queryset(self): + """Confirms that the deferring fields logic works correctly with a custom + queryset that uses select_related and prefetch_related. + """ + request = self.factory.get( + "/", + { + "omit": "entry_number,description,recap_documents__plain_text,recap_documents__pacer_doc_id", + }, + HTTP_AUTHORIZATION=f"Token {self.token.key}", + ) + response = self.view(request) + self.assertEqual(response.status_code, 200) + + deferred_fields = response.data["deferred_or_only"] + self.assertEqual( + deferred_fields, + {"entry_number", "description", "recap_sequence_number"}, + ) + + prefetches = response.data["prefetches"] + self.assertEqual(len(prefetches), 3) + self.assertIn("recap_documents__tags", prefetches) + self.assertIn("tags", prefetches) + + def test_deffer_fields_custom_queryset_with_only(self): + """Confirms that the deferring fields logic doesn't modify original + only statements in the queryset. + """ + request = self.factory.get( + "/", + { + "fields": "id,entry_number,recap_sequence_number,description,recap_documents__plain_text,recap_documents__id", + }, + HTTP_AUTHORIZATION=f"Token {self.token.key}", + ) + response = self.view_2(request) + self.assertEqual(response.status_code, 200) + + only_fields = response.data["deferred_or_only"] + self.assertEqual(only_fields, {"id", "recap_sequence_number"}) + + prefetches = response.data["prefetches"] + self.assertEqual(len(prefetches), 3) + self.assertIn("recap_documents__tags", prefetches) + self.assertIn("tags", prefetches) + + class ClusterRedirectionTest(TestCase): @classmethod def setUpTestData(cls): diff --git a/cl/api/utils.py b/cl/api/utils.py index 7824222658..d60f195e03 100644 --- a/cl/api/utils.py +++ b/cl/api/utils.py @@ -1,5 +1,7 @@ import logging +import warnings from collections import OrderedDict, defaultdict +from collections.abc import Callable from datetime import UTC, date, datetime, timedelta from itertools import batched, chain from typing import Any, TypedDict @@ -12,11 +14,12 @@ from django.contrib.humanize.templatetags.humanize import intcomma, ordinal from django.core.cache import caches from django.core.cache.backends.base import BaseCache -from django.db.models import F +from django.db.models import F, Model, Prefetch, QuerySet 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 +from django.utils.functional import cached_property from django.utils.timezone import now from django.views.decorators.cache import cache_page from django.views.decorators.vary import vary_on_headers @@ -1218,3 +1221,465 @@ def handle_webhook_events(results: list[int | float], user: User) -> None: f"{intcomma(ordinal(user_count))} webhook event.", user=user, ) + + +class DynamicFieldsMixin: + """ + A serializer mixin that takes an additional `fields` argument that controls + which fields should be displayed. + """ + + @property + def prevent_nested_processing(self) -> bool: + """True when this serializer is not the root nor a root’s list-child.""" + return not ( + self is self.root # type: ignore[attr-defined] + or ( + self.parent is self.root # type: ignore[attr-defined] + and getattr(self.parent, "many", False) # type: ignore[attr-defined] + ) + ) + + @cached_property + def fields(self): + """ + Filters the fields according to the `fields` query parameter. + + A blank `fields` parameter (?fields) will remove all fields. Not + passing `fields` will pass all fields individual fields are comma + separated (?fields=id,name,url,email). + + """ + fields = super(DynamicFieldsMixin, self).fields + + if not hasattr(self, "_context"): + # We are being called before a request cycle + return fields + + if self.prevent_nested_processing: + return fields + + try: + request = getattr(self, "context", {})["request"] + except KeyError: + conf = getattr(settings, "DRF_DYNAMIC_FIELDS", {}) + if conf.get("SUPPRESS_CONTEXT_WARNING", False) is not True: + warnings.warn( + "Context does not have access to request. " + "See README for more information." + ) + return fields + + params = request.GET + source = get_source_path(self) + level = compute_level(self) + + filter_fields = self.get_filter_fields( + params.get("fields", None), level, source + ) + omit_fields = self.get_omit_fields( + params.get("omit", None), level, source + ) + + # Drop any fields that are not specified in the `fields` argument. + existing = set(fields.keys()) + if filter_fields is None: + # no fields param given, don't filter. + allowed = existing + else: + allowed = set(filter(None, filter_fields)) + + # omit fields in the `omit` argument. + omitted = set(filter(None, omit_fields)) + + for field in existing: + if field not in allowed: + fields.pop(field, None) + + if field in omitted: + fields.pop(field, None) + + return fields + + def get_filter_fields( + self, params, level, source, default=None, include_parent=True + ): + try: + return params.split(",") + except AttributeError: + return default + + def get_omit_fields(self, params, level, source): + return self.get_filter_fields( + params, level, source, default=[], include_parent=False + ) + + +class NestedDynamicFieldsMixin(DynamicFieldsMixin): + """A serializer mixin that extends DynamicFieldsMixin to allow nested serializers + to filter their fields based on the original `fields` query parameter. + + Unlike the base mixin—which only applies filtering at the root serializer, + this subclass: + + - Disables the `prevent_nested_processing` guard, allowing each level of nested + serializer to apply field filtering independently. + - Overrides `get_filter_fields` to slice the raw `fields` string + down to exactly those names relevant at this serializer’s + current nesting depth (using get_fields_for_level_and_prefix). + - `get_filter_fields` first delegates to the super method for splitting + the comma‐separated string, then calls a helper that: + • Selects only the fields that are nested under this serializer's path in + the hierarchy + • Returns direct children at depth `level + 1` + """ + + @property + def prevent_nested_processing(self): + return False + + def get_filter_fields( + self, params, level, source, default=None, include_parent=True + ): + """ + Parse the raw `fields` parameter and return the subset of fields + that apply at this serializer’s nesting level under the given + source prefix. + """ + fields = super().get_filter_fields( + params, level, source, default, include_parent + ) + return get_fields_for_level_and_prefix( + fields, + level, + source, + default=default, + include_parent=include_parent, + ) + + +def get_source_path(serializer) -> str: + """Recursively walks up the serializer tree to build the nested field path.""" + parent = getattr(serializer, "parent", None) + if not parent: + return "" + parent_path = get_source_path(parent) + name = getattr(serializer, "field_name", None) + if not name: + return parent_path + return f"{parent_path}__{name}" if parent_path else name + + +def get_fields_for_level_and_prefix( + fields_list, level, source, include_parent, default +): + """Extract the field names relevant to a specific nesting depth + from a list of double‑underscore lookup strings. + """ + if not fields_list: + return default + + prefix = source.split("__") if source else [] + allowed = set() + for f in fields_list: + parts = f.split("__") + + if parts[:level] != prefix: + continue + + if len(parts) <= level + 1: + allowed.add(parts[-1]) + continue + + if len(parts) > level + 1 and include_parent: + # include parent field to ensure nesting proceeds + allowed.add(parts[level]) + continue + + # If the only allowed fields are exactly the prefix itself, + # fall back to default + if allowed == set(prefix): + return default + + return allowed + + +def compute_level(serializer) -> int: + """Recursively count how many ancestors of `serializer` are not + ListSerializer instances. Stops when parent is None. + """ + parent = getattr(serializer, "parent", None) + if parent is None: + # base case, reached the top + return 0 + + # if this immediate parent is a ListSerializer, don’t count it, otherwise 1 + this_level = 0 if isinstance(parent, serializers.ListSerializer) else 1 + + # recurse on the parent itself + return this_level + compute_level(parent) + + +class RetrieveFilteredFieldsMixin: + @staticmethod + def _get_concrete_fields_for_model(model: type[Model]) -> list[str]: + return [ + f.name + for f in model._meta.get_fields() + if getattr(f, "concrete", False) + ] + + def _filter_top_level_fields_to_defer( + self, field_list: set[str] | None, keep_if: Callable + ) -> list[str]: + """Method to retrieve the top-level fields to defer, given a list of + field names (allow or omit) and a condition that determines which + fields to defer. + + :param field_list: A list of field names to filter by. + :param keep_if: A function that takes a field name and returns a + boolean indicating whether to defer the field. + :return: A list of top field names to defer + """ + meta = getattr(self, "Meta", None) + model = getattr(meta, "model", None) + if not field_list or model is None: + return [] + + all_fields = self._get_concrete_fields_for_model(model) + return [name for name in all_fields if keep_if(name, field_list)] + + def _get_disallowed_top_level_fields_to_defer(self) -> list[str]: + """Determine which top-level model fields should be deferred when an + explicit fields filter is in use. + Other model fields not explicitly included in 'fields' are deferred. + + :return: A list of disallowed top field names to defer + """ + allow = getattr(self, "_flat_allow", None) + return self._filter_top_level_fields_to_defer( + allow, keep_if=lambda name, allow: name not in allow + ) + + def _get_omit_top_level_fields_to_defer(self) -> list[str]: + """ + Determine which top-level model fields should be deferred when an + explicit omit filter is in use. Valid database fields in the omit list + will be deferred. + :return: A list of omit top field names to defer + """ + omit = getattr(self, "_flat_omit", None) + return self._filter_top_level_fields_to_defer( + omit, keep_if=lambda name, omit: name in omit + ) + + def _get_nested_level_fields_to_defer( + self, + nested_mapping: defaultdict[str, list[str]] | dict, + should_defer: Callable, + ) -> dict[str, tuple[type[Model], list[str]]]: + """Method to retrieve nested fields to defer based on a mapping and a + defer condition. + + :param nested_mapping: A nested mapping from fields to defer + :param should_defer: A function that takes a field name and returns a + boolean indicating whether to defer the field. + :return: A dict containing the mapping of nested fields to defer. + """ + + nested_defer_map: dict[str, tuple[type[Model], list[str]]] = {} + for parent, items in nested_mapping.items(): + field = getattr(self, "fields", {}).get(parent) + if not field: + continue + + child_serializer = getattr(field, "child", field) + meta = getattr(child_serializer, "Meta", None) + nested_model = ( + getattr(meta, "model", None) if meta is not None else None + ) + if nested_model is None: + continue + + child_names: list[str] = [] + # Filter out nested fields that have a database column associated + # with them. + field_names = self._get_concrete_fields_for_model(nested_model) + # Determine which nested fields to defer + for name in field_names: + if should_defer(name, items): + child_names.append(name) + + if child_names: + nested_defer_map[parent] = (nested_model, child_names) + + return nested_defer_map + + def _get_disallowed_nested_level_fields_to_defer( + self, + ) -> dict[str, tuple[type[Model], list[str]]]: + """Determine which top-level model fields should be deferred when an + explicit fields filter is in use. + Other model fields not explicitly included in 'fields' are deferred. + + :return: A dict containing the mapping of nested fields to defer. + """ + allow_map = getattr(self, "_nested_allow", {}) + return self._get_nested_level_fields_to_defer( + allow_map, + should_defer=lambda name, allow_list: name not in allow_list, + ) + + def _get_omit_nested_level_fields_to_defer( + self, + ) -> dict[str, tuple[type[Model], list[str]]]: + """ + Determine which nested-model fields should be deferred for each nested + serializer when an explicit omit filter is in use. + + :return: A dict containing the mapping of nested fields to defer. + """ + omit_map = getattr(self, "_nested_omit", {}) + return self._get_nested_level_fields_to_defer( + omit_map, should_defer=lambda name, omit_list: name in omit_list + ) + + def get_deferred_model_fields( + self, + ) -> tuple[list[str], dict[str, tuple[type[Model], list[str]]]]: + """ + Returns a flat list of omitted model-fields; top-level and nested. + Ensures that parsing of "fields"/"omit" has run by accessing ".fields". + + :return: A two tuple of top fields names to defer and a dict containing + the mapping of nested fields to defer. + """ + + self._flat_allow = set() + self._flat_omit = set() + self._nested_allow = defaultdict(list) + self._nested_omit = defaultdict(list) + try: + request = getattr(self, "context", {})["request"] + except KeyError: + logger.error("Serializer context does not have access to request.") + return [], {} + + params = request.GET + try: + filter_fields = params.get("fields", None).split(",") + except AttributeError: + filter_fields = [] + + try: + omit_fields = params.get("omit", None).split(",") + except AttributeError: + omit_fields = [] + + # store top-level and nested fields specified in the `fields` argument. + for filtered_field in filter_fields: + if "__" in filtered_field: + parent, child = filtered_field.split("__", 1) + self._nested_allow[parent].append(child) + # If a nested field is allowed the related parent level field + # must also be allowed + self._flat_allow.add(parent) + else: + self._flat_allow.add(filtered_field) + + # store top-level and nested fields in the `omit` argument. + for omitted_field in omit_fields: + if "__" in omitted_field: + parent, child = omitted_field.split("__", 1) + self._nested_omit[parent].append(child) + else: + self._flat_omit.add(omitted_field) + + # Top‑level fields to defer + omit_top = self._get_omit_top_level_fields_to_defer() + disallowed_top = self._get_disallowed_top_level_fields_to_defer() + top_fields = list(set(omit_top + disallowed_top)) + + # Nested specs to defer + defer_omit = self._get_omit_nested_level_fields_to_defer() + defer_disallowed = self._get_disallowed_nested_level_fields_to_defer() + + # Merge child lists under each parent + nested_to_defer: dict[str, tuple[type[Model], list[str]]] = {} + for parent in defer_omit.keys() | defer_disallowed.keys(): + if parent in defer_omit: + model, omit_names = defer_omit[parent] + else: + model, omit_names = defer_disallowed[parent] + + disallowed_names = ( + defer_disallowed[parent][1] + if parent in defer_disallowed + else [] + ) + combined = set(omit_names) | set(disallowed_names) + nested_to_defer[parent] = (model, list(combined)) + + return top_fields, nested_to_defer + + +class DeferredFieldsMixin: + """ViewSet Mixin that: + - defers top‐level model columns based on omit/fields + - omit deferring select related fields + - builds a Prefetch for each nested relation to defer its columns, + merging cleanly with any existing prefetches in the right order. + """ + + def get_queryset(self) -> QuerySet: + qs = super().get_queryset() # type: ignore[misc] + # Skip for queries that uses values() or annotate() + if qs.query.values_select or qs.query.annotations: + return qs + + existing_select_related = set(qs.query.select_related or ()) + original_fields_defer_only, is_defer = qs.query.deferred_loading + original_deferred_fields = ( + set(original_fields_defer_only) if is_defer else set() + ) + serializer = self.get_serializer_class()( # type: ignore[attr-defined] + context=self.get_serializer_context() # type: ignore[attr-defined] + ) + + parent_fields, nested_map = serializer.get_deferred_model_fields() + # Remove select_related fields from the top-level deferred fields. + # Add the original deferred fields. + parent_defer_to_keep = ( + set(parent_fields) - existing_select_related + ) | original_deferred_fields + qs = qs.defer(*parent_defer_to_keep) + + # Prepare to rebuild prefetch_related in correct order + nested_prefetches = [] + existing_simple_lookups = list(qs._prefetch_related_lookups) + parent_model = serializer.Meta.model + for parent_field, (child_model, child_fields) in nested_map.items(): + # Look for FKs in the child serializer linked to the parent model. + # f.many_to_one is True for ForeignKey fields + # and f.remote_field.model is the parent model. + fk_names = { + f.name + for f in child_model._meta.get_fields() + if getattr(f, "many_to_one", False) + and getattr(f.remote_field, "model", None) == parent_model + } + child_fields = [f for f in child_fields if f not in fk_names] + if child_fields: + nested_prefetches.append( + Prefetch( + parent_field, + queryset=child_model.objects.defer(*child_fields), # type: ignore[attr-defined] + ) + ) + + new_qs = qs._clone() + # Apply prefetches in the correct order to prevent conflicts. + new_qs._prefetch_related_lookups = ( + nested_prefetches + existing_simple_lookups + ) + return new_qs diff --git a/cl/audio/api_serializers.py b/cl/audio/api_serializers.py index 1796bb2130..5531ace68b 100644 --- a/cl/audio/api_serializers.py +++ b/cl/audio/api_serializers.py @@ -1,13 +1,20 @@ -from drf_dynamic_fields import DynamicFieldsMixin from rest_framework.serializers import CharField, HyperlinkedRelatedField -from cl.api.utils import HyperlinkedModelSerializerWithId +from cl.api.utils import ( + DynamicFieldsMixin, + HyperlinkedModelSerializerWithId, + RetrieveFilteredFieldsMixin, +) from cl.audio import models as audio_models from cl.people_db.models import Person from cl.search.models import Docket -class AudioSerializer(DynamicFieldsMixin, HyperlinkedModelSerializerWithId): +class AudioSerializer( + RetrieveFilteredFieldsMixin, + DynamicFieldsMixin, + HyperlinkedModelSerializerWithId, +): absolute_url = CharField(source="get_absolute_url", read_only=True) panel: HyperlinkedRelatedField = HyperlinkedRelatedField( many=True, diff --git a/cl/audio/api_views.py b/cl/audio/api_views.py index fa6d518ec9..e60d357d53 100644 --- a/cl/audio/api_views.py +++ b/cl/audio/api_views.py @@ -2,13 +2,13 @@ from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly from cl.api.api_permissions import V3APIPermission -from cl.api.utils import LoggingMixin +from cl.api.utils import DeferredFieldsMixin, LoggingMixin from cl.audio.api_serializers import AudioSerializer from cl.audio.filters import AudioFilter from cl.audio.models import Audio -class AudioViewSet(LoggingMixin, viewsets.ModelViewSet): +class AudioViewSet(LoggingMixin, DeferredFieldsMixin, viewsets.ModelViewSet): serializer_class = AudioSerializer filterset_class = AudioFilter permission_classes = [ diff --git a/cl/disclosures/api_serializers.py b/cl/disclosures/api_serializers.py index c706e651a5..8587161f52 100644 --- a/cl/disclosures/api_serializers.py +++ b/cl/disclosures/api_serializers.py @@ -1,8 +1,12 @@ from django.conf import settings -from drf_dynamic_fields import DynamicFieldsMixin from rest_framework import serializers -from cl.api.utils import HyperlinkedModelSerializerWithId +from cl.api.utils import ( + DynamicFieldsMixin, + HyperlinkedModelSerializerWithId, + NestedDynamicFieldsMixin, + RetrieveFilteredFieldsMixin, +) from cl.disclosures.models import ( Agreement, Debt, @@ -18,42 +22,60 @@ class AgreementSerializer( - DynamicFieldsMixin, HyperlinkedModelSerializerWithId + RetrieveFilteredFieldsMixin, + NestedDynamicFieldsMixin, + HyperlinkedModelSerializerWithId, ): class Meta: model = Agreement fields = "__all__" -class DebtSerializer(DynamicFieldsMixin, HyperlinkedModelSerializerWithId): +class DebtSerializer( + RetrieveFilteredFieldsMixin, + NestedDynamicFieldsMixin, + HyperlinkedModelSerializerWithId, +): class Meta: model = Debt fields = "__all__" class InvestmentSerializer( - DynamicFieldsMixin, HyperlinkedModelSerializerWithId + RetrieveFilteredFieldsMixin, + NestedDynamicFieldsMixin, + HyperlinkedModelSerializerWithId, ): class Meta: model = Investment fields = "__all__" -class GiftSerializer(DynamicFieldsMixin, HyperlinkedModelSerializerWithId): +class GiftSerializer( + RetrieveFilteredFieldsMixin, + DynamicFieldsMixin, + HyperlinkedModelSerializerWithId, +): class Meta: model = Gift fields = "__all__" class NonInvestmentIncomeSerializer( - DynamicFieldsMixin, HyperlinkedModelSerializerWithId + RetrieveFilteredFieldsMixin, + NestedDynamicFieldsMixin, + HyperlinkedModelSerializerWithId, ): class Meta: model = NonInvestmentIncome fields = "__all__" -class PositionSerializer(DynamicFieldsMixin, HyperlinkedModelSerializerWithId): +class PositionSerializer( + RetrieveFilteredFieldsMixin, + NestedDynamicFieldsMixin, + HyperlinkedModelSerializerWithId, +): resource_uri = serializers.SerializerMethodField() def get_resource_uri(self, position: Position) -> str: @@ -79,7 +101,9 @@ class Meta: class ReimbursementSerializer( - DynamicFieldsMixin, HyperlinkedModelSerializerWithId + RetrieveFilteredFieldsMixin, + NestedDynamicFieldsMixin, + HyperlinkedModelSerializerWithId, ): class Meta: model = Reimbursement @@ -87,7 +111,9 @@ class Meta: class SpouseIncomeSerializer( - DynamicFieldsMixin, HyperlinkedModelSerializerWithId + RetrieveFilteredFieldsMixin, + NestedDynamicFieldsMixin, + HyperlinkedModelSerializerWithId, ): class Meta: model = SpouseIncome @@ -95,7 +121,9 @@ class Meta: class FinancialDisclosureSerializer( - DynamicFieldsMixin, HyperlinkedModelSerializerWithId + RetrieveFilteredFieldsMixin, + NestedDynamicFieldsMixin, + HyperlinkedModelSerializerWithId, ): agreements = AgreementSerializer(many=True, read_only=True) debts = DebtSerializer(many=True, read_only=True) diff --git a/cl/disclosures/api_views.py b/cl/disclosures/api_views.py index c08d2cdf1d..1ad174f667 100644 --- a/cl/disclosures/api_views.py +++ b/cl/disclosures/api_views.py @@ -2,7 +2,11 @@ from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly from cl.api.api_permissions import V3APIPermission -from cl.api.utils import LoggingMixin, NoFilterCacheListMixin +from cl.api.utils import ( + DeferredFieldsMixin, + LoggingMixin, + NoFilterCacheListMixin, +) from cl.disclosures.api_serializers import ( AgreementSerializer, DebtSerializer, @@ -38,7 +42,9 @@ ) -class AgreementViewSet(LoggingMixin, viewsets.ModelViewSet): +class AgreementViewSet( + LoggingMixin, DeferredFieldsMixin, viewsets.ModelViewSet +): queryset = Agreement.objects.all().order_by("-id") serializer_class = AgreementSerializer permission_classes = [ @@ -57,7 +63,7 @@ class AgreementViewSet(LoggingMixin, viewsets.ModelViewSet): ] -class DebtViewSet(LoggingMixin, viewsets.ModelViewSet): +class DebtViewSet(LoggingMixin, DeferredFieldsMixin, viewsets.ModelViewSet): queryset = Debt.objects.all().order_by("-id") serializer_class = DebtSerializer permission_classes = [ @@ -76,7 +82,9 @@ class DebtViewSet(LoggingMixin, viewsets.ModelViewSet): ] -class FinancialDisclosureViewSet(LoggingMixin, viewsets.ModelViewSet): +class FinancialDisclosureViewSet( + LoggingMixin, DeferredFieldsMixin, viewsets.ModelViewSet +): queryset = ( FinancialDisclosure.objects.all() .prefetch_related( @@ -109,7 +117,7 @@ class FinancialDisclosureViewSet(LoggingMixin, viewsets.ModelViewSet): ] -class GiftViewSet(LoggingMixin, viewsets.ModelViewSet): +class GiftViewSet(LoggingMixin, DeferredFieldsMixin, viewsets.ModelViewSet): queryset = Gift.objects.all().order_by("-id") serializer_class = GiftSerializer filterset_class = GiftFilter @@ -129,7 +137,10 @@ class GiftViewSet(LoggingMixin, viewsets.ModelViewSet): class InvestmentViewSet( - LoggingMixin, NoFilterCacheListMixin, viewsets.ModelViewSet + LoggingMixin, + NoFilterCacheListMixin, + DeferredFieldsMixin, + viewsets.ModelViewSet, ): queryset = Investment.objects.all().order_by("-id") serializer_class = InvestmentSerializer @@ -149,7 +160,9 @@ class InvestmentViewSet( ] -class NonInvestmentIncomeViewSet(LoggingMixin, viewsets.ModelViewSet): +class NonInvestmentIncomeViewSet( + LoggingMixin, DeferredFieldsMixin, viewsets.ModelViewSet +): queryset = NonInvestmentIncome.objects.all().order_by("-id") serializer_class = NonInvestmentIncomeSerializer filterset_class = NonInvestmentIncomeFilter @@ -168,7 +181,9 @@ class NonInvestmentIncomeViewSet(LoggingMixin, viewsets.ModelViewSet): ] -class PositionViewSet(LoggingMixin, viewsets.ModelViewSet): +class PositionViewSet( + LoggingMixin, DeferredFieldsMixin, viewsets.ModelViewSet +): queryset = Position.objects.all().order_by("-id") serializer_class = PositionSerializer filterset_class = PositionFilter @@ -187,7 +202,9 @@ class PositionViewSet(LoggingMixin, viewsets.ModelViewSet): ] -class ReimbursementViewSet(LoggingMixin, viewsets.ModelViewSet): +class ReimbursementViewSet( + LoggingMixin, DeferredFieldsMixin, viewsets.ModelViewSet +): queryset = Reimbursement.objects.all().order_by("-id") serializer_class = ReimbursementSerializer filterset_class = ReimbursementFilter @@ -206,7 +223,9 @@ class ReimbursementViewSet(LoggingMixin, viewsets.ModelViewSet): ] -class SpouseIncomeViewSet(LoggingMixin, viewsets.ModelViewSet): +class SpouseIncomeViewSet( + LoggingMixin, DeferredFieldsMixin, viewsets.ModelViewSet +): queryset = SpouseIncome.objects.all().order_by("-id") serializer_class = SpouseIncomeSerializer filterset_class = SpouseIncomeFilter diff --git a/cl/favorites/api_serializers.py b/cl/favorites/api_serializers.py index e4fcc9b78a..88f5282610 100644 --- a/cl/favorites/api_serializers.py +++ b/cl/favorites/api_serializers.py @@ -2,11 +2,11 @@ from asgiref.sync import async_to_sync from django.conf import settings -from drf_dynamic_fields import DynamicFieldsMixin from rest_framework import serializers from rest_framework.exceptions import ValidationError from rest_framework.serializers import ModelSerializer +from cl.api.utils import DynamicFieldsMixin from cl.favorites.models import DocketTag, Prayer, UserTag from cl.favorites.utils import prayer_eligible from cl.search.models import Docket diff --git a/cl/people_db/api_serializers.py b/cl/people_db/api_serializers.py index 6008b33e16..8880ff4ea0 100644 --- a/cl/people_db/api_serializers.py +++ b/cl/people_db/api_serializers.py @@ -1,8 +1,12 @@ -from drf_dynamic_fields import DynamicFieldsMixin from judge_pics.search import ImageSizes, portrait from rest_framework import serializers -from cl.api.utils import HyperlinkedModelSerializerWithId +from cl.api.utils import ( + DynamicFieldsMixin, + HyperlinkedModelSerializerWithId, + NestedDynamicFieldsMixin, + RetrieveFilteredFieldsMixin, +) from cl.disclosures.utils import make_disclosure_year_range from cl.people_db.models import ( ABARating, @@ -23,7 +27,11 @@ from cl.search.api_serializers import CourtSerializer -class SchoolSerializer(DynamicFieldsMixin, HyperlinkedModelSerializerWithId): +class SchoolSerializer( + RetrieveFilteredFieldsMixin, + DynamicFieldsMixin, + HyperlinkedModelSerializerWithId, +): is_alias_of = serializers.HyperlinkedRelatedField( many=False, view_name="school-detail", @@ -37,7 +45,9 @@ class Meta: class EducationSerializer( - DynamicFieldsMixin, HyperlinkedModelSerializerWithId + RetrieveFilteredFieldsMixin, + NestedDynamicFieldsMixin, + HyperlinkedModelSerializerWithId, ): school = SchoolSerializer(many=False, read_only=True) person = serializers.HyperlinkedRelatedField( @@ -53,7 +63,9 @@ class Meta: class PoliticalAffiliationSerializer( - DynamicFieldsMixin, HyperlinkedModelSerializerWithId + RetrieveFilteredFieldsMixin, + DynamicFieldsMixin, + HyperlinkedModelSerializerWithId, ): person = serializers.HyperlinkedRelatedField( many=False, @@ -67,7 +79,11 @@ class Meta: fields = "__all__" -class SourceSerializer(DynamicFieldsMixin, HyperlinkedModelSerializerWithId): +class SourceSerializer( + RetrieveFilteredFieldsMixin, + DynamicFieldsMixin, + HyperlinkedModelSerializerWithId, +): person = serializers.HyperlinkedRelatedField( many=False, view_name="person-detail", @@ -81,7 +97,9 @@ class Meta: class ABARatingSerializer( - DynamicFieldsMixin, HyperlinkedModelSerializerWithId + RetrieveFilteredFieldsMixin, + DynamicFieldsMixin, + HyperlinkedModelSerializerWithId, ): person = serializers.HyperlinkedRelatedField( many=False, @@ -136,7 +154,11 @@ class Meta: ) -class PersonSerializer(DynamicFieldsMixin, HyperlinkedModelSerializerWithId): +class PersonSerializer( + RetrieveFilteredFieldsMixin, + NestedDynamicFieldsMixin, + HyperlinkedModelSerializerWithId, +): race = serializers.StringRelatedField(many=True) sources = SourceSerializer(many=True, read_only=True) aba_ratings = ABARatingSerializer(many=True, read_only=True) @@ -163,7 +185,9 @@ class Meta: class RetentionEventSerializer( - DynamicFieldsMixin, HyperlinkedModelSerializerWithId + RetrieveFilteredFieldsMixin, + DynamicFieldsMixin, + HyperlinkedModelSerializerWithId, ): position = serializers.HyperlinkedRelatedField( many=False, @@ -177,7 +201,11 @@ class Meta: fields = "__all__" -class PositionSerializer(DynamicFieldsMixin, HyperlinkedModelSerializerWithId): +class PositionSerializer( + RetrieveFilteredFieldsMixin, + DynamicFieldsMixin, + HyperlinkedModelSerializerWithId, +): retention_events = RetentionEventSerializer(many=True, read_only=True) person = PersonSerializer(many=False, read_only=True) supervisor = PersonSerializer(many=False, read_only=True) diff --git a/cl/people_db/api_views.py b/cl/people_db/api_views.py index 47ed5fa12d..6c2d0a8dea 100644 --- a/cl/people_db/api_views.py +++ b/cl/people_db/api_views.py @@ -5,6 +5,7 @@ from cl.api.api_permissions import V3APIPermission from cl.api.pagination import TinyAdjustablePagination from cl.api.utils import ( + DeferredFieldsMixin, LoggingMixin, NoFilterCacheListMixin, RECAPUsersReadOnly, @@ -115,7 +116,7 @@ class PersonDisclosureViewSet(viewsets.ModelViewSet): ] -class PersonViewSet(LoggingMixin, viewsets.ModelViewSet): +class PersonViewSet(LoggingMixin, DeferredFieldsMixin, viewsets.ModelViewSet): queryset = ( Person.objects.all() .prefetch_related( @@ -152,7 +153,9 @@ class PersonViewSet(LoggingMixin, viewsets.ModelViewSet): ] -class PositionViewSet(LoggingMixin, viewsets.ModelViewSet): +class PositionViewSet( + LoggingMixin, DeferredFieldsMixin, viewsets.ModelViewSet +): queryset = Position.objects.all().order_by("-id") serializer_class = PositionSerializer filterset_class = PositionFilter @@ -185,7 +188,9 @@ class PositionViewSet(LoggingMixin, viewsets.ModelViewSet): ] -class RetentionEventViewSet(LoggingMixin, viewsets.ModelViewSet): +class RetentionEventViewSet( + LoggingMixin, DeferredFieldsMixin, viewsets.ModelViewSet +): queryset = RetentionEvent.objects.all().order_by("-id") serializer_class = RetentionEventSerializer filterset_class = RetentionEventFilter @@ -204,7 +209,9 @@ class RetentionEventViewSet(LoggingMixin, viewsets.ModelViewSet): ] -class EducationViewSet(LoggingMixin, viewsets.ModelViewSet): +class EducationViewSet( + LoggingMixin, DeferredFieldsMixin, viewsets.ModelViewSet +): queryset = Education.objects.all().order_by("-id") serializer_class = EducationSerializer filterset_class = EducationFilter @@ -223,7 +230,7 @@ class EducationViewSet(LoggingMixin, viewsets.ModelViewSet): ] -class SchoolViewSet(LoggingMixin, viewsets.ModelViewSet): +class SchoolViewSet(LoggingMixin, DeferredFieldsMixin, viewsets.ModelViewSet): queryset = School.objects.all().order_by("-id") serializer_class = SchoolSerializer filterset_class = SchoolFilter @@ -242,7 +249,9 @@ class SchoolViewSet(LoggingMixin, viewsets.ModelViewSet): ] -class PoliticalAffiliationViewSet(LoggingMixin, viewsets.ModelViewSet): +class PoliticalAffiliationViewSet( + LoggingMixin, DeferredFieldsMixin, viewsets.ModelViewSet +): queryset = PoliticalAffiliation.objects.all().order_by("-id") serializer_class = PoliticalAffiliationSerializer filterset_class = PoliticalAffiliationFilter @@ -267,7 +276,7 @@ class PoliticalAffiliationViewSet(LoggingMixin, viewsets.ModelViewSet): ] -class SourceViewSet(LoggingMixin, viewsets.ModelViewSet): +class SourceViewSet(LoggingMixin, DeferredFieldsMixin, viewsets.ModelViewSet): queryset = Source.objects.all().order_by("-id") serializer_class = SourceSerializer filterset_class = SourceFilter @@ -286,7 +295,9 @@ class SourceViewSet(LoggingMixin, viewsets.ModelViewSet): cursor_ordering_fields = ["id", "date_modified"] -class ABARatingViewSet(LoggingMixin, viewsets.ModelViewSet): +class ABARatingViewSet( + LoggingMixin, DeferredFieldsMixin, viewsets.ModelViewSet +): queryset = ABARating.objects.all().order_by("-id") serializer_class = ABARatingSerializer filterset_class = ABARatingFilter diff --git a/cl/people_db/models.py b/cl/people_db/models.py index 191635b973..09e51cb206 100644 --- a/cl/people_db/models.py +++ b/cl/people_db/models.py @@ -207,8 +207,10 @@ class Person(AbstractDateTimeModel): ) es_p_field_tracker = FieldTracker( fields=[ - "name_full", - "name_full_reverse", + "name_first", + "name_middle", + "name_last", + "name_suffix", "religion", "gender", "dob_city", @@ -221,7 +223,9 @@ class Person(AbstractDateTimeModel): "slug", ] ) - es_rd_field_tracker = FieldTracker(fields=["name_full"]) + es_rd_field_tracker = FieldTracker( + fields=["name_first", "name_middle", "name_last", "name_suffix"] + ) def __str__(self) -> str: return f"{self.pk}: {self.name_full}" diff --git a/cl/search/api_serializers.py b/cl/search/api_serializers.py index e2acaebe87..390ee1b82f 100644 --- a/cl/search/api_serializers.py +++ b/cl/search/api_serializers.py @@ -1,11 +1,15 @@ from datetime import UTC -from drf_dynamic_fields import DynamicFieldsMixin from rest_framework import serializers from rest_framework.serializers import ModelSerializer from waffle import flag_is_active -from cl.api.utils import HyperlinkedModelSerializerWithId +from cl.api.utils import ( + DynamicFieldsMixin, + HyperlinkedModelSerializerWithId, + NestedDynamicFieldsMixin, + RetrieveFilteredFieldsMixin, +) from cl.audio.models import Audio from cl.custom_filters.templatetags.extras import get_highlight from cl.lib.document_serializer import ( @@ -61,14 +65,20 @@ class Meta: class OriginalCourtInformationSerializer( - DynamicFieldsMixin, HyperlinkedModelSerializerWithId + RetrieveFilteredFieldsMixin, + DynamicFieldsMixin, + HyperlinkedModelSerializerWithId, ): class Meta: model = OriginatingCourtInformation fields = "__all__" -class DocketSerializer(DynamicFieldsMixin, HyperlinkedModelSerializerWithId): +class DocketSerializer( + RetrieveFilteredFieldsMixin, + DynamicFieldsMixin, + HyperlinkedModelSerializerWithId, +): court = serializers.HyperlinkedRelatedField( many=False, view_name="court-detail", @@ -118,7 +128,9 @@ class Meta: class RECAPDocumentSerializer( - DynamicFieldsMixin, HyperlinkedModelSerializerWithId + RetrieveFilteredFieldsMixin, + NestedDynamicFieldsMixin, + HyperlinkedModelSerializerWithId, ): tags = serializers.HyperlinkedRelatedField( many=True, @@ -136,7 +148,9 @@ class Meta: class DocketEntrySerializer( - DynamicFieldsMixin, HyperlinkedModelSerializerWithId + RetrieveFilteredFieldsMixin, + NestedDynamicFieldsMixin, + HyperlinkedModelSerializerWithId, ): docket = serializers.HyperlinkedRelatedField( many=False, @@ -155,13 +169,21 @@ class FullDocketSerializer(DocketSerializer): docket_entries = DocketEntrySerializer(many=True, read_only=True) -class CourtSerializer(DynamicFieldsMixin, HyperlinkedModelSerializerWithId): +class CourtSerializer( + RetrieveFilteredFieldsMixin, + DynamicFieldsMixin, + HyperlinkedModelSerializerWithId, +): class Meta: model = Court exclude = ("notes",) -class OpinionSerializer(DynamicFieldsMixin, HyperlinkedModelSerializerWithId): +class OpinionSerializer( + RetrieveFilteredFieldsMixin, + DynamicFieldsMixin, + HyperlinkedModelSerializerWithId, +): absolute_url = serializers.CharField( source="get_absolute_url", read_only=True ) @@ -192,7 +214,9 @@ class Meta: class OpinionsCitedSerializer( - DynamicFieldsMixin, HyperlinkedModelSerializerWithId + RetrieveFilteredFieldsMixin, + DynamicFieldsMixin, + HyperlinkedModelSerializerWithId, ): # These attributes seem unnecessary and this endpoint serializes the same # data without them, but when they're not here the API does a query that @@ -225,7 +249,9 @@ class Meta: class OpinionClusterSerializer( - DynamicFieldsMixin, HyperlinkedModelSerializerWithId + RetrieveFilteredFieldsMixin, + DynamicFieldsMixin, + HyperlinkedModelSerializerWithId, ): absolute_url = serializers.CharField( source="get_absolute_url", read_only=True @@ -262,7 +288,11 @@ class Meta: fields = "__all__" -class TagSerializer(DynamicFieldsMixin, HyperlinkedModelSerializerWithId): +class TagSerializer( + RetrieveFilteredFieldsMixin, + DynamicFieldsMixin, + HyperlinkedModelSerializerWithId, +): class Meta: model = Tag fields = "__all__" diff --git a/cl/search/api_views.py b/cl/search/api_views.py index 00982effcd..92efcd4225 100644 --- a/cl/search/api_views.py +++ b/cl/search/api_views.py @@ -12,6 +12,7 @@ from cl.api.api_permissions import V3APIPermission from cl.api.pagination import ESCursorPagination from cl.api.utils import ( + DeferredFieldsMixin, LoggingMixin, NoFilterCacheListMixin, RECAPUsersReadOnly, @@ -71,7 +72,9 @@ ) -class OriginatingCourtInformationViewSet(viewsets.ModelViewSet): +class OriginatingCourtInformationViewSet( + DeferredFieldsMixin, viewsets.ModelViewSet +): serializer_class = OriginalCourtInformationSerializer permission_classes = [ DjangoModelPermissionsOrAnonReadOnly, @@ -89,7 +92,10 @@ class OriginatingCourtInformationViewSet(viewsets.ModelViewSet): class DocketViewSet( - LoggingMixin, NoFilterCacheListMixin, viewsets.ModelViewSet + LoggingMixin, + NoFilterCacheListMixin, + DeferredFieldsMixin, + viewsets.ModelViewSet, ): serializer_class = DocketSerializer filterset_class = DocketFilter @@ -128,7 +134,10 @@ class DocketViewSet( class DocketEntryViewSet( - LoggingMixin, NoFilterCacheListMixin, viewsets.ModelViewSet + LoggingMixin, + NoFilterCacheListMixin, + DeferredFieldsMixin, + viewsets.ModelViewSet, ): permission_classes = (RECAPUsersReadOnly, V3APIPermission) serializer_class = DocketEntrySerializer @@ -163,7 +172,10 @@ class DocketEntryViewSet( class RECAPDocumentViewSet( - LoggingMixin, NoFilterCacheListMixin, viewsets.ModelViewSet + LoggingMixin, + NoFilterCacheListMixin, + DeferredFieldsMixin, + viewsets.ModelViewSet, ): permission_classes = (RECAPUsersReadOnly, V3APIPermission) serializer_class = RECAPDocumentSerializer @@ -186,7 +198,7 @@ class RECAPDocumentViewSet( ) -class CourtViewSet(LoggingMixin, viewsets.ModelViewSet): +class CourtViewSet(LoggingMixin, DeferredFieldsMixin, viewsets.ModelViewSet): serializer_class = CourtSerializer filterset_class = CourtFilter permission_classes = [ @@ -210,7 +222,10 @@ class CourtViewSet(LoggingMixin, viewsets.ModelViewSet): class OpinionClusterViewSet( - LoggingMixin, NoFilterCacheListMixin, viewsets.ModelViewSet + LoggingMixin, + NoFilterCacheListMixin, + DeferredFieldsMixin, + viewsets.ModelViewSet, ): serializer_class = OpinionClusterSerializer filterset_class = OpinionClusterFilter @@ -278,7 +293,10 @@ def retrieve(self, request, *args, **kwargs): class OpinionViewSet( - LoggingMixin, NoFilterCacheListMixin, viewsets.ModelViewSet + LoggingMixin, + NoFilterCacheListMixin, + DeferredFieldsMixin, + viewsets.ModelViewSet, ): serializer_class = OpinionSerializer filterset_class = OpinionFilter @@ -310,7 +328,10 @@ class OpinionViewSet( class OpinionsCitedViewSet( - LoggingMixin, NoFilterCacheListMixin, viewsets.ModelViewSet + LoggingMixin, + NoFilterCacheListMixin, + DeferredFieldsMixin, + viewsets.ModelViewSet, ): serializer_class = OpinionsCitedSerializer filterset_class = OpinionsCitedFilter @@ -325,7 +346,7 @@ class OpinionsCitedViewSet( queryset = OpinionsCited.objects.all().order_by("-id") -class TagViewSet(LoggingMixin, viewsets.ModelViewSet): +class TagViewSet(LoggingMixin, DeferredFieldsMixin, viewsets.ModelViewSet): permission_classes = (RECAPUsersReadOnly, V3APIPermission) serializer_class = TagSerializer # Default cursor ordering key diff --git a/cl/search/documents.py b/cl/search/documents.py index b9924225f4..590368d25e 100644 --- a/cl/search/documents.py +++ b/cl/search/documents.py @@ -727,11 +727,9 @@ class PositionDocument(PersonBaseDocument): fields={"raw": fields.KeywordField()}, ) appointer = fields.TextField( - attr="appointer.person.name_full_reverse", analyzer="text_en_splitting_cl", fields={ "exact": fields.TextField( - attr="appointer.person.name_full_reverse", analyzer="english_exact", search_analyzer="search_analyzer_exact", ), @@ -808,6 +806,18 @@ class Django: model = Position ignore_signals = True + def prepare_appointer(self, instance): + if instance.appointer: + return instance.appointer.person.name_full_reverse + + def prepare_predecessor(self, instance): + if instance.predecessor: + return instance.predecessor.name_full_reverse + + def prepare_supervisor(self, instance): + if instance.supervisor: + return instance.supervisor.name_full_reverse + def prepare_position_type(self, instance): return instance.get_position_type_display() @@ -893,9 +903,7 @@ def prepare_political_affiliation_id(self, instance): @people_db_index.document class PersonDocument(CSVSerializableDocumentMixin, PersonBaseDocument): - name_reverse = fields.KeywordField( - attr="name_full_reverse", - ) + name_reverse = fields.KeywordField() date_granularity_dob = fields.KeywordField(attr="date_granularity_dob") date_granularity_dod = fields.KeywordField(attr="date_granularity_dod") absolute_url = fields.KeywordField() @@ -965,6 +973,9 @@ def get_csv_transformations(cls) -> dict[str, Callable[..., Any]]: ).get(x, x) return transformations + def prepare_name_reverse(self, instance): + return instance.name_full_reverse + def prepare_person_child(self, instance): return "person" diff --git a/cl/search/signals.py b/cl/search/signals.py index 1ead6dc8e0..ba432629fe 100644 --- a/cl/search/signals.py +++ b/cl/search/signals.py @@ -177,8 +177,10 @@ "save": { Person: { "self": { - "name_full": ["name"], - "name_full_reverse": ["name_reverse"], + "name_first": ["name", "name_reverse"], + "name_middle": ["name", "name_reverse"], + "name_last": ["name", "name_reverse"], + "name_suffix": ["name", "name_reverse"], "religion": ["religion"], "gender": ["gender"], "dob_city": ["dob_city"], @@ -218,16 +220,28 @@ "save": { Person: { "appointer__person": { - "name_full_reverse": ["appointer"], + "name_first": ["appointer"], + "name_middle": ["appointer"], + "name_last": ["appointer"], + "name_suffix": ["appointer"], }, "predecessor": { - "name_full_reverse": ["predecessor"], + "name_first": ["predecessor"], + "name_middle": ["predecessor"], + "name_last": ["predecessor"], + "name_suffix": ["predecessor"], }, "supervisor": { - "name_full_reverse": ["supervisor"], + "name_first": ["supervisor"], + "name_middle": ["supervisor"], + "name_last": ["supervisor"], + "name_suffix": ["supervisor"], }, "person": { - "name_full": ["name"], + "name_first": ["name"], + "name_middle": ["name"], + "name_last": ["name"], + "name_suffix": ["name"], "religion": ["religion"], "gender": ["gender"], "dob_city": ["dob_city"], @@ -305,10 +319,16 @@ }, Person: { "assigned_to": { - "name_full": ["assignedTo"], + "name_first": ["assignedTo"], + "name_middle": ["assignedTo"], + "name_last": ["assignedTo"], + "get_name_suffix_display": ["assignedTo"], }, "referred_to": { - "name_full": ["referredTo"], + "name_first": ["referredTo"], + "name_middle": ["referredTo"], + "name_last": ["referredTo"], + "get_name_suffix_display": ["referredTo"], }, }, }, @@ -371,10 +391,16 @@ }, Person: { "assigned_to": { - "name_full": ["assignedTo"], + "name_first": ["assignedTo"], + "name_middle": ["assignedTo"], + "name_last": ["assignedTo"], + "get_name_suffix_display": ["assignedTo"], }, "referred_to": { - "name_full": ["referredTo"], + "name_first": ["referredTo"], + "name_middle": ["referredTo"], + "name_last": ["referredTo"], + "get_name_suffix_display": ["referredTo"], }, }, }, diff --git a/cl/visualizations/api_serializers.py b/cl/visualizations/api_serializers.py index e7c5cc82f2..bc213c4210 100644 --- a/cl/visualizations/api_serializers.py +++ b/cl/visualizations/api_serializers.py @@ -1,7 +1,6 @@ -from drf_dynamic_fields import DynamicFieldsMixin from rest_framework import serializers -from cl.api.utils import HyperlinkedModelSerializerWithId +from cl.api.utils import DynamicFieldsMixin, HyperlinkedModelSerializerWithId from cl.search.models import OpinionCluster from cl.visualizations.models import JSONVersion, SCOTUSMap diff --git a/pyproject.toml b/pyproject.toml index b8eb39af74..5a8974c95c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,7 +108,6 @@ dependencies = [ "psycopg[binary,pool]>=3.2.6", "boto3>=1.37.20", "django-tailwind>=3.8.0", - "drf-dynamic-fields>=0.4.0", "django-ses[events]>=0.44.0", "django-cotton>=2.0.1", "django-cursor-pagination>=0.3.0", diff --git a/uv.lock b/uv.lock index a8bee9d944..3ca23764a7 100644 --- a/uv.lock +++ b/uv.lock @@ -289,7 +289,6 @@ dependencies = [ { name = "djangorestframework" }, { name = "djangorestframework-filters" }, { name = "djangorestframework-xml" }, - { name = "drf-dynamic-fields" }, { name = "eyecite" }, { name = "factory-boy" }, { name = "feedparser" }, @@ -401,7 +400,6 @@ requires-dist = [ { name = "djangorestframework", git = "https://github.com/encode/django-rest-framework.git?rev=cc3c89a11c7ee9cf7cfd732e0a329c318ace71b2" }, { name = "djangorestframework-filters", specifier = "==1.0.0.dev2" }, { name = "djangorestframework-xml", specifier = ">=2.0.0" }, - { name = "drf-dynamic-fields", specifier = ">=0.4.0" }, { name = "eyecite" }, { name = "factory-boy", specifier = ">=3.3.3" }, { name = "feedparser", specifier = ">=6.0.10" }, @@ -1091,15 +1089,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/74/65485e0ceae183b9348cd080cc69126e498a95bcd5d37df84a598bf94bbd/djangorestframework_xml-2.0.0-py2.py3-none-any.whl", hash = "sha256:975955fbb0d49ac44a90bdeb33b7923d95b79884d283f983e116c80a936ef4d0", size = 6046, upload-time = "2020-04-12T22:38:43.087Z" }, ] -[[package]] -name = "drf-dynamic-fields" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d2/7f/1c17e0791b8d028b47908be78ac8041c544bd07a59afd8556304a0f0d355/drf_dynamic_fields-0.4.0.tar.gz", hash = "sha256:f20a5ec27d003db7595c9315db22217493dcaed575f3811d3e12f264c791c20c", size = 4864, upload-time = "2022-04-05T20:48:34.091Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/18/4b02bda9ae5a7ac52bfcb4f1740fb4a0aa12d220881695471b700bb3f00e/drf_dynamic_fields-0.4.0-py2.py3-none-any.whl", hash = "sha256:48b879fe899905bc18593a61bca43e3b595dc3431b3b4ee499a9fd6c9a53f98c", size = 5451, upload-time = "2022-04-05T20:48:32.452Z" }, -] - [[package]] name = "elastic-transport" version = "8.17.1"