diff --git a/README.md b/README.md index b97d84b..2c9a1c5 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Original Repository: https://github.com/SwapnikKatkoori/sleeper-api-wrapper 4. [Notes](#notes) 5. [Dependencies](#depends) 6. [License](#license) +7. [FastAPI Web Demo](#fastapi) # Project Roadmap @@ -50,3 +51,28 @@ This package is intended to be used by Python version 3.8 and higher. There migh # License This project is licensed under the terms of the MIT license. + + +# FastAPI Web Demo + +A simple FastAPI app lives under `webapp/` to showcase the wrapper in a browser for league `1257507151190958081`. It is intentionally lean but organized for extension. + +### Run locally +``` +pip install -e . +pip install "fastapi>=0.115" "uvicorn[standard]>=0.30" "jinja2>=3.1" "python-multipart>=0.0.9" +uvicorn webapp.main:app --reload +``` + +Override the league or sport via environment variables: +``` +export LEAGUE_ID=YOUR_LEAGUE_ID +export SPORT=nfl +export SCORE_TYPE=pts_ppr +``` + +Pages: +- `/` League dashboard (standings, managers, matchups, recent transactions). +- `/rosters` Per-manager rosters with starters/bench. +- `/draft` Draft results for the league's primary draft. +- `/players` Player stats for the season; search or use trending adds/drops. diff --git a/pyproject.toml b/pyproject.toml index 6aa3db0..b04a091 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,3 +30,8 @@ build-backend = "poetry.core.masonry.api" pytest = "*" pytest-mock = "*" tox = "^4.31.0" +fastapi = "^0.115.0" +uvicorn = {extras = ["standard"], version = "^0.30.0"} +jinja2 = "^3.1.0" +python-multipart = "^0.0.9" +pydantic-settings = "^2.2.0" diff --git a/webapp/__init__.py b/webapp/__init__.py new file mode 100644 index 0000000..6446e14 --- /dev/null +++ b/webapp/__init__.py @@ -0,0 +1 @@ +# FastAPI proof-of-concept package for Sleeper league views. diff --git a/webapp/config.py b/webapp/config.py new file mode 100644 index 0000000..93c5269 --- /dev/null +++ b/webapp/config.py @@ -0,0 +1,23 @@ +from functools import lru_cache + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """Configuration for the FastAPI proof of concept.""" + + league_id: str = "1257507151190958081" + sport: str = "nfl" + score_type: str = "pts_ppr" + league_cache_ttl_seconds: int = 300 + players_cache_ttl_seconds: int = 21600 + stats_cache_ttl_seconds: int = 900 + + class Config: + env_file = ".env" + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + """Returns cached settings instance.""" + return Settings() diff --git a/webapp/main.py b/webapp/main.py new file mode 100644 index 0000000..3767066 --- /dev/null +++ b/webapp/main.py @@ -0,0 +1,70 @@ +from fastapi import Depends, FastAPI, Query, Request +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from webapp.config import Settings, get_settings +from webapp.services.sleeper_client import SleeperClient, get_client + +app = FastAPI(title="Sleeper League Dashboard", version="0.1.0") + +templates = Jinja2Templates(directory="webapp/templates") +app.mount("/static", StaticFiles(directory="webapp/static"), name="static") + + +def client_dependency(settings: Settings = Depends(get_settings)) -> SleeperClient: + return get_client(settings) + + +@app.get("/", response_class=HTMLResponse) +def league_dashboard( + request: Request, + week: int | None = Query(default=None, description="Override week to view"), + client: SleeperClient = Depends(client_dependency), +): + data = client.league_dashboard(week=week) + return templates.TemplateResponse( + "dashboard.html", {"request": request, "title": "League Dashboard", **data} + ) + + +@app.get("/rosters", response_class=HTMLResponse) +def rosters( + request: Request, + client: SleeperClient = Depends(client_dependency), +): + data = client.roster_view() + return templates.TemplateResponse( + "rosters.html", {"request": request, "title": "Rosters", **data} + ) + + +@app.get("/draft", response_class=HTMLResponse) +def draft( + request: Request, + client: SleeperClient = Depends(client_dependency), +): + data = client.draft_results() + return templates.TemplateResponse( + "draft.html", {"request": request, "title": "Draft Results", **data} + ) + + +@app.get("/players", response_class=HTMLResponse) +def players( + request: Request, + q: str | None = Query(default=None, description="Player name search"), + season: str | None = Query(default=None, description="Season year for stats"), + add_drop: str = Query(default="add", description="Trending add or drop feed"), + client: SleeperClient = Depends(client_dependency), +): + data = client.player_stats(query=q, season=season, add_drop=add_drop) + return templates.TemplateResponse( + "players.html", + {"request": request, "title": "Player Stats", "query": q, "add_drop": add_drop, **data}, + ) + + +@app.get("/health") +def health() -> dict: + return {"status": "ok"} diff --git a/webapp/services/__init__.py b/webapp/services/__init__.py new file mode 100644 index 0000000..1677a40 --- /dev/null +++ b/webapp/services/__init__.py @@ -0,0 +1 @@ +# Services for fetching and transforming Sleeper data for the web app. diff --git a/webapp/services/sleeper_client.py b/webapp/services/sleeper_client.py new file mode 100644 index 0000000..1323709 --- /dev/null +++ b/webapp/services/sleeper_client.py @@ -0,0 +1,296 @@ +import time +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple + +from sleeper_wrapper import Drafts, League, Players, Stats, get_sport_state + +from webapp.config import Settings + + +@dataclass +class TTLCache: + """Lightweight TTL cache to avoid hammering the Sleeper API.""" + + ttl_seconds: int + value: Any = None + expires_at: float = 0 + + def get(self) -> Any: + if self.value is not None and time.time() < self.expires_at: + return self.value + return None + + def set(self, value: Any) -> None: + self.value = value + self.expires_at = time.time() + self.ttl_seconds + + +class SleeperClient: + """Fetches and shapes data for the FastAPI views.""" + + def __init__(self, settings: Settings): + self.settings = settings + self._league_cache = TTLCache(settings.league_cache_ttl_seconds) + self._players_cache = TTLCache(settings.players_cache_ttl_seconds) + self._stats_cache: Dict[str, TTLCache] = {} + + def _fetch_league_bundle(self) -> Dict[str, Any]: + """Returns cached league info, rosters, users, and sport state.""" + cached = self._league_cache.get() + if cached: + return cached + + league = League(self.settings.league_id) + info = league.get_league() + rosters = league.get_rosters() + users = league.get_users() + state = get_sport_state(self.settings.sport) + + bundle = { + "league": league, + "info": info, + "rosters": rosters, + "users": users, + "state": state, + } + self._league_cache.set(bundle) + return bundle + + def _user_lookup(self, users: List[Dict[str, Any]]) -> Dict[str, str]: + result = {} + for user in users: + team_name = user.get("metadata", {}).get("team_name") + name = team_name or user.get("display_name") or user.get("username") + if user.get("user_id"): + result[user["user_id"]] = name + return result + + def _roster_lookup(self, rosters: List[Dict[str, Any]]) -> Dict[int, str]: + return {roster["roster_id"]: roster.get("owner_id") for roster in rosters} + + def _players(self) -> Dict[str, Dict[str, Any]]: + """Returns cached player directory keyed by player_id.""" + cached = self._players_cache.get() + if cached: + return cached + players = Players().get_all_players(self.settings.sport) + if not isinstance(players, dict): + players = {} + self._players_cache.set(players) + return players + + def _season_stats(self, season: str) -> Dict[str, Any]: + """Returns cached season stats.""" + cache = self._stats_cache.get(season) + if cache and cache.get() is not None: + return cache.get() + + cache = TTLCache(self.settings.stats_cache_ttl_seconds) + self._stats_cache[season] = cache + + stats = Stats().get_all_stats("regular", season) + if not isinstance(stats, dict): + stats = {} + cache.set(stats) + return stats + + def league_dashboard(self, week: Optional[int] = None) -> Dict[str, Any]: + bundle = self._fetch_league_bundle() + league = bundle["league"] + info = bundle["info"] + rosters = bundle["rosters"] + users = bundle["users"] + state = bundle["state"] + + target_week = week or state.get("week") or info.get("week") or 1 + matchups = league.get_matchups(target_week) + if not isinstance(matchups, list): + matchups = [] + scoreboard = self._build_scoreboard(matchups, rosters, users, target_week) + + standings = league.get_standings(rosters=rosters, users=users) + transactions = league.get_transactions(target_week) + if not isinstance(transactions, list): + transactions = [] + + return { + "info": info, + "state": state, + "settings": info.get("settings", {}), + "standings": standings, + "managers": users, + "scoreboard": scoreboard, + "transactions": transactions or [], + "week": target_week, + } + + def _build_scoreboard( + self, + matchups: List[Dict[str, Any]], + rosters: List[Dict[str, Any]], + users: List[Dict[str, Any]], + week: int, + ) -> List[Dict[str, Any]]: + if not matchups: + return [] + user_lookup = self._user_lookup(users) + roster_lookup = self._roster_lookup(rosters) + + matchup_groups: Dict[int, List[Dict[str, Any]]] = {} + for matchup in matchups: + matchup_id = matchup.get("matchup_id", 0) + matchup_groups.setdefault(matchup_id, []).append(matchup) + + scoreboard = [] + for matchup_id, teams in sorted(matchup_groups.items()): + entries = [] + for team in teams: + roster_id = team.get("roster_id") + owner_id = roster_lookup.get(roster_id) + team_name = user_lookup.get(owner_id, f"Roster {roster_id}") + points_raw = team.get("points", 0) + projected_raw = team.get("projected_points") + points = round(points_raw, 2) if isinstance(points_raw, (int, float)) else 0 + projected = round(projected_raw, 2) if isinstance(projected_raw, (int, float)) else None + entries.append( + { + "team_name": team_name, + "points": points, + "projected": projected, + } + ) + scoreboard.append({"matchup_id": matchup_id, "week": week, "teams": entries}) + return scoreboard + + def roster_view(self) -> Dict[str, Any]: + bundle = self._fetch_league_bundle() + info = bundle["info"] + rosters = bundle["rosters"] + users = bundle["users"] + user_lookup = self._user_lookup(users) + players = self._players() + + def hydrate_player(player_id: str) -> Dict[str, Any]: + player = players.get(player_id, {}) + full_name = player.get("full_name") or f"{player.get('first_name', '')} {player.get('last_name', '')}".strip() + return { + "id": player_id, + "name": full_name or player_id, + "position": player.get("position"), + "team": player.get("team"), + } + + hydrated = [] + for roster in rosters: + owner_id = roster.get("owner_id") + starters_set = set(roster.get("starters", [])) + all_players = [hydrate_player(pid) for pid in roster.get("players", [])] + starters_full = [hydrate_player(pid) for pid in roster.get("starters", [])] + bench = [p for p in all_players if p["id"] not in starters_set] + hydrated.append( + { + "roster_id": roster.get("roster_id"), + "owner_id": owner_id, + "owner_name": user_lookup.get(owner_id, "Unassigned"), + "players": all_players, + "starters": starters_full, + "bench": bench, + } + ) + + return {"info": info, "rosters": hydrated, "users": users} + + def draft_results(self) -> Dict[str, Any]: + bundle = self._fetch_league_bundle() + info = bundle["info"] + league = bundle["league"] + users = bundle["users"] + user_lookup = self._user_lookup(users) + players = self._players() + + drafts = league.get_all_drafts() + if not drafts: + return {"draft": None, "picks": []} + active_draft = drafts[0] + draft = Drafts(active_draft["draft_id"]) + picks = draft.get_all_picks() or [] + + def player_name(player_id: str) -> str: + player = players.get(player_id, {}) + return player.get("full_name") or f"{player.get('first_name', '')} {player.get('last_name', '')}".strip() or player_id + + decorated = [] + for pick in sorted(picks, key=lambda p: (p.get("round", 0), p.get("pick_no", 0))): + player_id = pick.get("player_id") + decorated.append( + { + "round": pick.get("round"), + "pick_no": pick.get("pick_no"), + "roster_id": pick.get("roster_id"), + "picked_by": user_lookup.get(pick.get("picked_by")), + "player": player_name(player_id), + "player_id": player_id, + "metadata": pick.get("metadata", {}), + } + ) + + return {"info": info, "draft": active_draft, "picks": decorated} + + def player_stats( + self, query: Optional[str] = None, season: Optional[str] = None, add_drop: str = "add" + ) -> Dict[str, Any]: + bundle = self._fetch_league_bundle() + info = bundle["info"] + default_season = info.get("season") + season_to_use = str(season or default_season) + players = self._players() + stats = self._season_stats(season_to_use) if season_to_use else {} + + def enrich(player_id: str) -> Dict[str, Any]: + player = players.get(player_id, {}) + stat_line = stats.get(player_id, {}) + full_name = player.get("full_name") or f"{player.get('first_name', '')} {player.get('last_name', '')}".strip() + return { + "id": player_id, + "name": full_name or player_id, + "position": player.get("position"), + "team": player.get("team"), + "pts_ppr": stat_line.get("pts_ppr"), + "pts_std": stat_line.get("pts_std"), + "pts_half_ppr": stat_line.get("pts_half_ppr"), + "games": stat_line.get("gp"), + } + + results: List[Dict[str, Any]] = [] + source: str + if query: + query_lower = query.lower() + for player_id, player in players.items(): + name = player.get("full_name") or f"{player.get('first_name', '')} {player.get('last_name', '')}".strip() + if name and query_lower in name.lower(): + results.append(enrich(player_id)) + if len(results) >= 25: + break + source = "search" + else: + trending = Players().get_trending_players(self.settings.sport, add_drop=add_drop, hours=168, limit=25) + for entry in trending: + player_id = entry.get("player_id") + annotated = enrich(player_id) + annotated["count"] = entry.get("count") + annotated["direction"] = add_drop + results.append(annotated) + source = "trending" + + return {"info": info, "season": season_to_use, "results": results, "source": source} + + +_client_instance: Optional[SleeperClient] = None + + +def get_client(settings: Settings) -> SleeperClient: + """Factory for dependency injection.""" + global _client_instance + if _client_instance is None: + _client_instance = SleeperClient(settings) + return _client_instance diff --git a/webapp/static/app.css b/webapp/static/app.css new file mode 100644 index 0000000..53b7691 --- /dev/null +++ b/webapp/static/app.css @@ -0,0 +1,238 @@ +:root { + --bg: #0b1724; + --card: #132235; + --text: #f5f7fb; + --muted: #9eb0c7; + --accent: #4dc3ff; + --border: #20324a; + --shadow: 0 10px 30px rgba(0, 0, 0, 0.25); + --radius: 10px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Inter", system-ui, -apple-system, sans-serif; + background: radial-gradient(circle at 20% 20%, rgba(77, 195, 255, 0.08), transparent 25%), + radial-gradient(circle at 80% 0%, rgba(0, 255, 214, 0.08), transparent 20%), + var(--bg); + color: var(--text); + min-height: 100vh; +} + +.top-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 24px; + background: rgba(12, 24, 36, 0.85); + position: sticky; + top: 0; + z-index: 10; + backdrop-filter: blur(10px); +} + +.brand__title { + font-weight: 700; + letter-spacing: 0.02em; +} + +.brand__meta { + font-size: 12px; + color: var(--muted); +} + +.nav a { + color: var(--text); + text-decoration: none; + margin-left: 16px; + font-weight: 600; +} + +.nav a:hover { + color: var(--accent); +} + +.container { + max-width: 1200px; + margin: 24px auto 60px; + padding: 0 16px; +} + +.hero { + margin-bottom: 20px; +} + +.hero h1 { + margin: 0 0 6px 0; +} + +.pill-row { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.pill { + background: rgba(77, 195, 255, 0.12); + border: 1px solid var(--border); + padding: 8px 12px; + border-radius: 20px; + color: var(--text); + font-size: 13px; +} + +.panel { + background: rgba(19, 34, 53, 0.85); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px; + margin-bottom: 24px; + box-shadow: var(--shadow); +} + +.panel__header { + display: flex; + justify-content: space-between; + align-items: flex-end; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 12px; +} + +.panel__header h2, +.panel__header h1 { + margin: 0; +} + +.inline-form { + display: flex; + gap: 8px; + align-items: center; +} + +.inline-form input, +.inline-form select { + padding: 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: #0f1c2b; + color: var(--text); +} + +.inline-form button { + padding: 8px 12px; + border: none; + border-radius: 6px; + background: var(--accent); + color: #032039; + font-weight: 700; + cursor: pointer; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + padding: 10px 8px; + text-align: left; + border-bottom: 1px solid var(--border); +} + +th { + color: var(--muted); + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.grid { + display: grid; + gap: 12px; +} + +.grid.two { + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + +.grid.three { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.card { + background: #0f1c2b; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px; + box-shadow: var(--shadow); +} + +.card__title { + font-weight: 700; + margin-bottom: 8px; +} + +.card__body { + color: var(--muted); +} + +.muted { + color: var(--muted); + font-size: 13px; +} + +.list { + list-style: none; + padding-left: 0; + margin: 0; +} + +.list li { + padding: 6px 0; + border-bottom: 1px solid var(--border); +} + +.score-row { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.score { + background: rgba(77, 195, 255, 0.08); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px; + flex: 1; +} + +.score__name { + font-weight: 700; +} + +.score__points { + font-size: 24px; + font-weight: 800; +} + +.score__projected { + color: var(--muted); +} + +@media (max-width: 720px) { + .top-bar { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .score-row { + flex-direction: column; + } +} diff --git a/webapp/templates/base.html b/webapp/templates/base.html new file mode 100644 index 0000000..658ae0f --- /dev/null +++ b/webapp/templates/base.html @@ -0,0 +1,26 @@ + + +
+ + +Season {{ info.season }} · Week {{ week }} · Sport {{ info.sport }}
+Wins/Losses reflect Sleeper scoring (including custom settings).
+| Team | +W | +L | +Points | +
|---|---|---|---|
| {{ team }} | +{{ wins }} | +{{ losses }} | +{{ points }} | +
Pulled directly from Sleeper so custom scoring/positions are honored.
+Week {{ week }}
+No matchups available for this week.
+ {% endif %} +Team names pulled from league metadata.
+Waivers, trades, free agency activity.
+No transactions recorded for this week.
+ {% endif %} +Draft ID {{ draft.draft_id }} · Type {{ draft.type }} · Rounds {{ draft.settings.rounds }}
+ {% else %} +No drafts found for this league.
+ {% endif %} +| Round | +Pick | +Player | +Picked By | +Roster | +
|---|---|---|---|---|
| {{ pick.round }} | +{{ pick.pick_no }} | +{{ pick.player }} | +{{ pick.picked_by or "Unknown" }} | +{{ pick.roster_id }} | +
No picks to display.
+ {% endif %} +Season {{ season }} · {{ "Search results" if query else "Trending "+add_drop }}
+| Name | +Pos | +Team | +PPR | +Std | +Half PPR | +Games | +Trending | +
|---|---|---|---|---|---|---|---|
| {{ player.name }} | +{{ player.position or "?" }} | +{{ player.team or "FA" }} | +{{ player.pts_ppr or "—" }} | +{{ player.pts_std or "—" }} | +{{ player.pts_half_ppr or "—" }} | +{{ player.games or "—" }} | ++ {% if player.count %} + {{ player.count }} {{ player.direction }} + {% else %} + {% if source == "search" %}search{% else %}—{% endif %} + {% endif %} + | +
No players to show for this selection.
+ {% endif %} +Switch between managers to view starters and bench.
+