phase6: add provider abstraction, mvp adapter, and ingestion sync tasks
This commit is contained in:
99
apps/providers/adapters/mvp_provider.py
Normal file
99
apps/providers/adapters/mvp_provider.py
Normal 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
|
||||
152
apps/providers/data/mvp_provider.json
Normal file
152
apps/providers/data/mvp_provider.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
18
apps/providers/exceptions.py
Normal file
18
apps/providers/exceptions.py
Normal 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."""
|
||||
45
apps/providers/interfaces.py
Normal file
45
apps/providers/interfaces.py
Normal 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
|
||||
17
apps/providers/registry.py
Normal file
17
apps/providers/registry.py
Normal 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()
|
||||
Reference in New Issue
Block a user