diff --git a/.env.example b/.env.example index 7d26c8a..e02aebe 100644 --- a/.env.example +++ b/.env.example @@ -35,3 +35,5 @@ PROVIDER_REQUEST_RETRIES=3 PROVIDER_REQUEST_RETRY_SLEEP=1 CELERY_TASK_TIME_LIMIT=1800 CELERY_TASK_SOFT_TIME_LIMIT=1500 +API_THROTTLE_ANON=100/hour +API_THROTTLE_USER=1000/hour diff --git a/apps/api/__init__.py b/apps/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps.py b/apps/api/apps.py new file mode 100644 index 0000000..a79077a --- /dev/null +++ b/apps/api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.api" diff --git a/apps/api/permissions.py b/apps/api/permissions.py new file mode 100644 index 0000000..e8b9425 --- /dev/null +++ b/apps/api/permissions.py @@ -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") diff --git a/apps/api/serializers.py b/apps/api/serializers.py new file mode 100644 index 0000000..3eaddf3 --- /dev/null +++ b/apps/api/serializers.py @@ -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 diff --git a/apps/api/urls.py b/apps/api/urls.py new file mode 100644 index 0000000..3f3be6c --- /dev/null +++ b/apps/api/urls.py @@ -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//", 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"), +] diff --git a/apps/api/views.py b/apps/api/views.py new file mode 100644 index 0000000..dc4d64e --- /dev/null +++ b/apps/api/views.py @@ -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") diff --git a/apps/players/services/search.py b/apps/players/services/search.py index 25248f2..fdd1674 100644 --- a/apps/players/services/search.py +++ b/apps/players/services/search.py @@ -1,6 +1,17 @@ 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 apps.players.models import Player @@ -106,14 +117,42 @@ def filter_players(queryset, data: dict): ) 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)), - ppg_value=Coalesce(Max("player_seasons__stats__points"), Value(0.0)), - rpg_value=Coalesce(Max("player_seasons__stats__rebounds"), Value(0.0)), - apg_value=Coalesce(Max("player_seasons__stats__assists"), Value(0.0)), - spg_value=Coalesce(Max("player_seasons__stats__steals"), Value(0.0)), - bpg_value=Coalesce(Max("player_seasons__stats__blocks"), Value(0.0)), - top_efficiency=Coalesce(Max("player_seasons__stats__player_efficiency_rating"), Value(0.0)), + ppg_value=Coalesce( + Max("player_seasons__stats__points"), + Value(0, output_field=DecimalField(max_digits=6, decimal_places=2)), + output_field=DecimalField(max_digits=6, decimal_places=2), + ), + 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() diff --git a/config/settings/base.py b/config/settings/base.py index 0f098e8..4cd767e 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -30,6 +30,8 @@ INSTALLED_APPS = [ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "rest_framework", + "apps.api", "apps.core", "apps.users", "apps.players", @@ -123,3 +125,17 @@ PROVIDER_MVP_DATA_FILE = os.getenv( ) PROVIDER_REQUEST_RETRIES = int(os.getenv("PROVIDER_REQUEST_RETRIES", "3")) 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"), + }, +} diff --git a/config/urls.py b/config/urls.py index 20d1a1a..4526a0d 100644 --- a/config/urls.py +++ b/config/urls.py @@ -3,6 +3,7 @@ from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls), + path("api/", include("apps.api.urls")), path("", include("apps.core.urls")), path("users/", include("apps.users.urls")), path("players/", include("apps.players.urls")), diff --git a/requirements/base.txt b/requirements/base.txt index 1070d58..f008adc 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,4 +1,5 @@ Django>=5.1,<6.0 +djangorestframework>=3.15,<4.0 psycopg[binary]>=3.2,<4.0 gunicorn>=22.0,<23.0 celery[redis]>=5.4,<6.0 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..ba3b803 --- /dev/null +++ b/tests/test_api.py @@ -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