phase6: add provider abstraction, mvp adapter, and ingestion sync tasks

This commit is contained in:
Alfredo Di Stasio
2026-03-10 11:05:57 +01:00
parent f207ffbad8
commit ecd665e872
12 changed files with 1006 additions and 1 deletions

View File

@ -0,0 +1,99 @@
import json
import logging
import os
import time
from pathlib import Path
from django.conf import settings
from apps.providers.exceptions import ProviderRateLimitError, ProviderTransientError
from apps.providers.interfaces import BaseProviderAdapter
logger = logging.getLogger(__name__)
class MvpDemoProviderAdapter(BaseProviderAdapter):
"""MVP provider backed by a local JSON payload for deterministic development syncs."""
namespace = "mvp_demo"
def __init__(self):
default_path = Path(settings.BASE_DIR) / "apps" / "providers" / "data" / "mvp_provider.json"
self.data_file = Path(os.getenv("PROVIDER_MVP_DATA_FILE", str(default_path)))
self.max_retries = int(os.getenv("PROVIDER_REQUEST_RETRIES", "3"))
self.retry_sleep_seconds = float(os.getenv("PROVIDER_REQUEST_RETRY_SLEEP", "1"))
def _load_payload(self) -> dict:
for attempt in range(1, self.max_retries + 1):
try:
if os.getenv("PROVIDER_MVP_FORCE_RATE_LIMIT", "0") == "1":
raise ProviderRateLimitError("Simulated provider rate limit", retry_after_seconds=15)
with self.data_file.open("r", encoding="utf-8") as handle:
return json.load(handle)
except ProviderRateLimitError:
raise
except FileNotFoundError as exc:
logger.exception("Provider data file not found: %s", self.data_file)
raise ProviderTransientError(str(exc)) from exc
except json.JSONDecodeError as exc:
logger.exception("Invalid provider payload JSON in %s", self.data_file)
raise ProviderTransientError(str(exc)) from exc
except OSError as exc:
if attempt >= self.max_retries:
logger.exception("Provider payload read failed after retries")
raise ProviderTransientError(str(exc)) from exc
time.sleep(self.retry_sleep_seconds * attempt)
raise ProviderTransientError("Unable to read provider payload")
def _payload_list(self, key: str) -> list[dict]:
payload = self._load_payload()
value = payload.get(key, [])
return value if isinstance(value, list) else []
def search_players(self, *, query: str = "", limit: int = 50, offset: int = 0) -> list[dict]:
players = self.fetch_players()
if query:
query_lower = query.lower()
players = [p for p in players if query_lower in p.get("full_name", "").lower()]
return players[offset : offset + limit]
def fetch_player(self, *, external_player_id: str) -> dict | None:
for payload in self.fetch_players():
if payload.get("external_id") == external_player_id:
return payload
return None
def fetch_players(self) -> list[dict]:
return self._payload_list("players")
def fetch_competitions(self) -> list[dict]:
return self._payload_list("competitions")
def fetch_teams(self) -> list[dict]:
return self._payload_list("teams")
def fetch_seasons(self) -> list[dict]:
return self._payload_list("seasons")
def fetch_player_stats(self) -> list[dict]:
return self._payload_list("player_stats")
def fetch_player_careers(self) -> list[dict]:
return self._payload_list("player_careers")
def sync_all(self) -> dict:
return {
"players": self.fetch_players(),
"competitions": self.fetch_competitions(),
"teams": self.fetch_teams(),
"seasons": self.fetch_seasons(),
"player_stats": self.fetch_player_stats(),
"player_careers": self.fetch_player_careers(),
"cursor": None,
}
def sync_incremental(self, *, cursor: str | None = None) -> dict:
payload = self.sync_all()
# MVP source has no change feed yet; returns full snapshot.
payload["cursor"] = cursor
return payload

View File

@ -0,0 +1,152 @@
{
"players": [
{
"external_id": "player-001",
"first_name": "Luca",
"last_name": "Rinaldi",
"full_name": "Luca Rinaldi",
"birth_date": "2002-04-11",
"nationality": {"name": "Italy", "iso2_code": "IT", "iso3_code": "ITA"},
"nominal_position": {"code": "PG", "name": "Point Guard"},
"inferred_role": {"code": "playmaker", "name": "Playmaker"},
"height_cm": 191,
"weight_kg": 83,
"dominant_hand": "right",
"is_active": true,
"aliases": ["L. Rinaldi"]
},
{
"external_id": "player-002",
"first_name": "Mateo",
"last_name": "Silva",
"full_name": "Mateo Silva",
"birth_date": "2000-09-23",
"nationality": {"name": "Spain", "iso2_code": "ES", "iso3_code": "ESP"},
"nominal_position": {"code": "SF", "name": "Small Forward"},
"inferred_role": {"code": "wing_scorer", "name": "Wing Scorer"},
"height_cm": 201,
"weight_kg": 94,
"dominant_hand": "left",
"is_active": true,
"aliases": ["M. Silva"]
}
],
"competitions": [
{
"external_id": "comp-001",
"name": "Euro League",
"slug": "euro-league",
"competition_type": "international",
"gender": "men",
"level": 1,
"country": {"name": "Europe", "iso2_code": "EU", "iso3_code": "EUR"},
"is_active": true
}
],
"teams": [
{
"external_id": "team-001",
"name": "Roma Hoops",
"short_name": "ROM",
"slug": "roma-hoops",
"country": {"name": "Italy", "iso2_code": "IT", "iso3_code": "ITA"},
"is_national_team": false
},
{
"external_id": "team-002",
"name": "Madrid Flight",
"short_name": "MAD",
"slug": "madrid-flight",
"country": {"name": "Spain", "iso2_code": "ES", "iso3_code": "ESP"},
"is_national_team": false
}
],
"seasons": [
{
"external_id": "season-2024-2025",
"label": "2024-2025",
"start_date": "2024-09-01",
"end_date": "2025-06-30",
"is_current": false
},
{
"external_id": "season-2025-2026",
"label": "2025-2026",
"start_date": "2025-09-01",
"end_date": "2026-06-30",
"is_current": true
}
],
"player_stats": [
{
"external_id": "stats-001",
"player_external_id": "player-001",
"team_external_id": "team-001",
"competition_external_id": "comp-001",
"season_external_id": "season-2025-2026",
"games_played": 26,
"games_started": 22,
"minutes_played": 780,
"points": 15.6,
"rebounds": 4.1,
"assists": 7.4,
"steals": 1.7,
"blocks": 0.2,
"turnovers": 2.3,
"fg_pct": 46.5,
"three_pct": 38.0,
"ft_pct": 82.3,
"usage_rate": 24.8,
"true_shooting_pct": 57.4,
"player_efficiency_rating": 19.1
},
{
"external_id": "stats-002",
"player_external_id": "player-002",
"team_external_id": "team-002",
"competition_external_id": "comp-001",
"season_external_id": "season-2025-2026",
"games_played": 24,
"games_started": 24,
"minutes_played": 816,
"points": 18.2,
"rebounds": 6.6,
"assists": 2.9,
"steals": 1.1,
"blocks": 0.6,
"turnovers": 2.1,
"fg_pct": 49.2,
"three_pct": 36.1,
"ft_pct": 79.9,
"usage_rate": 27.3,
"true_shooting_pct": 59.0,
"player_efficiency_rating": 20.8
}
],
"player_careers": [
{
"external_id": "career-001",
"player_external_id": "player-001",
"team_external_id": "team-001",
"competition_external_id": "comp-001",
"season_external_id": "season-2025-2026",
"role_code": "playmaker",
"start_date": "2025-09-01",
"end_date": null,
"shirt_number": 5,
"notes": "Primary creator"
},
{
"external_id": "career-002",
"player_external_id": "player-002",
"team_external_id": "team-002",
"competition_external_id": "comp-001",
"season_external_id": "season-2025-2026",
"role_code": "wing_scorer",
"start_date": "2025-09-01",
"end_date": null,
"shirt_number": 11,
"notes": "First scoring option"
}
]
}

View File

@ -0,0 +1,18 @@
class ProviderError(Exception):
"""Base provider exception."""
class ProviderTransientError(ProviderError):
"""Temporary provider failure that can be retried."""
class ProviderRateLimitError(ProviderTransientError):
"""Raised when provider rate limit is hit."""
def __init__(self, message: str, retry_after_seconds: int = 30):
super().__init__(message)
self.retry_after_seconds = retry_after_seconds
class ProviderNotFoundError(ProviderError):
"""Raised when an unknown provider namespace is requested."""

View File

@ -0,0 +1,45 @@
from abc import ABC, abstractmethod
class BaseProviderAdapter(ABC):
namespace: str
@abstractmethod
def search_players(self, *, query: str = "", limit: int = 50, offset: int = 0) -> list[dict]:
raise NotImplementedError
@abstractmethod
def fetch_player(self, *, external_player_id: str) -> dict | None:
raise NotImplementedError
@abstractmethod
def fetch_players(self) -> list[dict]:
raise NotImplementedError
@abstractmethod
def fetch_competitions(self) -> list[dict]:
raise NotImplementedError
@abstractmethod
def fetch_teams(self) -> list[dict]:
raise NotImplementedError
@abstractmethod
def fetch_seasons(self) -> list[dict]:
raise NotImplementedError
@abstractmethod
def fetch_player_stats(self) -> list[dict]:
raise NotImplementedError
@abstractmethod
def fetch_player_careers(self) -> list[dict]:
raise NotImplementedError
@abstractmethod
def sync_all(self) -> dict:
raise NotImplementedError
@abstractmethod
def sync_incremental(self, *, cursor: str | None = None) -> dict:
raise NotImplementedError

View File

@ -0,0 +1,17 @@
from django.conf import settings
from apps.providers.adapters.mvp_provider import MvpDemoProviderAdapter
from apps.providers.exceptions import ProviderNotFoundError
PROVIDER_REGISTRY = {
MvpDemoProviderAdapter.namespace: MvpDemoProviderAdapter,
}
def get_provider(namespace: str | None = None):
provider_namespace = namespace or settings.PROVIDER_DEFAULT_NAMESPACE
provider_cls = PROVIDER_REGISTRY.get(provider_namespace)
if not provider_cls:
raise ProviderNotFoundError(f"Unknown provider namespace: {provider_namespace}")
return provider_cls()