Align balldontlie OpenAPI integration and clarify search metric semantics
This commit is contained in:
@ -59,14 +59,12 @@ PROVIDER_MVP_DATA_FILE=/app/apps/providers/data/mvp_provider.json
|
|||||||
PROVIDER_REQUEST_RETRIES=3
|
PROVIDER_REQUEST_RETRIES=3
|
||||||
PROVIDER_REQUEST_RETRY_SLEEP=1
|
PROVIDER_REQUEST_RETRY_SLEEP=1
|
||||||
PROVIDER_HTTP_TIMEOUT_SECONDS=10
|
PROVIDER_HTTP_TIMEOUT_SECONDS=10
|
||||||
PROVIDER_BALLDONTLIE_BASE_URL=https://api.balldontlie.io/nba/v1
|
PROVIDER_BALLDONTLIE_BASE_URL=https://api.balldontlie.io
|
||||||
PROVIDER_BALLDONTLIE_API_KEY=
|
PROVIDER_BALLDONTLIE_API_KEY=
|
||||||
# NBA-centric MVP provider seasons to ingest (comma-separated years).
|
# NBA-centric MVP provider seasons to ingest (comma-separated years).
|
||||||
PROVIDER_BALLDONTLIE_SEASONS=2024
|
PROVIDER_BALLDONTLIE_SEASONS=2024
|
||||||
PROVIDER_BALLDONTLIE_PLAYERS_PAGE_LIMIT=5
|
PROVIDER_BALLDONTLIE_PLAYERS_PAGE_LIMIT=5
|
||||||
PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE=100
|
PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE=100
|
||||||
PROVIDER_BALLDONTLIE_GAMES_PAGE_LIMIT=5
|
|
||||||
PROVIDER_BALLDONTLIE_GAMES_PER_PAGE=100
|
|
||||||
PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT=10
|
PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT=10
|
||||||
PROVIDER_BALLDONTLIE_STATS_PER_PAGE=100
|
PROVIDER_BALLDONTLIE_STATS_PER_PAGE=100
|
||||||
# When 0, a 401 on stats endpoint degrades to players/teams-only sync.
|
# When 0, a 401 on stats endpoint degrades to players/teams-only sync.
|
||||||
|
|||||||
@ -135,7 +135,11 @@ Notes:
|
|||||||
|
|
||||||
- The server-rendered player search page (`/players/`) and read-only players API (`/api/players/`) use the same search form and ORM filter service.
|
- The server-rendered player search page (`/players/`) and read-only players API (`/api/players/`) use the same search form and ORM filter service.
|
||||||
- Sorting/filter semantics are aligned across UI, HTMX partial refreshes, and API responses.
|
- Sorting/filter semantics are aligned across UI, HTMX partial refreshes, and API responses.
|
||||||
- Statistical metric annotations are always computed for UI result tables, and only computed for API requests when metric-based sorting is requested.
|
- Search result metrics in the UI table use **best eligible semantics**:
|
||||||
|
- each metric (Games, MPG, PPG, RPG, APG) is the maximum value across eligible player-season rows
|
||||||
|
- eligibility is scoped by the active season/team/competition/stat filters
|
||||||
|
- different displayed metrics for one player can come from different eligible rows
|
||||||
|
- Metric-based API sorting (`ppg_*`, `mpg_*`) uses the same best-eligible semantics as UI search.
|
||||||
|
|
||||||
## Docker Volumes and Persistence
|
## Docker Volumes and Persistence
|
||||||
|
|
||||||
@ -325,7 +329,7 @@ Provider backend is selected via environment variables:
|
|||||||
- `PROVIDER_DEFAULT_NAMESPACE` can override backend mapping explicitly
|
- `PROVIDER_DEFAULT_NAMESPACE` can override backend mapping explicitly
|
||||||
|
|
||||||
The balldontlie adapter is NBA-centric and intended as MVP ingestion only. The provider abstraction remains ready for future multi-league providers (for example Sportradar or FIBA GDAP).
|
The balldontlie adapter is NBA-centric and intended as MVP ingestion only. The provider abstraction remains ready for future multi-league providers (for example Sportradar or FIBA GDAP).
|
||||||
The adapter uses balldontlie getting-started query style (`/nba/v1`, cursor pagination, stats by `game_ids[]`).
|
The adapter follows the published balldontlie OpenAPI contract: server `https://api.balldontlie.io`, NBA endpoints under `/nba/v1/*`, cursor pagination via `meta.next_cursor`, and `stats` ingestion filtered by `seasons[]`.
|
||||||
Some balldontlie plans do not include stats endpoints; set `PROVIDER_BALLDONTLIE_STATS_STRICT=0` (default) to ingest players/teams/seasons even when stats are unauthorized.
|
Some balldontlie plans do not include stats endpoints; set `PROVIDER_BALLDONTLIE_STATS_STRICT=0` (default) to ingest players/teams/seasons even when stats are unauthorized.
|
||||||
|
|
||||||
Provider normalization details and explicit adapter assumptions are documented in [docs/provider-normalization.md](docs/provider-normalization.md).
|
Provider normalization details and explicit adapter assumptions are documented in [docs/provider-normalization.md](docs/provider-normalization.md).
|
||||||
|
|||||||
@ -38,6 +38,13 @@ class ReadOnlyBaseAPIView:
|
|||||||
|
|
||||||
|
|
||||||
class PlayerSearchApiView(ReadOnlyBaseAPIView, generics.ListAPIView):
|
class PlayerSearchApiView(ReadOnlyBaseAPIView, generics.ListAPIView):
|
||||||
|
"""
|
||||||
|
Read-only player search API.
|
||||||
|
|
||||||
|
Metric sorts (`ppg_*`, `mpg_*`) follow the same best-eligible semantics as UI search:
|
||||||
|
max metric value across eligible player-season rows after applying search filters.
|
||||||
|
"""
|
||||||
|
|
||||||
serializer_class = PlayerListSerializer
|
serializer_class = PlayerListSerializer
|
||||||
pagination_class = ApiPagination
|
pagination_class = ApiPagination
|
||||||
|
|
||||||
|
|||||||
@ -13,10 +13,10 @@ class PlayerSearchForm(forms.Form):
|
|||||||
("age_oldest", "Age (Oldest first)"),
|
("age_oldest", "Age (Oldest first)"),
|
||||||
("height_desc", "Height (Tallest first)"),
|
("height_desc", "Height (Tallest first)"),
|
||||||
("height_asc", "Height (Shortest first)"),
|
("height_asc", "Height (Shortest first)"),
|
||||||
("ppg_desc", "Points per game (High to low)"),
|
("ppg_desc", "Best eligible PPG (High to low)"),
|
||||||
("ppg_asc", "Points per game (Low to high)"),
|
("ppg_asc", "Best eligible PPG (Low to high)"),
|
||||||
("mpg_desc", "Minutes per game (High to low)"),
|
("mpg_desc", "Best eligible MPG (High to low)"),
|
||||||
("mpg_asc", "Minutes per game (Low to high)"),
|
("mpg_asc", "Best eligible MPG (Low to high)"),
|
||||||
)
|
)
|
||||||
|
|
||||||
PAGE_SIZE_CHOICES = ((20, "20"), (50, "50"), (100, "100"))
|
PAGE_SIZE_CHOICES = ((20, "20"), (50, "50"), (100, "100"))
|
||||||
|
|||||||
@ -20,6 +20,10 @@ from apps.players.models import Player
|
|||||||
from apps.stats.models import PlayerSeason
|
from apps.stats.models import PlayerSeason
|
||||||
|
|
||||||
METRIC_SORT_KEYS = {"ppg_desc", "ppg_asc", "mpg_desc", "mpg_asc"}
|
METRIC_SORT_KEYS = {"ppg_desc", "ppg_asc", "mpg_desc", "mpg_asc"}
|
||||||
|
SEARCH_METRIC_SEMANTICS_TEXT = (
|
||||||
|
"Search metrics are best eligible values per player (max per metric across eligible player-season rows). "
|
||||||
|
"With season/team/competition/stat filters, eligibility is scoped by those filters."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _years_ago_today(years: int) -> date:
|
def _years_ago_today(years: int) -> date:
|
||||||
@ -213,6 +217,13 @@ def filter_players(queryset, data: dict):
|
|||||||
|
|
||||||
|
|
||||||
def annotate_player_metrics(queryset, data: dict | None = None):
|
def annotate_player_metrics(queryset, data: dict | None = None):
|
||||||
|
"""
|
||||||
|
Annotate player list metrics using best-eligible semantics.
|
||||||
|
|
||||||
|
Each metric is computed as MAX over eligible player-season rows. This is intentionally
|
||||||
|
not a single-row projection; different displayed metrics for one player can come from
|
||||||
|
different eligible player-season rows.
|
||||||
|
"""
|
||||||
data = data or {}
|
data = data or {}
|
||||||
context_filter = _build_metric_context_filter(data)
|
context_filter = _build_metric_context_filter(data)
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,13 @@ from apps.stats.models import PlayerSeason
|
|||||||
|
|
||||||
from .forms import PlayerSearchForm
|
from .forms import PlayerSearchForm
|
||||||
from .models import Player, PlayerCareerEntry
|
from .models import Player, PlayerCareerEntry
|
||||||
from .services.search import annotate_player_metrics, apply_sorting, base_player_queryset, filter_players
|
from .services.search import (
|
||||||
|
SEARCH_METRIC_SEMANTICS_TEXT,
|
||||||
|
annotate_player_metrics,
|
||||||
|
apply_sorting,
|
||||||
|
base_player_queryset,
|
||||||
|
filter_players,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def calculate_age(birth_date):
|
def calculate_age(birth_date):
|
||||||
@ -61,6 +67,7 @@ class PlayerSearchView(ListView):
|
|||||||
search_form = self.get_form()
|
search_form = self.get_form()
|
||||||
context["search_form"] = search_form
|
context["search_form"] = search_form
|
||||||
context["search_has_errors"] = search_form.is_bound and bool(search_form.errors)
|
context["search_has_errors"] = search_form.is_bound and bool(search_form.errors)
|
||||||
|
context["search_metric_semantics"] = SEARCH_METRIC_SEMANTICS_TEXT
|
||||||
context["favorite_player_ids"] = set()
|
context["favorite_player_ids"] = set()
|
||||||
if self.request.user.is_authenticated:
|
if self.request.user.is_authenticated:
|
||||||
player_ids = [player.id for player in context["players"]]
|
player_ids = [player.id for player in context["players"]]
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import logging
|
import logging
|
||||||
from itertools import islice
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
@ -30,6 +29,7 @@ class BalldontlieProviderAdapter(BaseProviderAdapter):
|
|||||||
"""HTTP MVP adapter for balldontlie (NBA-centric data source)."""
|
"""HTTP MVP adapter for balldontlie (NBA-centric data source)."""
|
||||||
|
|
||||||
namespace = "balldontlie"
|
namespace = "balldontlie"
|
||||||
|
nba_prefix = "/nba/v1"
|
||||||
|
|
||||||
def __init__(self, client: BalldontlieClient | None = None):
|
def __init__(self, client: BalldontlieClient | None = None):
|
||||||
self.client = client or BalldontlieClient()
|
self.client = client or BalldontlieClient()
|
||||||
@ -38,46 +38,23 @@ class BalldontlieProviderAdapter(BaseProviderAdapter):
|
|||||||
def configured_seasons(self) -> list[int]:
|
def configured_seasons(self) -> list[int]:
|
||||||
return settings.PROVIDER_BALLDONTLIE_SEASONS
|
return settings.PROVIDER_BALLDONTLIE_SEASONS
|
||||||
|
|
||||||
@staticmethod
|
def _api_path(self, path: str) -> str:
|
||||||
def _chunked(values: list[int], size: int):
|
# Support both base URL variants:
|
||||||
iterator = iter(values)
|
# - https://api.balldontlie.io
|
||||||
while True:
|
# - https://api.balldontlie.io/nba/v1
|
||||||
chunk = list(islice(iterator, size))
|
base = getattr(self.client, "base_url", "").rstrip("/")
|
||||||
if not chunk:
|
if base.endswith("/nba/v1"):
|
||||||
return
|
return path.lstrip("/")
|
||||||
yield chunk
|
return f"{self.nba_prefix}/{path.lstrip('/')}"
|
||||||
|
|
||||||
def _fetch_game_ids(self) -> list[int]:
|
|
||||||
game_ids: set[int] = set()
|
|
||||||
for season in self.configured_seasons:
|
|
||||||
rows = self.client.list_paginated(
|
|
||||||
"games",
|
|
||||||
params={"seasons[]": season},
|
|
||||||
per_page=settings.PROVIDER_BALLDONTLIE_GAMES_PER_PAGE,
|
|
||||||
page_limit=settings.PROVIDER_BALLDONTLIE_GAMES_PAGE_LIMIT,
|
|
||||||
)
|
|
||||||
for row in rows:
|
|
||||||
game_id = row.get("id")
|
|
||||||
if isinstance(game_id, int):
|
|
||||||
game_ids.add(game_id)
|
|
||||||
return sorted(game_ids)
|
|
||||||
|
|
||||||
def _fetch_stats_rows(self) -> list[dict]:
|
def _fetch_stats_rows(self) -> list[dict]:
|
||||||
game_ids = self._fetch_game_ids()
|
|
||||||
if not game_ids:
|
|
||||||
logger.info(
|
|
||||||
"provider_stats_skipped_no_games",
|
|
||||||
extra={"provider": self.namespace, "seasons": self.configured_seasons},
|
|
||||||
)
|
|
||||||
return []
|
|
||||||
|
|
||||||
all_rows: list[dict] = []
|
all_rows: list[dict] = []
|
||||||
try:
|
try:
|
||||||
# Use game_ids[] query as documented in balldontlie getting-started flow.
|
# OpenAPI supports seasons[] directly for /nba/v1/stats.
|
||||||
for game_id_chunk in self._chunked(game_ids, 25):
|
for season in self.configured_seasons:
|
||||||
rows = self.client.list_paginated(
|
rows = self.client.list_paginated(
|
||||||
"stats",
|
self._api_path("stats"),
|
||||||
params={"game_ids[]": game_id_chunk},
|
params={"seasons[]": season},
|
||||||
per_page=settings.PROVIDER_BALLDONTLIE_STATS_PER_PAGE,
|
per_page=settings.PROVIDER_BALLDONTLIE_STATS_PER_PAGE,
|
||||||
page_limit=settings.PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT,
|
page_limit=settings.PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT,
|
||||||
)
|
)
|
||||||
@ -101,7 +78,7 @@ class BalldontlieProviderAdapter(BaseProviderAdapter):
|
|||||||
def search_players(self, *, query: str = "", limit: int = 50, offset: int = 0) -> list[PlayerPayload]:
|
def search_players(self, *, query: str = "", limit: int = 50, offset: int = 0) -> list[PlayerPayload]:
|
||||||
params = {"search": query} if query else None
|
params = {"search": query} if query else None
|
||||||
rows = self.client.list_paginated(
|
rows = self.client.list_paginated(
|
||||||
"players",
|
self._api_path("players"),
|
||||||
params=params,
|
params=params,
|
||||||
per_page=min(limit, settings.PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE),
|
per_page=min(limit, settings.PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE),
|
||||||
page_limit=1,
|
page_limit=1,
|
||||||
@ -113,7 +90,7 @@ class BalldontlieProviderAdapter(BaseProviderAdapter):
|
|||||||
if not external_player_id.startswith("player-"):
|
if not external_player_id.startswith("player-"):
|
||||||
return None
|
return None
|
||||||
player_id = external_player_id.replace("player-", "", 1)
|
player_id = external_player_id.replace("player-", "", 1)
|
||||||
payload = self.client.get_json(f"players/{player_id}")
|
payload = self.client.get_json(self._api_path(f"players/{player_id}"))
|
||||||
data = payload.get("data")
|
data = payload.get("data")
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return None
|
return None
|
||||||
@ -122,7 +99,7 @@ class BalldontlieProviderAdapter(BaseProviderAdapter):
|
|||||||
|
|
||||||
def fetch_players(self) -> list[PlayerPayload]:
|
def fetch_players(self) -> list[PlayerPayload]:
|
||||||
rows = self.client.list_paginated(
|
rows = self.client.list_paginated(
|
||||||
"players",
|
self._api_path("players"),
|
||||||
per_page=settings.PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE,
|
per_page=settings.PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE,
|
||||||
page_limit=settings.PROVIDER_BALLDONTLIE_PLAYERS_PAGE_LIMIT,
|
page_limit=settings.PROVIDER_BALLDONTLIE_PLAYERS_PAGE_LIMIT,
|
||||||
)
|
)
|
||||||
@ -132,7 +109,7 @@ class BalldontlieProviderAdapter(BaseProviderAdapter):
|
|||||||
return map_competitions()
|
return map_competitions()
|
||||||
|
|
||||||
def fetch_teams(self) -> list[TeamPayload]:
|
def fetch_teams(self) -> list[TeamPayload]:
|
||||||
payload = self.client.get_json("teams")
|
payload = self.client.get_json(self._api_path("teams"))
|
||||||
rows = payload.get("data") or []
|
rows = payload.get("data") or []
|
||||||
return map_teams(rows if isinstance(rows, list) else [])
|
return map_teams(rows if isinstance(rows, list) else [])
|
||||||
|
|
||||||
|
|||||||
@ -123,9 +123,6 @@ class BalldontlieClient:
|
|||||||
request_query["per_page"] = per_page
|
request_query["per_page"] = per_page
|
||||||
if cursor is not None:
|
if cursor is not None:
|
||||||
request_query["cursor"] = cursor
|
request_query["cursor"] = cursor
|
||||||
else:
|
|
||||||
# Keep backwards compatibility for endpoints still supporting page-based pagination.
|
|
||||||
request_query["page"] = page
|
|
||||||
|
|
||||||
payload = self.get_json(path, params=request_query)
|
payload = self.get_json(path, params=request_query)
|
||||||
data = payload.get("data") or []
|
data = payload.get("data") or []
|
||||||
@ -139,11 +136,6 @@ class BalldontlieClient:
|
|||||||
page += 1
|
page += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
next_page = meta.get("next_page")
|
|
||||||
if next_page:
|
|
||||||
page = int(next_page)
|
|
||||||
continue
|
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
|
|||||||
@ -171,14 +171,12 @@ 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"))
|
||||||
PROVIDER_HTTP_TIMEOUT_SECONDS = float(os.getenv("PROVIDER_HTTP_TIMEOUT_SECONDS", "10"))
|
PROVIDER_HTTP_TIMEOUT_SECONDS = float(os.getenv("PROVIDER_HTTP_TIMEOUT_SECONDS", "10"))
|
||||||
PROVIDER_BALLDONTLIE_BASE_URL = os.getenv("PROVIDER_BALLDONTLIE_BASE_URL", "https://api.balldontlie.io/nba/v1")
|
PROVIDER_BALLDONTLIE_BASE_URL = os.getenv("PROVIDER_BALLDONTLIE_BASE_URL", "https://api.balldontlie.io")
|
||||||
PROVIDER_BALLDONTLIE_API_KEY = os.getenv("PROVIDER_BALLDONTLIE_API_KEY", "")
|
PROVIDER_BALLDONTLIE_API_KEY = os.getenv("PROVIDER_BALLDONTLIE_API_KEY", "")
|
||||||
PROVIDER_BALLDONTLIE_PLAYERS_PAGE_LIMIT = int(os.getenv("PROVIDER_BALLDONTLIE_PLAYERS_PAGE_LIMIT", "5"))
|
PROVIDER_BALLDONTLIE_PLAYERS_PAGE_LIMIT = int(os.getenv("PROVIDER_BALLDONTLIE_PLAYERS_PAGE_LIMIT", "5"))
|
||||||
PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE = int(os.getenv("PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE", "100"))
|
PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE = int(os.getenv("PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE", "100"))
|
||||||
PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT = int(os.getenv("PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT", "10"))
|
PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT = int(os.getenv("PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT", "10"))
|
||||||
PROVIDER_BALLDONTLIE_STATS_PER_PAGE = int(os.getenv("PROVIDER_BALLDONTLIE_STATS_PER_PAGE", "100"))
|
PROVIDER_BALLDONTLIE_STATS_PER_PAGE = int(os.getenv("PROVIDER_BALLDONTLIE_STATS_PER_PAGE", "100"))
|
||||||
PROVIDER_BALLDONTLIE_GAMES_PAGE_LIMIT = int(os.getenv("PROVIDER_BALLDONTLIE_GAMES_PAGE_LIMIT", "5"))
|
|
||||||
PROVIDER_BALLDONTLIE_GAMES_PER_PAGE = int(os.getenv("PROVIDER_BALLDONTLIE_GAMES_PER_PAGE", "100"))
|
|
||||||
PROVIDER_BALLDONTLIE_STATS_STRICT = env_bool("PROVIDER_BALLDONTLIE_STATS_STRICT", False)
|
PROVIDER_BALLDONTLIE_STATS_STRICT = env_bool("PROVIDER_BALLDONTLIE_STATS_STRICT", False)
|
||||||
PROVIDER_BALLDONTLIE_SEASONS = [
|
PROVIDER_BALLDONTLIE_SEASONS = [
|
||||||
int(value.strip())
|
int(value.strip())
|
||||||
|
|||||||
@ -24,9 +24,11 @@ Raw provider response structures must remain inside `apps/providers` (client/ada
|
|||||||
|
|
||||||
- Source scope is NBA-centric.
|
- Source scope is NBA-centric.
|
||||||
- Competition is normalized as a single NBA competition (`competition-nba`).
|
- Competition is normalized as a single NBA competition (`competition-nba`).
|
||||||
|
- API contract source is `https://www.balldontlie.io/openapi.yml` (server `https://api.balldontlie.io`, NBA endpoints under `/nba/v1/*`).
|
||||||
- Team country is not reliably available in source payloads and is normalized to `null`.
|
- Team country is not reliably available in source payloads and is normalized to `null`.
|
||||||
- Player nationality/birth/physical details are not available in player list payloads and are normalized to `null` (except fields explicitly present).
|
- Player nationality/birth/physical details are not available in player list payloads and are normalized to `null` (except fields explicitly present).
|
||||||
- Configured seasons are normalized from `PROVIDER_BALLDONTLIE_SEASONS`; the highest configured season is marked `is_current=true`.
|
- Configured seasons are normalized from `PROVIDER_BALLDONTLIE_SEASONS`; the highest configured season is marked `is_current=true`.
|
||||||
|
- Stats ingestion uses `/nba/v1/stats` with `seasons[]` and cursor pagination.
|
||||||
- Advanced metrics (`usage_rate`, `true_shooting_pct`, `player_efficiency_rating`) are currently unavailable from this source path and normalized to `null`.
|
- Advanced metrics (`usage_rate`, `true_shooting_pct`, `player_efficiency_rating`) are currently unavailable from this source path and normalized to `null`.
|
||||||
|
|
||||||
## Domain rules vs provider assumptions
|
## Domain rules vs provider assumptions
|
||||||
|
|||||||
1187
static/css/main.css
1187
static/css/main.css
File diff suppressed because one or more lines are too long
@ -6,6 +6,9 @@
|
|||||||
{{ page_obj.paginator.count }} player{{ page_obj.paginator.count|pluralize }} found
|
{{ page_obj.paginator.count }} player{{ page_obj.paginator.count|pluralize }} found
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-slate-600">
|
||||||
|
{{ search_metric_semantics }}
|
||||||
|
</p>
|
||||||
|
|
||||||
{% if search_has_errors %}
|
{% if search_has_errors %}
|
||||||
<div class="mt-3 rounded-md border border-rose-200 bg-rose-50 p-3 text-sm text-rose-800">
|
<div class="mt-3 rounded-md border border-rose-200 bg-rose-50 p-3 text-sm text-rose-800">
|
||||||
@ -35,11 +38,11 @@
|
|||||||
<th>Pos / Role</th>
|
<th>Pos / Role</th>
|
||||||
<th>Origin</th>
|
<th>Origin</th>
|
||||||
<th>Height / Weight</th>
|
<th>Height / Weight</th>
|
||||||
<th>Games</th>
|
<th>Best Eligible Games</th>
|
||||||
<th>MPG</th>
|
<th>Best Eligible MPG</th>
|
||||||
<th>PPG</th>
|
<th>Best Eligible PPG</th>
|
||||||
<th>RPG</th>
|
<th>Best Eligible RPG</th>
|
||||||
<th>APG</th>
|
<th>Best Eligible APG</th>
|
||||||
{% if request.user.is_authenticated %}<th>Watchlist</th>{% endif %}
|
{% if request.user.is_authenticated %}<th>Watchlist</th>{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@ -128,6 +128,57 @@ def test_players_api_search_consistent_with_ui_filters(client):
|
|||||||
assert api_response.json()["results"][0]["id"] == matching.id
|
assert api_response.json()["results"][0]["id"] == matching.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_players_api_metric_sort_uses_best_eligible_values(client):
|
||||||
|
nationality = Nationality.objects.create(name="Romania", iso2_code="RO", iso3_code="ROU")
|
||||||
|
competition = Competition.objects.create(
|
||||||
|
name="LNBM",
|
||||||
|
slug="lnbm",
|
||||||
|
competition_type=Competition.CompetitionType.LEAGUE,
|
||||||
|
gender=Competition.Gender.MEN,
|
||||||
|
country=nationality,
|
||||||
|
)
|
||||||
|
season_old = Season.objects.create(label="2022-2023", start_date=date(2022, 9, 1), end_date=date(2023, 6, 30))
|
||||||
|
season_new = Season.objects.create(label="2023-2024", start_date=date(2023, 9, 1), end_date=date(2024, 6, 30))
|
||||||
|
team = Team.objects.create(name="Bucharest", slug="bucharest", country=nationality)
|
||||||
|
|
||||||
|
p1 = Player.objects.create(first_name="Ion", last_name="Low", full_name="Ion Low", nationality=nationality)
|
||||||
|
p1s = PlayerSeason.objects.create(
|
||||||
|
player=p1,
|
||||||
|
season=season_old,
|
||||||
|
team=team,
|
||||||
|
competition=competition,
|
||||||
|
games_played=20,
|
||||||
|
minutes_played=400,
|
||||||
|
)
|
||||||
|
PlayerSeasonStats.objects.create(player_season=p1s, points=13, rebounds=3, assists=2, steals=1, blocks=0.1, turnovers=2)
|
||||||
|
|
||||||
|
p2 = Player.objects.create(first_name="Dan", last_name="High", full_name="Dan High", nationality=nationality)
|
||||||
|
p2s_old = PlayerSeason.objects.create(
|
||||||
|
player=p2,
|
||||||
|
season=season_old,
|
||||||
|
team=team,
|
||||||
|
competition=competition,
|
||||||
|
games_played=20,
|
||||||
|
minutes_played=400,
|
||||||
|
)
|
||||||
|
PlayerSeasonStats.objects.create(player_season=p2s_old, points=9, rebounds=3, assists=2, steals=1, blocks=0.1, turnovers=2)
|
||||||
|
p2s_new = PlayerSeason.objects.create(
|
||||||
|
player=p2,
|
||||||
|
season=season_new,
|
||||||
|
team=team,
|
||||||
|
competition=competition,
|
||||||
|
games_played=20,
|
||||||
|
minutes_played=500,
|
||||||
|
)
|
||||||
|
PlayerSeasonStats.objects.create(player_season=p2s_new, points=22, rebounds=5, assists=4, steals=1.3, blocks=0.2, turnovers=2.3)
|
||||||
|
|
||||||
|
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"]]
|
||||||
|
assert names.index("Dan High") < names.index("Ion Low")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_player_detail_api_includes_origin_fields(client):
|
def test_player_detail_api_includes_origin_fields(client):
|
||||||
nationality = Nationality.objects.create(name="Greece", iso2_code="GR", iso3_code="GRC")
|
nationality = Nationality.objects.create(name="Greece", iso2_code="GR", iso3_code="GRC")
|
||||||
|
|||||||
@ -332,3 +332,207 @@ def test_displayed_metrics_are_scoped_to_filtered_context(client):
|
|||||||
row = list(response.context["players"])[0]
|
row = list(response.context["players"])[0]
|
||||||
assert float(row.ppg_value) == pytest.approx(9.0)
|
assert float(row.ppg_value) == pytest.approx(9.0)
|
||||||
assert float(row.mpg_value) == pytest.approx(25.0)
|
assert float(row.mpg_value) == pytest.approx(25.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_displayed_metrics_are_scoped_to_team_season_competition_context(client):
|
||||||
|
nationality = Nationality.objects.create(name="Czechia", iso2_code="CZ", iso3_code="CZE")
|
||||||
|
position = Position.objects.create(code="SF", name="Small Forward")
|
||||||
|
role = Role.objects.create(code="wing", name="Wing")
|
||||||
|
competition_a = Competition.objects.create(
|
||||||
|
name="NBL",
|
||||||
|
slug="nbl-cz",
|
||||||
|
competition_type=Competition.CompetitionType.LEAGUE,
|
||||||
|
gender=Competition.Gender.MEN,
|
||||||
|
country=nationality,
|
||||||
|
)
|
||||||
|
competition_b = Competition.objects.create(
|
||||||
|
name="CZ Cup",
|
||||||
|
slug="cz-cup",
|
||||||
|
competition_type=Competition.CompetitionType.CUP,
|
||||||
|
gender=Competition.Gender.MEN,
|
||||||
|
country=nationality,
|
||||||
|
)
|
||||||
|
season = Season.objects.create(label="2024-2025", start_date=date(2024, 9, 1), end_date=date(2025, 6, 30))
|
||||||
|
team = Team.objects.create(name="Prague", slug="prague", country=nationality)
|
||||||
|
player = Player.objects.create(
|
||||||
|
first_name="Adam",
|
||||||
|
last_name="Scope",
|
||||||
|
full_name="Adam Scope",
|
||||||
|
birth_date=date(2002, 2, 2),
|
||||||
|
nationality=nationality,
|
||||||
|
nominal_position=position,
|
||||||
|
inferred_role=role,
|
||||||
|
)
|
||||||
|
|
||||||
|
ps_a = PlayerSeason.objects.create(
|
||||||
|
player=player,
|
||||||
|
season=season,
|
||||||
|
team=team,
|
||||||
|
competition=competition_a,
|
||||||
|
games_played=12,
|
||||||
|
minutes_played=300,
|
||||||
|
)
|
||||||
|
PlayerSeasonStats.objects.create(player_season=ps_a, points=11, rebounds=4, assists=3, steals=1, blocks=0.2, turnovers=1.5)
|
||||||
|
|
||||||
|
ps_b = PlayerSeason.objects.create(
|
||||||
|
player=player,
|
||||||
|
season=season,
|
||||||
|
team=team,
|
||||||
|
competition=competition_b,
|
||||||
|
games_played=6,
|
||||||
|
minutes_played=210,
|
||||||
|
)
|
||||||
|
PlayerSeasonStats.objects.create(player_season=ps_b, points=24, rebounds=6, assists=5, steals=1.5, blocks=0.3, turnovers=2.2)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
reverse("players:index"),
|
||||||
|
data={"team": team.id, "season": season.id, "competition": competition_a.id},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
row = list(response.context["players"])[0]
|
||||||
|
assert float(row.ppg_value) == pytest.approx(11.0)
|
||||||
|
assert float(row.mpg_value) == pytest.approx(25.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_displayed_metrics_without_season_filter_use_best_eligible_values(client):
|
||||||
|
nationality = Nationality.objects.create(name="Slovenia", iso2_code="SI", iso3_code="SVN")
|
||||||
|
position = Position.objects.create(code="PG", name="Point Guard")
|
||||||
|
role = Role.objects.create(code="playmaker", name="Playmaker")
|
||||||
|
competition = Competition.objects.create(
|
||||||
|
name="SBL",
|
||||||
|
slug="sbl",
|
||||||
|
competition_type=Competition.CompetitionType.LEAGUE,
|
||||||
|
gender=Competition.Gender.MEN,
|
||||||
|
country=nationality,
|
||||||
|
)
|
||||||
|
season_a = Season.objects.create(label="2023-2024", start_date=date(2023, 9, 1), end_date=date(2024, 6, 30))
|
||||||
|
season_b = Season.objects.create(label="2024-2025", start_date=date(2024, 9, 1), end_date=date(2025, 6, 30))
|
||||||
|
team = Team.objects.create(name="Ljubljana", slug="ljubljana", country=nationality)
|
||||||
|
|
||||||
|
player = Player.objects.create(
|
||||||
|
first_name="Luka",
|
||||||
|
last_name="Semantics",
|
||||||
|
full_name="Luka Semantics",
|
||||||
|
birth_date=date(2001, 3, 3),
|
||||||
|
nationality=nationality,
|
||||||
|
nominal_position=position,
|
||||||
|
inferred_role=role,
|
||||||
|
)
|
||||||
|
|
||||||
|
# High games, lower per-game production.
|
||||||
|
ps_a = PlayerSeason.objects.create(
|
||||||
|
player=player,
|
||||||
|
season=season_a,
|
||||||
|
team=team,
|
||||||
|
competition=competition,
|
||||||
|
games_played=34,
|
||||||
|
minutes_played=680, # 20 MPG
|
||||||
|
)
|
||||||
|
PlayerSeasonStats.objects.create(
|
||||||
|
player_season=ps_a,
|
||||||
|
points=10.0,
|
||||||
|
rebounds=3.0,
|
||||||
|
assists=4.0,
|
||||||
|
steals=1.0,
|
||||||
|
blocks=0.1,
|
||||||
|
turnovers=2.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Lower games, higher per-game production.
|
||||||
|
ps_b = PlayerSeason.objects.create(
|
||||||
|
player=player,
|
||||||
|
season=season_b,
|
||||||
|
team=team,
|
||||||
|
competition=competition,
|
||||||
|
games_played=18,
|
||||||
|
minutes_played=630, # 35 MPG
|
||||||
|
)
|
||||||
|
PlayerSeasonStats.objects.create(
|
||||||
|
player_season=ps_b,
|
||||||
|
points=25.0,
|
||||||
|
rebounds=6.0,
|
||||||
|
assists=8.0,
|
||||||
|
steals=1.5,
|
||||||
|
blocks=0.2,
|
||||||
|
turnovers=3.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(reverse("players:index"))
|
||||||
|
assert response.status_code == 200
|
||||||
|
row = next(item for item in response.context["players"] if item.id == player.id)
|
||||||
|
assert float(row.games_played_value) == pytest.approx(34.0)
|
||||||
|
assert float(row.mpg_value) == pytest.approx(35.0)
|
||||||
|
assert float(row.ppg_value) == pytest.approx(25.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_ppg_sort_uses_best_eligible_metrics_without_season_filter(client):
|
||||||
|
nationality = Nationality.objects.create(name="Latvia", iso2_code="LV", iso3_code="LVA")
|
||||||
|
position = Position.objects.create(code="SG", name="Shooting Guard")
|
||||||
|
role = Role.objects.create(code="scorer", name="Scorer")
|
||||||
|
competition = Competition.objects.create(
|
||||||
|
name="LBL",
|
||||||
|
slug="lbl",
|
||||||
|
competition_type=Competition.CompetitionType.LEAGUE,
|
||||||
|
gender=Competition.Gender.MEN,
|
||||||
|
country=nationality,
|
||||||
|
)
|
||||||
|
season_old = Season.objects.create(label="2022-2023", start_date=date(2022, 9, 1), end_date=date(2023, 6, 30))
|
||||||
|
season_new = Season.objects.create(label="2023-2024", start_date=date(2023, 9, 1), end_date=date(2024, 6, 30))
|
||||||
|
team = Team.objects.create(name="Riga", slug="riga", country=nationality)
|
||||||
|
|
||||||
|
player_a = Player.objects.create(
|
||||||
|
first_name="A",
|
||||||
|
last_name="Sorter",
|
||||||
|
full_name="A Sorter",
|
||||||
|
birth_date=date(2000, 1, 1),
|
||||||
|
nationality=nationality,
|
||||||
|
nominal_position=position,
|
||||||
|
inferred_role=role,
|
||||||
|
)
|
||||||
|
ps_a = PlayerSeason.objects.create(
|
||||||
|
player=player_a,
|
||||||
|
season=season_old,
|
||||||
|
team=team,
|
||||||
|
competition=competition,
|
||||||
|
games_played=20,
|
||||||
|
minutes_played=400,
|
||||||
|
)
|
||||||
|
PlayerSeasonStats.objects.create(player_season=ps_a, points=14, rebounds=4, assists=3, steals=1, blocks=0.1, turnovers=2)
|
||||||
|
|
||||||
|
player_b = Player.objects.create(
|
||||||
|
first_name="B",
|
||||||
|
last_name="Sorter",
|
||||||
|
full_name="B Sorter",
|
||||||
|
birth_date=date(2000, 1, 1),
|
||||||
|
nationality=nationality,
|
||||||
|
nominal_position=position,
|
||||||
|
inferred_role=role,
|
||||||
|
)
|
||||||
|
# Lower old season.
|
||||||
|
ps_b_old = PlayerSeason.objects.create(
|
||||||
|
player=player_b,
|
||||||
|
season=season_old,
|
||||||
|
team=team,
|
||||||
|
competition=competition,
|
||||||
|
games_played=20,
|
||||||
|
minutes_played=400,
|
||||||
|
)
|
||||||
|
PlayerSeasonStats.objects.create(player_season=ps_b_old, points=12, rebounds=3, assists=2, steals=1, blocks=0.1, turnovers=2)
|
||||||
|
# Better newer season; should drive best-eligible ppg sort.
|
||||||
|
ps_b_new = PlayerSeason.objects.create(
|
||||||
|
player=player_b,
|
||||||
|
season=season_new,
|
||||||
|
team=team,
|
||||||
|
competition=competition,
|
||||||
|
games_played=20,
|
||||||
|
minutes_played=420,
|
||||||
|
)
|
||||||
|
PlayerSeasonStats.objects.create(player_season=ps_b_new, points=21, rebounds=5, assists=4, steals=1.2, blocks=0.2, turnovers=2.5)
|
||||||
|
|
||||||
|
response = client.get(reverse("players:index"), data={"sort": "ppg_desc"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
ordered = [row.full_name for row in response.context["players"] if row.full_name in {"A Sorter", "B Sorter"}]
|
||||||
|
assert ordered.index("B Sorter") < ordered.index("A Sorter")
|
||||||
|
|||||||
@ -193,3 +193,52 @@ def test_player_search_htmx_invalid_filters_return_validation_feedback(client):
|
|||||||
body = response.content.decode().lower()
|
body = response.content.decode().lower()
|
||||||
assert "search filters are invalid" in body
|
assert "search filters are invalid" in body
|
||||||
assert "points per game min" in body
|
assert "points per game min" in body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_player_search_results_render_best_eligible_metric_labels(client):
|
||||||
|
nationality = Nationality.objects.create(name="Ireland", iso2_code="IE", iso3_code="IRL")
|
||||||
|
position = Position.objects.create(code="PG", name="Point Guard")
|
||||||
|
role = Role.objects.create(code="playmaker", name="Playmaker")
|
||||||
|
season = Season.objects.create(label="2025-2026", start_date=date(2025, 9, 1), end_date=date(2026, 6, 30))
|
||||||
|
competition = Competition.objects.create(
|
||||||
|
name="Super League",
|
||||||
|
slug="super-league",
|
||||||
|
competition_type=Competition.CompetitionType.LEAGUE,
|
||||||
|
gender=Competition.Gender.MEN,
|
||||||
|
country=nationality,
|
||||||
|
)
|
||||||
|
team = Team.objects.create(name="Dublin", slug="dublin", country=nationality)
|
||||||
|
player = Player.objects.create(
|
||||||
|
first_name="Sean",
|
||||||
|
last_name="Label",
|
||||||
|
full_name="Sean Label",
|
||||||
|
birth_date=date(2001, 1, 1),
|
||||||
|
nationality=nationality,
|
||||||
|
nominal_position=position,
|
||||||
|
inferred_role=role,
|
||||||
|
)
|
||||||
|
season_row = PlayerSeason.objects.create(
|
||||||
|
player=player,
|
||||||
|
season=season,
|
||||||
|
team=team,
|
||||||
|
competition=competition,
|
||||||
|
games_played=10,
|
||||||
|
minutes_played=250,
|
||||||
|
)
|
||||||
|
PlayerSeasonStats.objects.create(
|
||||||
|
player_season=season_row,
|
||||||
|
points=12,
|
||||||
|
rebounds=4,
|
||||||
|
assists=5,
|
||||||
|
steals=1,
|
||||||
|
blocks=0.1,
|
||||||
|
turnovers=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(reverse("players:index"))
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.content.decode()
|
||||||
|
assert "Best Eligible PPG" in body
|
||||||
|
assert "Best Eligible MPG" in body
|
||||||
|
assert "best eligible values per player" in body.lower()
|
||||||
|
|||||||
@ -40,7 +40,7 @@ class _FakeSession:
|
|||||||
|
|
||||||
class _FakeBalldontlieClient:
|
class _FakeBalldontlieClient:
|
||||||
def get_json(self, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
def get_json(self, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
if path == "teams":
|
if path == "/nba/v1/teams":
|
||||||
return {
|
return {
|
||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
@ -60,7 +60,7 @@ class _FakeBalldontlieClient:
|
|||||||
per_page: int = 100,
|
per_page: int = 100,
|
||||||
page_limit: int = 1,
|
page_limit: int = 1,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
if path == "players":
|
if path == "/nba/v1/players":
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": 237,
|
"id": 237,
|
||||||
@ -70,10 +70,7 @@ class _FakeBalldontlieClient:
|
|||||||
"team": {"id": 14},
|
"team": {"id": 14},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
if path == "stats":
|
if path == "/nba/v1/stats":
|
||||||
requested_ids = (params or {}).get("game_ids[]") or []
|
|
||||||
if requested_ids and 9902 not in requested_ids:
|
|
||||||
return []
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"pts": 20,
|
"pts": 20,
|
||||||
@ -88,7 +85,7 @@ class _FakeBalldontlieClient:
|
|||||||
"min": "35:12",
|
"min": "35:12",
|
||||||
"player": {"id": 237},
|
"player": {"id": 237},
|
||||||
"team": {"id": 14},
|
"team": {"id": 14},
|
||||||
"game": {"id": 9901, "season": 2024},
|
"game": {"season": 2024},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pts": 30,
|
"pts": 30,
|
||||||
@ -103,14 +100,9 @@ class _FakeBalldontlieClient:
|
|||||||
"min": "33:00",
|
"min": "33:00",
|
||||||
"player": {"id": 237},
|
"player": {"id": 237},
|
||||||
"team": {"id": 14},
|
"team": {"id": 14},
|
||||||
"game": {"id": 9902, "season": 2024},
|
"game": {"season": 2024},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
if path == "games":
|
|
||||||
return [
|
|
||||||
{"id": 9901, "season": 2024},
|
|
||||||
{"id": 9902, "season": 2024},
|
|
||||||
]
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@ -179,7 +171,7 @@ def test_balldontlie_map_seasons_marks_latest_as_current():
|
|||||||
def test_balldontlie_adapter_degrades_when_stats_unauthorized(settings):
|
def test_balldontlie_adapter_degrades_when_stats_unauthorized(settings):
|
||||||
class _UnauthorizedStatsClient(_FakeBalldontlieClient):
|
class _UnauthorizedStatsClient(_FakeBalldontlieClient):
|
||||||
def list_paginated(self, path: str, *, params=None, per_page=100, page_limit=1):
|
def list_paginated(self, path: str, *, params=None, per_page=100, page_limit=1):
|
||||||
if path == "stats":
|
if path == "/nba/v1/stats":
|
||||||
raise ProviderUnauthorizedError(
|
raise ProviderUnauthorizedError(
|
||||||
provider="balldontlie",
|
provider="balldontlie",
|
||||||
path="stats",
|
path="stats",
|
||||||
@ -266,6 +258,6 @@ def test_balldontlie_client_cursor_pagination(settings):
|
|||||||
rows = client.list_paginated("players", per_page=1, page_limit=5)
|
rows = client.list_paginated("players", per_page=1, page_limit=5)
|
||||||
|
|
||||||
assert rows == [{"id": 1}, {"id": 2}]
|
assert rows == [{"id": 1}, {"id": 2}]
|
||||||
assert session.calls[0]["params"]["page"] == 1
|
assert "page" not in session.calls[0]["params"]
|
||||||
assert "cursor" not in session.calls[0]["params"]
|
assert "cursor" not in session.calls[0]["params"]
|
||||||
assert session.calls[1]["params"]["cursor"] == 101
|
assert session.calls[1]["params"]["cursor"] == 101
|
||||||
|
|||||||
Reference in New Issue
Block a user