Add two endpoints for reading EvolutionCardState:
GET /api/v2/teams/{team_id}/evolutions
- Optional filters: card_type, tier
- Pagination: page / per_page (default 10, max 100)
- Joins EvolutionTrack so card_type filter is a single query
- Returns {count, items} with full card state + threshold context
GET /api/v2/evolution/cards/{card_id}
- Resolves card_id -> (player_id, team_id) via Card table
- Duplicate cards for same player+team share one state row
- Returns 404 when card missing or has no evolution state
Both endpoints:
- Require bearer token auth (valid_token dependency)
- Embed the EvolutionTrack in each item (not just the FK id)
- Compute next_threshold: threshold for tier above current (null at T4)
- Share _build_card_state_response() helper in evolution.py
Also cleans up 30 pre-existing ruff violations in teams.py that were
blocking the pre-commit hook: F541 bare f-strings, E712 boolean
comparisons (now noqa where Peewee ORM requires == False/True),
and F841 unused variable assignments.
Tests: tests/test_evolution_state_api.py — 10 integration tests that
skip automatically without POSTGRES_HOST, following the same pattern as
test_evolution_track_api.py.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
161 lines
5.5 KiB
Python
161 lines
5.5 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from ..db_engine import model_to_dict
|
|
from ..dependencies import oauth2_scheme, valid_token
|
|
|
|
router = APIRouter(prefix="/api/v2/evolution", tags=["evolution"])
|
|
|
|
# Tier -> threshold attribute name. Index = current_tier; value is the
|
|
# attribute on EvolutionTrack whose value is the *next* threshold to reach.
|
|
# Tier 4 is fully evolved so there is no next threshold (None sentinel).
|
|
_NEXT_THRESHOLD_ATTR = {
|
|
0: "t1_threshold",
|
|
1: "t2_threshold",
|
|
2: "t3_threshold",
|
|
3: "t4_threshold",
|
|
4: None,
|
|
}
|
|
|
|
|
|
def _build_card_state_response(state) -> dict:
|
|
"""Serialise an EvolutionCardState into the standard API response shape.
|
|
|
|
Produces a flat dict with player_id and team_id as plain integers,
|
|
a nested 'track' dict with all threshold fields, and a computed
|
|
'next_threshold' field:
|
|
- For tiers 0-3: the threshold value for the tier immediately above.
|
|
- For tier 4 (fully evolved): None.
|
|
|
|
Uses model_to_dict(recurse=False) internally so FK fields are returned
|
|
as IDs rather than nested objects, then promotes the needed IDs up to
|
|
the top level.
|
|
"""
|
|
track = state.track
|
|
track_dict = model_to_dict(track, recurse=False)
|
|
|
|
next_attr = _NEXT_THRESHOLD_ATTR.get(state.current_tier)
|
|
next_threshold = getattr(track, next_attr) if next_attr else None
|
|
|
|
return {
|
|
"player_id": state.player_id,
|
|
"team_id": state.team_id,
|
|
"current_tier": state.current_tier,
|
|
"current_value": state.current_value,
|
|
"fully_evolved": state.fully_evolved,
|
|
"last_evaluated_at": (
|
|
state.last_evaluated_at.isoformat() if state.last_evaluated_at else None
|
|
),
|
|
"track": track_dict,
|
|
"next_threshold": next_threshold,
|
|
}
|
|
|
|
|
|
@router.get("/tracks")
|
|
async def list_tracks(
|
|
card_type: Optional[str] = Query(default=None),
|
|
token: str = Depends(oauth2_scheme),
|
|
):
|
|
if not valid_token(token):
|
|
logging.warning("Bad Token: [REDACTED]")
|
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
|
|
from ..db_engine import EvolutionTrack
|
|
|
|
query = EvolutionTrack.select()
|
|
if card_type is not None:
|
|
query = query.where(EvolutionTrack.card_type == card_type)
|
|
|
|
items = [model_to_dict(t, recurse=False) for t in query]
|
|
return {"count": len(items), "items": items}
|
|
|
|
|
|
@router.get("/tracks/{track_id}")
|
|
async def get_track(track_id: int, token: str = Depends(oauth2_scheme)):
|
|
if not valid_token(token):
|
|
logging.warning("Bad Token: [REDACTED]")
|
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
|
|
from ..db_engine import EvolutionTrack
|
|
|
|
try:
|
|
track = EvolutionTrack.get_by_id(track_id)
|
|
except Exception:
|
|
raise HTTPException(status_code=404, detail=f"Track {track_id} not found")
|
|
|
|
return model_to_dict(track, recurse=False)
|
|
|
|
|
|
@router.get("/cards/{card_id}")
|
|
async def get_card_state(card_id: int, token: str = Depends(oauth2_scheme)):
|
|
"""Return the EvolutionCardState for a card identified by its Card.id.
|
|
|
|
Resolves card_id -> (player_id, team_id) via the Card table, then looks
|
|
up the matching EvolutionCardState row. Because duplicate cards for the
|
|
same player+team share one state row (unique-(player,team) constraint),
|
|
any card_id belonging to that player on that team returns the same state.
|
|
|
|
Returns 404 when:
|
|
- The card_id does not exist in the Card table.
|
|
- The card exists but has no corresponding EvolutionCardState yet.
|
|
"""
|
|
if not valid_token(token):
|
|
logging.warning("Bad Token: [REDACTED]")
|
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
|
|
from ..db_engine import Card, EvolutionCardState, EvolutionTrack, DoesNotExist
|
|
|
|
# Resolve card_id to player+team
|
|
try:
|
|
card = Card.get_by_id(card_id)
|
|
except DoesNotExist:
|
|
raise HTTPException(status_code=404, detail=f"Card {card_id} not found")
|
|
|
|
# Look up the evolution state for this (player, team) pair, joining the
|
|
# track so a single query resolves both rows.
|
|
try:
|
|
state = (
|
|
EvolutionCardState.select(EvolutionCardState, EvolutionTrack)
|
|
.join(EvolutionTrack)
|
|
.where(
|
|
(EvolutionCardState.player == card.player_id)
|
|
& (EvolutionCardState.team == card.team_id)
|
|
)
|
|
.get()
|
|
)
|
|
except DoesNotExist:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"No evolution state for card {card_id}",
|
|
)
|
|
|
|
return _build_card_state_response(state)
|
|
|
|
|
|
@router.post("/cards/{card_id}/evaluate")
|
|
async def evaluate_card(card_id: int, token: str = Depends(oauth2_scheme)):
|
|
"""Force-recalculate evolution state for a card from career stats.
|
|
|
|
Resolves card_id to (player_id, team_id), then recomputes the evolution
|
|
tier from all player_season_stats rows for that pair. Idempotent.
|
|
"""
|
|
if not valid_token(token):
|
|
logging.warning("Bad Token: [REDACTED]")
|
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
|
|
from ..db_engine import Card
|
|
from ..services.evolution_evaluator import evaluate_card as _evaluate
|
|
|
|
try:
|
|
card = Card.get_by_id(card_id)
|
|
except Exception:
|
|
raise HTTPException(status_code=404, detail=f"Card {card_id} not found")
|
|
|
|
try:
|
|
result = _evaluate(card.player_id, card.team_id)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc))
|
|
|
|
return result
|