phase7: add minimal read-only drf api with player search endpoints

This commit is contained in:
Alfredo Di Stasio
2026-03-10 11:13:30 +01:00
parent ecd665e872
commit fa4c901bc1
12 changed files with 376 additions and 8 deletions

0
apps/api/__init__.py Normal file
View File

6
apps/api/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.api"

6
apps/api/permissions.py Normal file
View File

@ -0,0 +1,6 @@
from rest_framework.permissions import BasePermission
class ReadOnlyOrDeny(BasePermission):
def has_permission(self, request, view):
return request.method in ("GET", "HEAD", "OPTIONS")

151
apps/api/serializers.py Normal file
View File

@ -0,0 +1,151 @@
from datetime import date
from rest_framework import serializers
from apps.competitions.models import Competition, Season
from apps.players.models import Player
from apps.stats.models import PlayerSeason
from apps.teams.models import Team
class CompetitionSerializer(serializers.ModelSerializer):
country = serializers.CharField(source="country.name", allow_null=True)
class Meta:
model = Competition
fields = [
"id",
"name",
"slug",
"competition_type",
"gender",
"level",
"country",
"is_active",
]
class TeamSerializer(serializers.ModelSerializer):
country = serializers.CharField(source="country.name", allow_null=True)
class Meta:
model = Team
fields = ["id", "name", "short_name", "slug", "country", "is_national_team"]
class SeasonSerializer(serializers.ModelSerializer):
class Meta:
model = Season
fields = ["id", "label", "start_date", "end_date", "is_current"]
class PlayerListSerializer(serializers.ModelSerializer):
nationality = serializers.CharField(source="nationality.name", allow_null=True)
nominal_position = serializers.CharField(source="nominal_position.code", allow_null=True)
inferred_role = serializers.CharField(source="inferred_role.name", allow_null=True)
class Meta:
model = Player
fields = [
"id",
"full_name",
"birth_date",
"nationality",
"nominal_position",
"inferred_role",
"height_cm",
"weight_kg",
"dominant_hand",
"is_active",
]
class PlayerAliasSerializer(serializers.Serializer):
alias = serializers.CharField()
source = serializers.CharField(allow_blank=True)
is_primary = serializers.BooleanField()
class PlayerSeasonStatSerializer(serializers.Serializer):
season = serializers.CharField(allow_null=True)
team = serializers.CharField(allow_null=True)
competition = serializers.CharField(allow_null=True)
games_played = serializers.IntegerField()
minutes_played = serializers.IntegerField()
points = serializers.DecimalField(max_digits=6, decimal_places=2, allow_null=True)
rebounds = serializers.DecimalField(max_digits=6, decimal_places=2, allow_null=True)
assists = serializers.DecimalField(max_digits=6, decimal_places=2, allow_null=True)
steals = serializers.DecimalField(max_digits=6, decimal_places=2, allow_null=True)
blocks = serializers.DecimalField(max_digits=6, decimal_places=2, allow_null=True)
turnovers = serializers.DecimalField(max_digits=6, decimal_places=2, allow_null=True)
fg_pct = serializers.DecimalField(max_digits=5, decimal_places=2, allow_null=True)
three_pct = serializers.DecimalField(max_digits=5, decimal_places=2, allow_null=True)
ft_pct = serializers.DecimalField(max_digits=5, decimal_places=2, allow_null=True)
player_efficiency_rating = serializers.DecimalField(max_digits=6, decimal_places=2, allow_null=True)
class PlayerDetailSerializer(serializers.ModelSerializer):
nationality = serializers.CharField(source="nationality.name", allow_null=True)
nominal_position = serializers.CharField(source="nominal_position.name", allow_null=True)
inferred_role = serializers.CharField(source="inferred_role.name", allow_null=True)
age = serializers.SerializerMethodField()
aliases = serializers.SerializerMethodField()
season_stats = serializers.SerializerMethodField()
class Meta:
model = Player
fields = [
"id",
"full_name",
"birth_date",
"age",
"nationality",
"nominal_position",
"inferred_role",
"height_cm",
"weight_kg",
"dominant_hand",
"aliases",
"season_stats",
]
def get_age(self, obj):
if not obj.birth_date:
return None
today = date.today()
return today.year - obj.birth_date.year - (
(today.month, today.day) < (obj.birth_date.month, obj.birth_date.day)
)
def get_aliases(self, obj):
return PlayerAliasSerializer(obj.aliases.all(), many=True).data
def get_season_stats(self, obj):
rows = (
PlayerSeason.objects.filter(player=obj)
.select_related("season", "team", "competition", "stats")
.order_by("-season__start_date", "-id")
)
payload = []
for row in rows:
stats = getattr(row, "stats", None)
payload.append(
{
"season": row.season.label if row.season else None,
"team": row.team.name if row.team else None,
"competition": row.competition.name if row.competition else None,
"games_played": row.games_played,
"minutes_played": row.minutes_played,
"points": getattr(stats, "points", None),
"rebounds": getattr(stats, "rebounds", None),
"assists": getattr(stats, "assists", None),
"steals": getattr(stats, "steals", None),
"blocks": getattr(stats, "blocks", None),
"turnovers": getattr(stats, "turnovers", None),
"fg_pct": getattr(stats, "fg_pct", None),
"three_pct": getattr(stats, "three_pct", None),
"ft_pct": getattr(stats, "ft_pct", None),
"player_efficiency_rating": getattr(stats, "player_efficiency_rating", None),
}
)
return PlayerSeasonStatSerializer(payload, many=True).data

19
apps/api/urls.py Normal file
View File

@ -0,0 +1,19 @@
from django.urls import path
from .views import (
CompetitionListApiView,
PlayerDetailApiView,
PlayerSearchApiView,
SeasonListApiView,
TeamListApiView,
)
app_name = "api"
urlpatterns = [
path("players/", PlayerSearchApiView.as_view(), name="players"),
path("players/<int:pk>/", PlayerDetailApiView.as_view(), name="player_detail"),
path("competitions/", CompetitionListApiView.as_view(), name="competitions"),
path("teams/", TeamListApiView.as_view(), name="teams"),
path("seasons/", SeasonListApiView.as_view(), name="seasons"),
]

68
apps/api/views.py Normal file
View File

@ -0,0 +1,68 @@
from rest_framework import generics
from rest_framework.pagination import PageNumberPagination
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
from apps.competitions.models import Competition, Season
from apps.players.forms import PlayerSearchForm
from apps.players.models import Player
from apps.players.services.search import apply_sorting, base_player_queryset, filter_players
from apps.teams.models import Team
from .permissions import ReadOnlyOrDeny
from .serializers import (
CompetitionSerializer,
PlayerDetailSerializer,
PlayerListSerializer,
SeasonSerializer,
TeamSerializer,
)
class ApiPagination(PageNumberPagination):
page_size = 20
page_size_query_param = "page_size"
max_page_size = 100
class ReadOnlyBaseAPIView:
permission_classes = [ReadOnlyOrDeny]
throttle_classes = [AnonRateThrottle, UserRateThrottle]
class PlayerSearchApiView(ReadOnlyBaseAPIView, generics.ListAPIView):
serializer_class = PlayerListSerializer
pagination_class = ApiPagination
def get_queryset(self):
form = PlayerSearchForm(self.request.query_params or None)
queryset = base_player_queryset()
if form.is_valid():
queryset = filter_players(queryset, form.cleaned_data)
queryset = apply_sorting(queryset, form.cleaned_data.get("sort", "name_asc"))
else:
queryset = queryset.order_by("full_name", "id")
return queryset
class PlayerDetailApiView(ReadOnlyBaseAPIView, generics.RetrieveAPIView):
serializer_class = PlayerDetailSerializer
queryset = Player.objects.select_related(
"nationality",
"nominal_position",
"inferred_role",
).prefetch_related("aliases")
class CompetitionListApiView(ReadOnlyBaseAPIView, generics.ListAPIView):
serializer_class = CompetitionSerializer
queryset = Competition.objects.select_related("country").order_by("name")
class TeamListApiView(ReadOnlyBaseAPIView, generics.ListAPIView):
serializer_class = TeamSerializer
queryset = Team.objects.select_related("country").order_by("name")
class SeasonListApiView(ReadOnlyBaseAPIView, generics.ListAPIView):
serializer_class = SeasonSerializer
queryset = Season.objects.order_by("-start_date")