feat(v2-api): expose sortable search metrics in player list responses
This commit is contained in:
14
README.md
14
README.md
@ -419,6 +419,20 @@ Search metric semantics:
|
|||||||
- different metric columns for one player may come from different eligible seasons
|
- 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 `-`
|
- 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:
|
Pagination and sorting:
|
||||||
- querystring is preserved
|
- querystring is preserved
|
||||||
- HTMX navigation keeps URL state in sync with current filters/page/sort
|
- HTMX navigation keeps URL state in sync with current filters/page/sort
|
||||||
|
|||||||
@ -45,6 +45,8 @@ class PlayerListSerializer(serializers.ModelSerializer):
|
|||||||
inferred_role = serializers.CharField(source="inferred_role.name", allow_null=True)
|
inferred_role = serializers.CharField(source="inferred_role.name", allow_null=True)
|
||||||
origin_competition = serializers.CharField(source="origin_competition.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)
|
origin_team = serializers.CharField(source="origin_team.name", allow_null=True)
|
||||||
|
ppg_value = serializers.SerializerMethodField()
|
||||||
|
mpg_value = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Player
|
model = Player
|
||||||
@ -59,10 +61,20 @@ class PlayerListSerializer(serializers.ModelSerializer):
|
|||||||
"origin_team",
|
"origin_team",
|
||||||
"height_cm",
|
"height_cm",
|
||||||
"weight_kg",
|
"weight_kg",
|
||||||
|
"ppg_value",
|
||||||
|
"mpg_value",
|
||||||
"dominant_hand",
|
"dominant_hand",
|
||||||
"is_active",
|
"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):
|
class PlayerAliasSerializer(serializers.Serializer):
|
||||||
alias = serializers.CharField()
|
alias = serializers.CharField()
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from apps.players.forms import PlayerSearchForm
|
|||||||
from apps.players.models import Player
|
from apps.players.models import Player
|
||||||
from apps.players.services.search import (
|
from apps.players.services.search import (
|
||||||
METRIC_SORT_KEYS,
|
METRIC_SORT_KEYS,
|
||||||
|
SEARCH_METRIC_SEMANTICS_TEXT,
|
||||||
annotate_player_metrics,
|
annotate_player_metrics,
|
||||||
apply_sorting,
|
apply_sorting,
|
||||||
base_player_queryset,
|
base_player_queryset,
|
||||||
@ -67,15 +68,18 @@ class PlayerSearchApiView(ReadOnlyBaseAPIView, generics.ListAPIView):
|
|||||||
form = self.get_search_form()
|
form = self.get_search_form()
|
||||||
if form.is_bound and not form.is_valid():
|
if form.is_bound and not form.is_valid():
|
||||||
return self._validation_error_response()
|
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):
|
def get_queryset(self):
|
||||||
form = self.get_search_form()
|
form = self.get_search_form()
|
||||||
queryset = base_player_queryset()
|
queryset = base_player_queryset()
|
||||||
queryset = filter_players(queryset, form.cleaned_data)
|
queryset = filter_players(queryset, form.cleaned_data)
|
||||||
sort_key = form.cleaned_data.get("sort", "name_asc")
|
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)
|
queryset = apply_sorting(queryset, sort_key)
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,12 @@ def test_players_api_list_and_detail(client):
|
|||||||
list_response = client.get(reverse("api:players"), data={"q": "rossi"})
|
list_response = client.get(reverse("api:players"), data={"q": "rossi"})
|
||||||
assert list_response.status_code == 200
|
assert list_response.status_code == 200
|
||||||
assert list_response.json()["count"] == 1
|
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}))
|
detail_response = client.get(reverse("api:player_detail", kwargs={"pk": player.pk}))
|
||||||
assert detail_response.status_code == 200
|
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"})
|
response = client.get(reverse("api:players"), data={"sort": "ppg_desc"})
|
||||||
assert response.status_code == 200
|
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 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
|
@pytest.mark.django_db
|
||||||
|
|||||||
Reference in New Issue
Block a user