From 90f83091ced7f8bbd913e2c0a8dd412406e6f499 Mon Sep 17 00:00:00 2001 From: Alfredo Di Stasio Date: Fri, 20 Mar 2026 16:05:56 +0100 Subject: [PATCH] feat(v2-api): expose sortable search metrics in player list responses --- README.md | 14 ++++++++++++++ apps/api/serializers.py | 12 ++++++++++++ apps/api/views.py | 10 +++++++--- tests/test_api.py | 33 ++++++++++++++++++++++++++++++++- 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 95714d6..69303a6 100644 --- a/README.md +++ b/README.md @@ -419,6 +419,20 @@ Search metric semantics: - different metric columns for one player may come from different eligible seasons - when no eligible value exists for a metric in the current context, the UI shows `-` +### API Search Metric Transparency + +`GET /api/players/` now exposes sortable metric fields directly in each list row: +- `ppg_value` +- `mpg_value` + +These fields use the same **best eligible** semantics as UI search. They are computed from eligible +player-season rows in the current filter context and may be `null` when no eligible data exists. + +API list responses also include: +- `sort`: effective sort key applied +- `metric_sort_keys`: metric-based sort keys currently supported +- `metric_semantics`: plain-language metric contract used for sorting/interpretation + Pagination and sorting: - querystring is preserved - HTMX navigation keeps URL state in sync with current filters/page/sort diff --git a/apps/api/serializers.py b/apps/api/serializers.py index 1d12419..4686942 100644 --- a/apps/api/serializers.py +++ b/apps/api/serializers.py @@ -45,6 +45,8 @@ class PlayerListSerializer(serializers.ModelSerializer): inferred_role = serializers.CharField(source="inferred_role.name", allow_null=True) origin_competition = serializers.CharField(source="origin_competition.name", allow_null=True) origin_team = serializers.CharField(source="origin_team.name", allow_null=True) + ppg_value = serializers.SerializerMethodField() + mpg_value = serializers.SerializerMethodField() class Meta: model = Player @@ -59,10 +61,20 @@ class PlayerListSerializer(serializers.ModelSerializer): "origin_team", "height_cm", "weight_kg", + "ppg_value", + "mpg_value", "dominant_hand", "is_active", ] + def get_ppg_value(self, obj): + value = getattr(obj, "ppg_value", None) + return str(value) if value is not None else None + + def get_mpg_value(self, obj): + value = getattr(obj, "mpg_value", None) + return float(value) if value is not None else None + class PlayerAliasSerializer(serializers.Serializer): alias = serializers.CharField() diff --git a/apps/api/views.py b/apps/api/views.py index 456b67b..1c9c159 100644 --- a/apps/api/views.py +++ b/apps/api/views.py @@ -9,6 +9,7 @@ from apps.players.forms import PlayerSearchForm from apps.players.models import Player from apps.players.services.search import ( METRIC_SORT_KEYS, + SEARCH_METRIC_SEMANTICS_TEXT, annotate_player_metrics, apply_sorting, base_player_queryset, @@ -67,15 +68,18 @@ class PlayerSearchApiView(ReadOnlyBaseAPIView, generics.ListAPIView): form = self.get_search_form() if form.is_bound and not form.is_valid(): return self._validation_error_response() - return super().list(request, *args, **kwargs) + response = super().list(request, *args, **kwargs) + response.data["sort"] = form.cleaned_data.get("sort", "name_asc") + response.data["metric_semantics"] = SEARCH_METRIC_SEMANTICS_TEXT + response.data["metric_sort_keys"] = sorted(METRIC_SORT_KEYS) + return response def get_queryset(self): form = self.get_search_form() queryset = base_player_queryset() queryset = filter_players(queryset, form.cleaned_data) sort_key = form.cleaned_data.get("sort", "name_asc") - if sort_key in METRIC_SORT_KEYS: - queryset = annotate_player_metrics(queryset, form.cleaned_data) + queryset = annotate_player_metrics(queryset, form.cleaned_data) queryset = apply_sorting(queryset, sort_key) return queryset diff --git a/tests/test_api.py b/tests/test_api.py index 9117c6c..1510f69 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -30,6 +30,12 @@ def test_players_api_list_and_detail(client): list_response = client.get(reverse("api:players"), data={"q": "rossi"}) assert list_response.status_code == 200 assert list_response.json()["count"] == 1 + list_payload = list_response.json() + assert "sort" in list_payload + assert "metric_semantics" in list_payload + assert "metric_sort_keys" in list_payload + assert "ppg_value" in list_payload["results"][0] + assert "mpg_value" in list_payload["results"][0] detail_response = client.get(reverse("api:player_detail", kwargs={"pk": player.pk})) assert detail_response.status_code == 200 @@ -173,8 +179,33 @@ def test_players_api_metric_sort_uses_best_eligible_values(client): response = client.get(reverse("api:players"), data={"sort": "ppg_desc"}) assert response.status_code == 200 - names = [row["full_name"] for row in response.json()["results"]] + payload = response.json() + names = [row["full_name"] for row in payload["results"]] assert names.index("Dan High") < names.index("Ion Low") + assert payload["sort"] == "ppg_desc" + assert "best eligible values per player" in payload["metric_semantics"] + dan = next(row for row in payload["results"] if row["full_name"] == "Dan High") + ion = next(row for row in payload["results"] if row["full_name"] == "Ion Low") + assert float(dan["ppg_value"]) > float(ion["ppg_value"]) + + +@pytest.mark.django_db +def test_players_api_metric_fields_are_exposed_and_nullable(client): + nationality = Nationality.objects.create(name="Sweden", iso2_code="SE", iso3_code="SWE") + Player.objects.create( + first_name="No", + last_name="Stats", + full_name="No Stats", + birth_date=date(2002, 1, 1), + nationality=nationality, + ) + + response = client.get(reverse("api:players"), data={"sort": "name_asc"}) + assert response.status_code == 200 + payload = response.json() + row = next(item for item in payload["results"] if item["full_name"] == "No Stats") + assert row["ppg_value"] is None + assert row["mpg_value"] is None @pytest.mark.django_db