phase7: add minimal read-only drf api with player search endpoints
This commit is contained in:
@ -35,3 +35,5 @@ PROVIDER_REQUEST_RETRIES=3
|
|||||||
PROVIDER_REQUEST_RETRY_SLEEP=1
|
PROVIDER_REQUEST_RETRY_SLEEP=1
|
||||||
CELERY_TASK_TIME_LIMIT=1800
|
CELERY_TASK_TIME_LIMIT=1800
|
||||||
CELERY_TASK_SOFT_TIME_LIMIT=1500
|
CELERY_TASK_SOFT_TIME_LIMIT=1500
|
||||||
|
API_THROTTLE_ANON=100/hour
|
||||||
|
API_THROTTLE_USER=1000/hour
|
||||||
|
|||||||
0
apps/api/__init__.py
Normal file
0
apps/api/__init__.py
Normal file
6
apps/api/apps.py
Normal file
6
apps/api/apps.py
Normal 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
6
apps/api/permissions.py
Normal 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
151
apps/api/serializers.py
Normal 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
19
apps/api/urls.py
Normal 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
68
apps/api/views.py
Normal 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")
|
||||||
@ -1,6 +1,17 @@
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
from django.db.models import Case, ExpressionWrapper, F, FloatField, Max, Q, Value, When
|
from django.db.models import (
|
||||||
|
Case,
|
||||||
|
DecimalField,
|
||||||
|
ExpressionWrapper,
|
||||||
|
F,
|
||||||
|
FloatField,
|
||||||
|
IntegerField,
|
||||||
|
Max,
|
||||||
|
Q,
|
||||||
|
Value,
|
||||||
|
When,
|
||||||
|
)
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
|
|
||||||
from apps.players.models import Player
|
from apps.players.models import Player
|
||||||
@ -106,14 +117,42 @@ def filter_players(queryset, data: dict):
|
|||||||
)
|
)
|
||||||
|
|
||||||
queryset = queryset.annotate(
|
queryset = queryset.annotate(
|
||||||
games_played_value=Coalesce(Max("player_seasons__games_played"), Value(0)),
|
games_played_value=Coalesce(
|
||||||
|
Max("player_seasons__games_played"),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
),
|
||||||
mpg_value=Coalesce(Max(mpg_expression), Value(0.0)),
|
mpg_value=Coalesce(Max(mpg_expression), Value(0.0)),
|
||||||
ppg_value=Coalesce(Max("player_seasons__stats__points"), Value(0.0)),
|
ppg_value=Coalesce(
|
||||||
rpg_value=Coalesce(Max("player_seasons__stats__rebounds"), Value(0.0)),
|
Max("player_seasons__stats__points"),
|
||||||
apg_value=Coalesce(Max("player_seasons__stats__assists"), Value(0.0)),
|
Value(0, output_field=DecimalField(max_digits=6, decimal_places=2)),
|
||||||
spg_value=Coalesce(Max("player_seasons__stats__steals"), Value(0.0)),
|
output_field=DecimalField(max_digits=6, decimal_places=2),
|
||||||
bpg_value=Coalesce(Max("player_seasons__stats__blocks"), Value(0.0)),
|
),
|
||||||
top_efficiency=Coalesce(Max("player_seasons__stats__player_efficiency_rating"), Value(0.0)),
|
rpg_value=Coalesce(
|
||||||
|
Max("player_seasons__stats__rebounds"),
|
||||||
|
Value(0, output_field=DecimalField(max_digits=6, decimal_places=2)),
|
||||||
|
output_field=DecimalField(max_digits=6, decimal_places=2),
|
||||||
|
),
|
||||||
|
apg_value=Coalesce(
|
||||||
|
Max("player_seasons__stats__assists"),
|
||||||
|
Value(0, output_field=DecimalField(max_digits=6, decimal_places=2)),
|
||||||
|
output_field=DecimalField(max_digits=6, decimal_places=2),
|
||||||
|
),
|
||||||
|
spg_value=Coalesce(
|
||||||
|
Max("player_seasons__stats__steals"),
|
||||||
|
Value(0, output_field=DecimalField(max_digits=6, decimal_places=2)),
|
||||||
|
output_field=DecimalField(max_digits=6, decimal_places=2),
|
||||||
|
),
|
||||||
|
bpg_value=Coalesce(
|
||||||
|
Max("player_seasons__stats__blocks"),
|
||||||
|
Value(0, output_field=DecimalField(max_digits=6, decimal_places=2)),
|
||||||
|
output_field=DecimalField(max_digits=6, decimal_places=2),
|
||||||
|
),
|
||||||
|
top_efficiency=Coalesce(
|
||||||
|
Max("player_seasons__stats__player_efficiency_rating"),
|
||||||
|
Value(0, output_field=DecimalField(max_digits=6, decimal_places=2)),
|
||||||
|
output_field=DecimalField(max_digits=6, decimal_places=2),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return queryset.distinct()
|
return queryset.distinct()
|
||||||
|
|||||||
@ -30,6 +30,8 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
|
"rest_framework",
|
||||||
|
"apps.api",
|
||||||
"apps.core",
|
"apps.core",
|
||||||
"apps.users",
|
"apps.users",
|
||||||
"apps.players",
|
"apps.players",
|
||||||
@ -123,3 +125,17 @@ PROVIDER_MVP_DATA_FILE = os.getenv(
|
|||||||
)
|
)
|
||||||
PROVIDER_REQUEST_RETRIES = int(os.getenv("PROVIDER_REQUEST_RETRIES", "3"))
|
PROVIDER_REQUEST_RETRIES = int(os.getenv("PROVIDER_REQUEST_RETRIES", "3"))
|
||||||
PROVIDER_REQUEST_RETRY_SLEEP = float(os.getenv("PROVIDER_REQUEST_RETRY_SLEEP", "1"))
|
PROVIDER_REQUEST_RETRY_SLEEP = float(os.getenv("PROVIDER_REQUEST_RETRY_SLEEP", "1"))
|
||||||
|
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
"DEFAULT_PERMISSION_CLASSES": [
|
||||||
|
"apps.api.permissions.ReadOnlyOrDeny",
|
||||||
|
],
|
||||||
|
"DEFAULT_THROTTLE_CLASSES": [
|
||||||
|
"rest_framework.throttling.AnonRateThrottle",
|
||||||
|
"rest_framework.throttling.UserRateThrottle",
|
||||||
|
],
|
||||||
|
"DEFAULT_THROTTLE_RATES": {
|
||||||
|
"anon": os.getenv("API_THROTTLE_ANON", "100/hour"),
|
||||||
|
"user": os.getenv("API_THROTTLE_USER", "1000/hour"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ from django.urls import include, path
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
|
path("api/", include("apps.api.urls")),
|
||||||
path("", include("apps.core.urls")),
|
path("", include("apps.core.urls")),
|
||||||
path("users/", include("apps.users.urls")),
|
path("users/", include("apps.users.urls")),
|
||||||
path("players/", include("apps.players.urls")),
|
path("players/", include("apps.players.urls")),
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
Django>=5.1,<6.0
|
Django>=5.1,<6.0
|
||||||
|
djangorestframework>=3.15,<4.0
|
||||||
psycopg[binary]>=3.2,<4.0
|
psycopg[binary]>=3.2,<4.0
|
||||||
gunicorn>=22.0,<23.0
|
gunicorn>=22.0,<23.0
|
||||||
celery[redis]>=5.4,<6.0
|
celery[redis]>=5.4,<6.0
|
||||||
|
|||||||
59
tests/test_api.py
Normal file
59
tests/test_api.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.competitions.models import Competition, Season
|
||||||
|
from apps.players.models import Nationality, Player, Position, Role
|
||||||
|
from apps.teams.models import Team
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_players_api_list_and_detail(client):
|
||||||
|
nationality = Nationality.objects.create(name="Italy", iso2_code="IT", iso3_code="ITA")
|
||||||
|
position = Position.objects.create(code="PG", name="Point Guard")
|
||||||
|
role = Role.objects.create(code="playmaker", name="Playmaker")
|
||||||
|
|
||||||
|
player = Player.objects.create(
|
||||||
|
first_name="Marco",
|
||||||
|
last_name="Rossi",
|
||||||
|
full_name="Marco Rossi",
|
||||||
|
birth_date=date(2001, 1, 5),
|
||||||
|
nationality=nationality,
|
||||||
|
nominal_position=position,
|
||||||
|
inferred_role=role,
|
||||||
|
height_cm=190,
|
||||||
|
weight_kg=84,
|
||||||
|
)
|
||||||
|
|
||||||
|
list_response = client.get(reverse("api:players"), data={"q": "rossi"})
|
||||||
|
assert list_response.status_code == 200
|
||||||
|
assert list_response.json()["count"] == 1
|
||||||
|
|
||||||
|
detail_response = client.get(reverse("api:player_detail", kwargs={"pk": player.pk}))
|
||||||
|
assert detail_response.status_code == 200
|
||||||
|
assert detail_response.json()["full_name"] == "Marco Rossi"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_lookup_list_endpoints(client):
|
||||||
|
nationality = Nationality.objects.create(name="Spain", iso2_code="ES", iso3_code="ESP")
|
||||||
|
Competition.objects.create(
|
||||||
|
name="Liga ACB",
|
||||||
|
slug="liga-acb",
|
||||||
|
competition_type=Competition.CompetitionType.LEAGUE,
|
||||||
|
gender=Competition.Gender.MEN,
|
||||||
|
country=nationality,
|
||||||
|
)
|
||||||
|
Team.objects.create(name="Madrid Flight", slug="madrid-flight", country=nationality)
|
||||||
|
Season.objects.create(label="2025-2026", start_date=date(2025, 9, 1), end_date=date(2026, 6, 30), is_current=True)
|
||||||
|
|
||||||
|
assert client.get(reverse("api:competitions")).status_code == 200
|
||||||
|
assert client.get(reverse("api:teams")).status_code == 200
|
||||||
|
assert client.get(reverse("api:seasons")).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_api_is_read_only(client):
|
||||||
|
response = client.post(reverse("api:players"), data={"q": "x"})
|
||||||
|
assert response.status_code == 403
|
||||||
Reference in New Issue
Block a user