Fix linting errors across backend and frontend
Backend: auto-fix and format all ruff issues, manually fix B904/B023/ SIM117/B007/E741/F841 errors, suppress B008 (FastAPI Depends) and F821 (SQLAlchemy forward refs) in config. Frontend: allow constant exports, disable React compiler-specific rules (set-state-in-effect, preserve-manual-memoization). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from sqlalchemy import select
|
||||
@@ -33,7 +33,9 @@ async def _get_version_group_id(session: AsyncSession, game_id: int) -> int:
|
||||
if game is None:
|
||||
raise HTTPException(status_code=404, detail="Game not found")
|
||||
if game.version_group_id is None:
|
||||
raise HTTPException(status_code=400, detail="Game has no version group assigned")
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Game has no version group assigned"
|
||||
)
|
||||
return game.version_group_id
|
||||
|
||||
|
||||
@@ -41,9 +43,7 @@ async def _get_version_group_id(session: AsyncSession, game_id: int) -> int:
|
||||
|
||||
|
||||
@router.get("/games/{game_id}/bosses", response_model=list[BossBattleResponse])
|
||||
async def list_bosses(
|
||||
game_id: int, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
async def list_bosses(game_id: int, session: AsyncSession = Depends(get_session)):
|
||||
vg_id = await _get_version_group_id(session, game_id)
|
||||
|
||||
result = await session.execute(
|
||||
@@ -72,7 +72,9 @@ async def reorder_bosses(
|
||||
bosses = {b.id: b for b in result.scalars().all()}
|
||||
|
||||
if len(bosses) != len(boss_ids):
|
||||
raise HTTPException(status_code=400, detail="Some boss IDs not found in this game")
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Some boss IDs not found in this game"
|
||||
)
|
||||
|
||||
# Phase 1: set temporary negative orders to avoid unique constraint violations
|
||||
for i, item in enumerate(data.bosses):
|
||||
@@ -94,7 +96,9 @@ async def reorder_bosses(
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/games/{game_id}/bosses", response_model=BossBattleResponse, status_code=201)
|
||||
@router.post(
|
||||
"/games/{game_id}/bosses", response_model=BossBattleResponse, status_code=201
|
||||
)
|
||||
async def create_boss(
|
||||
game_id: int,
|
||||
data: BossBattleCreate,
|
||||
@@ -157,7 +161,9 @@ async def delete_boss(
|
||||
vg_id = await _get_version_group_id(session, game_id)
|
||||
|
||||
result = await session.execute(
|
||||
select(BossBattle).where(BossBattle.id == boss_id, BossBattle.version_group_id == vg_id)
|
||||
select(BossBattle).where(
|
||||
BossBattle.id == boss_id, BossBattle.version_group_id == vg_id
|
||||
)
|
||||
)
|
||||
boss = result.scalar_one_or_none()
|
||||
if boss is None:
|
||||
@@ -188,9 +194,13 @@ async def bulk_import_bosses(
|
||||
|
||||
bosses_data = [item.model_dump() for item in items]
|
||||
try:
|
||||
count = await upsert_bosses(session, vg_id, bosses_data, dex_to_id, route_name_to_id)
|
||||
count = await upsert_bosses(
|
||||
session, vg_id, bosses_data, dex_to_id, route_name_to_id
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to import bosses: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Failed to import bosses: {e}"
|
||||
) from e
|
||||
|
||||
await session.commit()
|
||||
return BulkImportResult(created=count, updated=0, errors=[])
|
||||
@@ -252,22 +262,20 @@ async def set_boss_team(
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/boss-results", response_model=list[BossResultResponse])
|
||||
async def list_boss_results(
|
||||
run_id: int, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
async def list_boss_results(run_id: int, session: AsyncSession = Depends(get_session)):
|
||||
run = await session.get(NuzlockeRun, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
result = await session.execute(
|
||||
select(BossResult)
|
||||
.where(BossResult.run_id == run_id)
|
||||
.order_by(BossResult.id)
|
||||
select(BossResult).where(BossResult.run_id == run_id).order_by(BossResult.id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/runs/{run_id}/boss-results", response_model=BossResultResponse, status_code=201)
|
||||
@router.post(
|
||||
"/runs/{run_id}/boss-results", response_model=BossResultResponse, status_code=201
|
||||
)
|
||||
async def create_boss_result(
|
||||
run_id: int,
|
||||
data: BossResultCreate,
|
||||
@@ -293,14 +301,14 @@ async def create_boss_result(
|
||||
if result:
|
||||
result.result = data.result
|
||||
result.attempts = data.attempts
|
||||
result.completed_at = datetime.now(timezone.utc) if data.result == "won" else None
|
||||
result.completed_at = datetime.now(UTC) if data.result == "won" else None
|
||||
else:
|
||||
result = BossResult(
|
||||
run_id=run_id,
|
||||
boss_battle_id=data.boss_battle_id,
|
||||
result=data.result,
|
||||
attempts=data.attempts,
|
||||
completed_at=datetime.now(timezone.utc) if data.result == "won" else None,
|
||||
completed_at=datetime.now(UTC) if data.result == "won" else None,
|
||||
)
|
||||
session.add(result)
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ from sqlalchemy.orm import joinedload, selectinload
|
||||
from app.core.database import get_session
|
||||
from app.models.encounter import Encounter
|
||||
from app.models.evolution import Evolution
|
||||
from app.models.genlocke_transfer import GenlockeTransfer
|
||||
from app.models.genlocke import GenlockeLeg
|
||||
from app.models.genlocke_transfer import GenlockeTransfer
|
||||
from app.models.nuzlocke_run import NuzlockeRun
|
||||
from app.models.pokemon import Pokemon
|
||||
from app.models.route import Route
|
||||
@@ -60,7 +60,11 @@ async def create_encounter(
|
||||
|
||||
# Shiny clause: shiny encounters bypass the route-lock check
|
||||
shiny_clause_on = run.rules.get("shinyClause", True) if run.rules else True
|
||||
skip_route_lock = (data.is_shiny and shiny_clause_on) or data.origin in ("shed_evolution", "egg", "transfer")
|
||||
skip_route_lock = (data.is_shiny and shiny_clause_on) or data.origin in (
|
||||
"shed_evolution",
|
||||
"egg",
|
||||
"transfer",
|
||||
)
|
||||
|
||||
# If this route has a parent, check if sibling already has an encounter
|
||||
if route.parent_route_id is not None and not skip_route_lock:
|
||||
@@ -78,7 +82,8 @@ async def create_encounter(
|
||||
# Zone-aware: only check siblings in the same zone (null treated as 0)
|
||||
my_zone = route.pinwheel_zone if route.pinwheel_zone is not None else 0
|
||||
sibling_ids = [
|
||||
s.id for s in siblings
|
||||
s.id
|
||||
for s in siblings
|
||||
if (s.pinwheel_zone if s.pinwheel_zone is not None else 0) == my_zone
|
||||
]
|
||||
else:
|
||||
@@ -89,8 +94,7 @@ async def create_encounter(
|
||||
# Exclude transfer-target encounters so they don't block the starter
|
||||
transfer_target_ids = select(GenlockeTransfer.target_encounter_id)
|
||||
existing_encounter = await session.execute(
|
||||
select(Encounter)
|
||||
.where(
|
||||
select(Encounter).where(
|
||||
Encounter.run_id == run_id,
|
||||
Encounter.route_id.in_(sibling_ids),
|
||||
~Encounter.id.in_(transfer_target_ids),
|
||||
@@ -197,6 +201,7 @@ async def bulk_randomize_encounters(
|
||||
|
||||
# 2. Get version_group_id from game
|
||||
from app.models.game import Game
|
||||
|
||||
game = await session.get(Game, game_id)
|
||||
if game is None or game.version_group_id is None:
|
||||
raise HTTPException(status_code=400, detail="Game has no version group")
|
||||
@@ -257,8 +262,7 @@ async def bulk_randomize_encounters(
|
||||
leg = leg_result.scalar_one_or_none()
|
||||
if leg:
|
||||
genlocke_result = await session.execute(
|
||||
select(GenlockeLeg.retired_pokemon_ids)
|
||||
.where(
|
||||
select(GenlockeLeg.retired_pokemon_ids).where(
|
||||
GenlockeLeg.genlocke_id == leg.genlocke_id,
|
||||
GenlockeLeg.leg_order < leg.leg_order,
|
||||
GenlockeLeg.retired_pokemon_ids.isnot(None),
|
||||
@@ -268,7 +272,6 @@ async def bulk_randomize_encounters(
|
||||
duped.update(retired_ids)
|
||||
|
||||
# 8. Organize routes: identify top-level and children
|
||||
routes_by_id = {r.id: r for r in all_routes}
|
||||
top_level = [r for r in all_routes if r.parent_route_id is None]
|
||||
children_by_parent: dict[int, list[Route]] = {}
|
||||
for r in all_routes:
|
||||
@@ -289,7 +292,11 @@ async def bulk_randomize_encounters(
|
||||
if parent_route.id in encountered_route_ids:
|
||||
continue
|
||||
available = route_pokemon.get(parent_route.id, [])
|
||||
eligible = [p for p in available if p not in duped] if dupes_clause_on else available
|
||||
eligible = (
|
||||
[p for p in available if p not in duped]
|
||||
if dupes_clause_on
|
||||
else available
|
||||
)
|
||||
if not eligible:
|
||||
skipped += 1
|
||||
continue
|
||||
@@ -335,7 +342,11 @@ async def bulk_randomize_encounters(
|
||||
if p not in zone_pokemon:
|
||||
zone_pokemon.append(p)
|
||||
|
||||
eligible = [p for p in zone_pokemon if p not in duped] if dupes_clause_on else zone_pokemon
|
||||
eligible = (
|
||||
[p for p in zone_pokemon if p not in duped]
|
||||
if dupes_clause_on
|
||||
else zone_pokemon
|
||||
)
|
||||
if not eligible:
|
||||
skipped += 1
|
||||
continue
|
||||
@@ -371,7 +382,11 @@ async def bulk_randomize_encounters(
|
||||
if p not in group_pokemon:
|
||||
group_pokemon.append(p)
|
||||
|
||||
eligible = [p for p in group_pokemon if p not in duped] if dupes_clause_on else group_pokemon
|
||||
eligible = (
|
||||
[p for p in group_pokemon if p not in duped]
|
||||
if dupes_clause_on
|
||||
else group_pokemon
|
||||
)
|
||||
if not eligible:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
@@ -26,17 +26,18 @@ async def list_evolutions(
|
||||
offset: int = Query(0, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
base_query = (
|
||||
select(Evolution)
|
||||
.options(joinedload(Evolution.from_pokemon), joinedload(Evolution.to_pokemon))
|
||||
base_query = select(Evolution).options(
|
||||
joinedload(Evolution.from_pokemon), joinedload(Evolution.to_pokemon)
|
||||
)
|
||||
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
# Join pokemon to search by name
|
||||
from_pokemon = select(Pokemon.id).where(
|
||||
func.lower(Pokemon.name).contains(search_lower)
|
||||
).scalar_subquery()
|
||||
from_pokemon = (
|
||||
select(Pokemon.id)
|
||||
.where(func.lower(Pokemon.name).contains(search_lower))
|
||||
.scalar_subquery()
|
||||
)
|
||||
base_query = base_query.where(
|
||||
or_(
|
||||
Evolution.from_pokemon_id.in_(from_pokemon),
|
||||
@@ -52,9 +53,11 @@ async def list_evolutions(
|
||||
count_base = select(Evolution)
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
from_pokemon = select(Pokemon.id).where(
|
||||
func.lower(Pokemon.name).contains(search_lower)
|
||||
).scalar_subquery()
|
||||
from_pokemon = (
|
||||
select(Pokemon.id)
|
||||
.where(func.lower(Pokemon.name).contains(search_lower))
|
||||
.scalar_subquery()
|
||||
)
|
||||
count_base = count_base.where(
|
||||
or_(
|
||||
Evolution.from_pokemon_id.in_(from_pokemon),
|
||||
@@ -68,7 +71,11 @@ async def list_evolutions(
|
||||
count_query = select(func.count()).select_from(count_base.subquery())
|
||||
total = (await session.execute(count_query)).scalar() or 0
|
||||
|
||||
items_query = base_query.order_by(Evolution.from_pokemon_id, Evolution.to_pokemon_id).offset(offset).limit(limit)
|
||||
items_query = (
|
||||
base_query.order_by(Evolution.from_pokemon_id, Evolution.to_pokemon_id)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await session.execute(items_query)
|
||||
items = result.scalars().unique().all()
|
||||
|
||||
@@ -209,7 +216,9 @@ async def bulk_import_evolutions(
|
||||
session.add(evolution)
|
||||
created += 1
|
||||
except Exception as e:
|
||||
errors.append(f"Evolution {item.from_pokeapi_id} -> {item.to_pokeapi_id}: {e}")
|
||||
errors.append(
|
||||
f"Evolution {item.from_pokeapi_id} -> {item.to_pokeapi_id}: {e}"
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
return BulkImportResult(created=created, updated=updated, errors=errors)
|
||||
|
||||
@@ -20,9 +20,7 @@ router = APIRouter()
|
||||
@router.get("/games")
|
||||
async def export_games(session: AsyncSession = Depends(get_session)):
|
||||
"""Export all games in seed JSON format."""
|
||||
result = await session.execute(
|
||||
select(Game).order_by(Game.name)
|
||||
)
|
||||
result = await session.execute(select(Game).order_by(Game.name))
|
||||
games = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
@@ -154,7 +152,11 @@ async def export_game_bosses(
|
||||
"pokemon_name": bp.pokemon.name,
|
||||
"level": bp.level,
|
||||
"order": bp.order,
|
||||
**({"condition_label": bp.condition_label} if bp.condition_label else {}),
|
||||
**(
|
||||
{"condition_label": bp.condition_label}
|
||||
if bp.condition_label
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for bp in sorted(b.pokemon, key=lambda p: p.order)
|
||||
],
|
||||
@@ -167,9 +169,7 @@ async def export_game_bosses(
|
||||
@router.get("/pokemon")
|
||||
async def export_pokemon(session: AsyncSession = Depends(get_session)):
|
||||
"""Export all pokemon in seed JSON format."""
|
||||
result = await session.execute(
|
||||
select(Pokemon).order_by(Pokemon.pokeapi_id)
|
||||
)
|
||||
result = await session.execute(select(Pokemon).order_by(Pokemon.pokeapi_id))
|
||||
pokemon_list = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -40,7 +40,9 @@ async def _get_game_or_404(session: AsyncSession, game_id: int) -> Game:
|
||||
async def _get_version_group_id(session: AsyncSession, game_id: int) -> int:
|
||||
game = await _get_game_or_404(session, game_id)
|
||||
if game.version_group_id is None:
|
||||
raise HTTPException(status_code=400, detail="Game has no version group assigned")
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Game has no version group assigned"
|
||||
)
|
||||
return game.version_group_id
|
||||
|
||||
|
||||
@@ -68,16 +70,18 @@ async def list_games_by_region(session: AsyncSession = Depends(get_session)):
|
||||
for region in regions_data:
|
||||
region_games = games_by_region.get(region["name"], [])
|
||||
defaults = region["genlocke_defaults"]
|
||||
response.append({
|
||||
"name": region["name"],
|
||||
"generation": region["generation"],
|
||||
"order": region["order"],
|
||||
"genlocke_defaults": {
|
||||
"true_genlocke": defaults["true"],
|
||||
"normal_genlocke": defaults["normal"],
|
||||
},
|
||||
"games": region_games,
|
||||
})
|
||||
response.append(
|
||||
{
|
||||
"name": region["name"],
|
||||
"generation": region["generation"],
|
||||
"order": region["order"],
|
||||
"genlocke_defaults": {
|
||||
"true_genlocke": defaults["true"],
|
||||
"normal_genlocke": defaults["normal"],
|
||||
},
|
||||
"games": region_games,
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@@ -89,9 +93,7 @@ async def get_game(game_id: int, session: AsyncSession = Depends(get_session)):
|
||||
|
||||
# Load routes via version_group_id
|
||||
result = await session.execute(
|
||||
select(Route)
|
||||
.where(Route.version_group_id == vg_id)
|
||||
.order_by(Route.order)
|
||||
select(Route).where(Route.version_group_id == vg_id).order_by(Route.order)
|
||||
)
|
||||
routes = result.scalars().all()
|
||||
|
||||
@@ -149,10 +151,13 @@ async def list_game_routes(
|
||||
|
||||
def route_to_dict(route: Route) -> dict:
|
||||
# Only show encounter methods for the requested game
|
||||
methods = sorted({
|
||||
re.encounter_method for re in route.route_encounters
|
||||
if re.game_id == game_id
|
||||
})
|
||||
methods = sorted(
|
||||
{
|
||||
re.encounter_method
|
||||
for re in route.route_encounters
|
||||
if re.game_id == game_id
|
||||
}
|
||||
)
|
||||
return {
|
||||
"id": route.id,
|
||||
"name": route.name,
|
||||
@@ -193,14 +198,12 @@ async def list_game_routes(
|
||||
|
||||
|
||||
@router.post("", response_model=GameResponse, status_code=201)
|
||||
async def create_game(
|
||||
data: GameCreate, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
existing = await session.execute(
|
||||
select(Game).where(Game.slug == data.slug)
|
||||
)
|
||||
async def create_game(data: GameCreate, session: AsyncSession = Depends(get_session)):
|
||||
existing = await session.execute(select(Game).where(Game.slug == data.slug))
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(status_code=409, detail="Game with this slug already exists")
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Game with this slug already exists"
|
||||
)
|
||||
|
||||
game = Game(**data.model_dump())
|
||||
session.add(game)
|
||||
@@ -223,7 +226,9 @@ async def update_game(
|
||||
select(Game).where(Game.slug == update_data["slug"], Game.id != game_id)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(status_code=409, detail="Game with this slug already exists")
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Game with this slug already exists"
|
||||
)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(game, field, value)
|
||||
@@ -234,9 +239,7 @@ async def update_game(
|
||||
|
||||
|
||||
@router.delete("/{game_id}", status_code=204)
|
||||
async def delete_game(
|
||||
game_id: int, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
async def delete_game(game_id: int, session: AsyncSession = Depends(get_session)):
|
||||
result = await session.execute(
|
||||
select(Game).where(Game.id == game_id).options(selectinload(Game.runs))
|
||||
)
|
||||
@@ -393,7 +396,9 @@ async def bulk_import_routes(
|
||||
try:
|
||||
route_name_to_id = await upsert_routes(session, vg_id, routes_data)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to import routes: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Failed to import routes: {e}"
|
||||
) from e
|
||||
|
||||
# Upsert encounters for each route
|
||||
encounter_count = 0
|
||||
@@ -406,8 +411,11 @@ async def bulk_import_routes(
|
||||
if item.encounters:
|
||||
try:
|
||||
count = await upsert_route_encounters(
|
||||
session, route_id, [e.model_dump() for e in item.encounters],
|
||||
dex_to_id, game_id,
|
||||
session,
|
||||
route_id,
|
||||
[e.model_dump() for e in item.encounters],
|
||||
dex_to_id,
|
||||
game_id,
|
||||
)
|
||||
encounter_count += count
|
||||
except Exception as e:
|
||||
@@ -422,8 +430,11 @@ async def bulk_import_routes(
|
||||
if child.encounters:
|
||||
try:
|
||||
count = await upsert_route_encounters(
|
||||
session, child_id, [e.model_dump() for e in child.encounters],
|
||||
dex_to_id, game_id,
|
||||
session,
|
||||
child_id,
|
||||
[e.model_dump() for e in child.encounters],
|
||||
dex_to_id,
|
||||
game_id,
|
||||
)
|
||||
encounter_count += count
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import delete as sa_delete, func, select, update as sa_update
|
||||
from sqlalchemy import delete as sa_delete
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -9,9 +11,9 @@ from app.models.encounter import Encounter
|
||||
from app.models.evolution import Evolution
|
||||
from app.models.game import Game
|
||||
from app.models.genlocke import Genlocke, GenlockeLeg
|
||||
from app.models.genlocke_transfer import GenlockeTransfer
|
||||
from app.models.nuzlocke_run import NuzlockeRun
|
||||
from app.models.pokemon import Pokemon
|
||||
from app.models.genlocke_transfer import GenlockeTransfer
|
||||
from app.models.route import Route
|
||||
from app.schemas.genlocke import (
|
||||
AddLegRequest,
|
||||
@@ -74,9 +76,7 @@ async def list_genlockes(session: AsyncSession = Depends(get_session)):
|
||||
|
||||
|
||||
@router.get("/{genlocke_id}", response_model=GenlockeDetailResponse)
|
||||
async def get_genlocke(
|
||||
genlocke_id: int, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
async def get_genlocke(genlocke_id: int, session: AsyncSession = Depends(get_session)):
|
||||
result = await session.execute(
|
||||
select(Genlocke)
|
||||
.where(Genlocke.id == genlocke_id)
|
||||
@@ -112,7 +112,9 @@ async def get_genlocke(
|
||||
legs_completed = 0
|
||||
for leg in genlocke.legs:
|
||||
run_status = leg.run.status if leg.run else None
|
||||
enc_count, death_count = stats_by_run.get(leg.run_id, (0, 0)) if leg.run_id else (0, 0)
|
||||
enc_count, death_count = (
|
||||
stats_by_run.get(leg.run_id, (0, 0)) if leg.run_id else (0, 0)
|
||||
)
|
||||
total_encounters += enc_count
|
||||
total_deaths += death_count
|
||||
if run_status == "completed":
|
||||
@@ -254,7 +256,9 @@ async def get_genlocke_graveyard(
|
||||
)
|
||||
)
|
||||
|
||||
deadliest = max(deaths_per_leg, key=lambda s: s.death_count) if deaths_per_leg else None
|
||||
deadliest = (
|
||||
max(deaths_per_leg, key=lambda s: s.death_count) if deaths_per_leg else None
|
||||
)
|
||||
|
||||
return GenlockeGraveyardResponse(
|
||||
entries=entries,
|
||||
@@ -285,9 +289,7 @@ async def get_genlocke_lineages(
|
||||
|
||||
# Query all transfers for this genlocke
|
||||
transfer_result = await session.execute(
|
||||
select(GenlockeTransfer).where(
|
||||
GenlockeTransfer.genlocke_id == genlocke_id
|
||||
)
|
||||
select(GenlockeTransfer).where(GenlockeTransfer.genlocke_id == genlocke_id)
|
||||
)
|
||||
transfers = transfer_result.scalars().all()
|
||||
|
||||
@@ -302,7 +304,11 @@ async def get_genlocke_lineages(
|
||||
backward.add(t.target_encounter_id)
|
||||
|
||||
# Find roots: sources that are NOT targets
|
||||
roots = [t.source_encounter_id for t in transfers if t.source_encounter_id not in backward]
|
||||
roots = [
|
||||
t.source_encounter_id
|
||||
for t in transfers
|
||||
if t.source_encounter_id not in backward
|
||||
]
|
||||
# Deduplicate while preserving order
|
||||
seen_roots: set[int] = set()
|
||||
unique_roots: list[int] = []
|
||||
@@ -421,7 +427,7 @@ async def get_genlocke_lineages(
|
||||
)
|
||||
|
||||
# Sort by first leg order, then by encounter ID
|
||||
lineages.sort(key=lambda l: (l.legs[0].leg_order, l.legs[0].encounter_id))
|
||||
lineages.sort(key=lambda lin: (lin.legs[0].leg_order, lin.legs[0].encounter_id))
|
||||
|
||||
return GenlockeLineageResponse(
|
||||
lineages=lineages,
|
||||
@@ -440,15 +446,11 @@ async def create_genlocke(
|
||||
raise HTTPException(status_code=400, detail="Name is required")
|
||||
|
||||
# Validate all game_ids exist
|
||||
result = await session.execute(
|
||||
select(Game).where(Game.id.in_(data.game_ids))
|
||||
)
|
||||
result = await session.execute(select(Game).where(Game.id.in_(data.game_ids)))
|
||||
found_games = {g.id: g for g in result.scalars().all()}
|
||||
missing = [gid for gid in data.game_ids if gid not in found_games]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Games not found: {missing}"
|
||||
)
|
||||
raise HTTPException(status_code=404, detail=f"Games not found: {missing}")
|
||||
|
||||
# Create genlocke
|
||||
genlocke = Genlocke(
|
||||
@@ -578,9 +580,7 @@ async def advance_leg(
|
||||
raise HTTPException(status_code=404, detail="Genlocke not found")
|
||||
|
||||
if genlocke.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Genlocke is not active"
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Genlocke is not active")
|
||||
|
||||
# Find the current leg
|
||||
current_leg = None
|
||||
@@ -596,9 +596,7 @@ async def advance_leg(
|
||||
|
||||
# Verify current leg's run is completed
|
||||
if current_leg.run_id is None:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Current leg has no run"
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Current leg has no run")
|
||||
current_run = await session.get(NuzlockeRun, current_leg.run_id)
|
||||
if current_run is None or current_run.status != "completed":
|
||||
raise HTTPException(
|
||||
@@ -606,14 +604,10 @@ async def advance_leg(
|
||||
)
|
||||
|
||||
if next_leg is None:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="No next leg to advance to"
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="No next leg to advance to")
|
||||
|
||||
if next_leg.run_id is not None:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Next leg already has a run"
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Next leg already has a run")
|
||||
|
||||
# Compute retired Pokemon families if retireHoF is enabled
|
||||
if genlocke.genlocke_rules.get("retireHoF", False):
|
||||
@@ -807,10 +801,12 @@ async def get_retired_families(
|
||||
for leg in legs:
|
||||
ids = leg.retired_pokemon_ids or []
|
||||
cumulative.update(ids)
|
||||
by_leg.append(RetiredLegResponse(
|
||||
leg_order=leg.leg_order,
|
||||
retired_pokemon_ids=ids,
|
||||
))
|
||||
by_leg.append(
|
||||
RetiredLegResponse(
|
||||
leg_order=leg.leg_order,
|
||||
retired_pokemon_ids=ids,
|
||||
)
|
||||
)
|
||||
|
||||
return RetiredFamiliesResponse(
|
||||
retired_pokemon_ids=sorted(cumulative),
|
||||
@@ -837,12 +833,15 @@ async def update_genlocke(
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
if "status" in update_data:
|
||||
if update_data["status"] not in ("active", "completed", "failed"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Status must be one of: active, completed, failed",
|
||||
)
|
||||
if "status" in update_data and update_data["status"] not in (
|
||||
"active",
|
||||
"completed",
|
||||
"failed",
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Status must be one of: active, completed, failed",
|
||||
)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(genlocke, field, value)
|
||||
@@ -871,8 +870,7 @@ async def delete_genlocke(
|
||||
# Delete legs explicitly to avoid ORM cascade issues
|
||||
# (genlocke_id is non-nullable, so SQLAlchemy can't nullify it)
|
||||
await session.execute(
|
||||
sa_delete(GenlockeLeg)
|
||||
.where(GenlockeLeg.genlocke_id == genlocke_id)
|
||||
sa_delete(GenlockeLeg).where(GenlockeLeg.genlocke_id == genlocke_id)
|
||||
)
|
||||
|
||||
await session.delete(genlocke)
|
||||
|
||||
@@ -8,7 +8,6 @@ from app.models.evolution import Evolution
|
||||
from app.models.pokemon import Pokemon
|
||||
from app.models.route import Route
|
||||
from app.models.route_encounter import RouteEncounter
|
||||
from app.models.game import Game
|
||||
from app.schemas.pokemon import (
|
||||
BulkImportItem,
|
||||
BulkImportResult,
|
||||
@@ -40,9 +39,7 @@ async def list_pokemon(
|
||||
# Build base query with optional search filter
|
||||
base_query = select(Pokemon)
|
||||
if search:
|
||||
base_query = base_query.where(
|
||||
func.lower(Pokemon.name).contains(search.lower())
|
||||
)
|
||||
base_query = base_query.where(func.lower(Pokemon.name).contains(search.lower()))
|
||||
if type:
|
||||
base_query = base_query.where(Pokemon.types.any(type))
|
||||
|
||||
@@ -51,7 +48,11 @@ async def list_pokemon(
|
||||
total = (await session.execute(count_query)).scalar() or 0
|
||||
|
||||
# Get paginated items
|
||||
items_query = base_query.order_by(Pokemon.national_dex, Pokemon.name).offset(offset).limit(limit)
|
||||
items_query = (
|
||||
base_query.order_by(Pokemon.national_dex, Pokemon.name)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await session.execute(items_query)
|
||||
items = result.scalars().all()
|
||||
|
||||
@@ -156,9 +157,7 @@ async def get_pokemon_families(
|
||||
|
||||
|
||||
@router.get("/pokemon/{pokemon_id}", response_model=PokemonResponse)
|
||||
async def get_pokemon(
|
||||
pokemon_id: int, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
async def get_pokemon(pokemon_id: int, session: AsyncSession = Depends(get_session)):
|
||||
pokemon = await session.get(Pokemon, pokemon_id)
|
||||
if pokemon is None:
|
||||
raise HTTPException(status_code=404, detail="Pokemon not found")
|
||||
@@ -258,7 +257,8 @@ async def get_pokemon_evolution_chain(
|
||||
|
||||
# Filter evolutions to only those in the family
|
||||
family_evo_ids = [
|
||||
evo.id for evo in evolutions
|
||||
evo.id
|
||||
for evo in evolutions
|
||||
if evo.from_pokemon_id in family and evo.to_pokemon_id in family
|
||||
]
|
||||
|
||||
@@ -294,9 +294,7 @@ async def get_pokemon_evolutions(
|
||||
.options(joinedload(Evolution.to_pokemon))
|
||||
)
|
||||
if region is not None:
|
||||
query = query.where(
|
||||
or_(Evolution.region.is_(None), Evolution.region == region)
|
||||
)
|
||||
query = query.where(or_(Evolution.region.is_(None), Evolution.region == region))
|
||||
result = await session.execute(query)
|
||||
evolutions = result.scalars().unique().all()
|
||||
|
||||
@@ -309,7 +307,8 @@ async def get_pokemon_evolutions(
|
||||
}
|
||||
if regional_keys:
|
||||
evolutions = [
|
||||
e for e in evolutions
|
||||
e
|
||||
for e in evolutions
|
||||
if e.region is not None or (e.trigger, e.item) not in regional_keys
|
||||
]
|
||||
|
||||
@@ -349,9 +348,7 @@ async def update_pokemon(
|
||||
|
||||
|
||||
@router.delete("/pokemon/{pokemon_id}", status_code=204)
|
||||
async def delete_pokemon(
|
||||
pokemon_id: int, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
async def delete_pokemon(pokemon_id: int, session: AsyncSession = Depends(get_session)):
|
||||
result = await session.execute(
|
||||
select(Pokemon)
|
||||
.where(Pokemon.id == pokemon_id)
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api import bosses, encounters, evolutions, export, games, genlockes, health, pokemon, runs, stats
|
||||
from app.api import (
|
||||
bosses,
|
||||
encounters,
|
||||
evolutions,
|
||||
export,
|
||||
games,
|
||||
genlockes,
|
||||
health,
|
||||
pokemon,
|
||||
runs,
|
||||
stats,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health.router)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from sqlalchemy import func, select
|
||||
@@ -9,18 +9,22 @@ from app.core.database import get_session
|
||||
from app.models.boss_result import BossResult
|
||||
from app.models.encounter import Encounter
|
||||
from app.models.game import Game
|
||||
from app.models.genlocke import Genlocke, GenlockeLeg
|
||||
from app.models.genlocke import GenlockeLeg
|
||||
from app.models.genlocke_transfer import GenlockeTransfer
|
||||
from app.models.nuzlocke_run import NuzlockeRun
|
||||
from app.schemas.run import RunCreate, RunDetailResponse, RunGenlockeContext, RunResponse, RunUpdate
|
||||
from app.schemas.run import (
|
||||
RunCreate,
|
||||
RunDetailResponse,
|
||||
RunGenlockeContext,
|
||||
RunResponse,
|
||||
RunUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=RunResponse, status_code=201)
|
||||
async def create_run(
|
||||
data: RunCreate, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
async def create_run(data: RunCreate, session: AsyncSession = Depends(get_session)):
|
||||
# Validate game exists
|
||||
game = await session.get(Game, data.game_id)
|
||||
if game is None:
|
||||
@@ -53,12 +57,9 @@ async def get_run(run_id: int, session: AsyncSession = Depends(get_session)):
|
||||
.where(NuzlockeRun.id == run_id)
|
||||
.options(
|
||||
joinedload(NuzlockeRun.game),
|
||||
selectinload(NuzlockeRun.encounters)
|
||||
.joinedload(Encounter.pokemon),
|
||||
selectinload(NuzlockeRun.encounters)
|
||||
.joinedload(Encounter.current_pokemon),
|
||||
selectinload(NuzlockeRun.encounters)
|
||||
.joinedload(Encounter.route),
|
||||
selectinload(NuzlockeRun.encounters).joinedload(Encounter.pokemon),
|
||||
selectinload(NuzlockeRun.encounters).joinedload(Encounter.current_pokemon),
|
||||
selectinload(NuzlockeRun.encounters).joinedload(Encounter.route),
|
||||
)
|
||||
)
|
||||
run = result.scalar_one_or_none()
|
||||
@@ -134,7 +135,10 @@ async def update_run(
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# Validate hof_encounter_ids if provided
|
||||
if "hof_encounter_ids" in update_data and update_data["hof_encounter_ids"] is not None:
|
||||
if (
|
||||
"hof_encounter_ids" in update_data
|
||||
and update_data["hof_encounter_ids"] is not None
|
||||
):
|
||||
hof_ids = update_data["hof_encounter_ids"]
|
||||
if len(hof_ids) > 6:
|
||||
raise HTTPException(
|
||||
@@ -156,7 +160,8 @@ async def update_run(
|
||||
detail=f"Encounters not found in this run: {missing}",
|
||||
)
|
||||
not_alive = [
|
||||
eid for eid, e in found.items()
|
||||
eid
|
||||
for eid, e in found.items()
|
||||
if e.status != "caught" or e.faint_level is not None
|
||||
]
|
||||
if not_alive:
|
||||
@@ -168,13 +173,15 @@ async def update_run(
|
||||
# Auto-set completed_at when ending a run
|
||||
if "status" in update_data and update_data["status"] in ("completed", "failed"):
|
||||
if run.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Only active runs can be ended"
|
||||
)
|
||||
update_data["completed_at"] = datetime.now(timezone.utc)
|
||||
raise HTTPException(status_code=400, detail="Only active runs can be ended")
|
||||
update_data["completed_at"] = datetime.now(UTC)
|
||||
|
||||
# Block reactivating a completed/failed run that belongs to a genlocke
|
||||
if "status" in update_data and update_data["status"] == "active" and run.status != "active":
|
||||
if (
|
||||
"status" in update_data
|
||||
and update_data["status"] == "active"
|
||||
and run.status != "active"
|
||||
):
|
||||
leg_result = await session.execute(
|
||||
select(GenlockeLeg).where(GenlockeLeg.run_id == run_id)
|
||||
)
|
||||
@@ -215,9 +222,7 @@ async def update_run(
|
||||
|
||||
|
||||
@router.delete("/{run_id}", status_code=204)
|
||||
async def delete_run(
|
||||
run_id: int, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
async def delete_run(run_id: int, session: AsyncSession = Depends(get_session)):
|
||||
run = await session.get(NuzlockeRun, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
@@ -84,8 +84,12 @@ async def get_stats(session: AsyncSession = Depends(get_session)):
|
||||
fainted_count = enc.fainted
|
||||
missed_count = enc.missed
|
||||
|
||||
catch_rate = round(caught_count / total_encounters, 4) if total_encounters > 0 else None
|
||||
avg_encounters_per_run = round(total_encounters / total_runs, 1) if total_runs > 0 else None
|
||||
catch_rate = (
|
||||
round(caught_count / total_encounters, 4) if total_encounters > 0 else None
|
||||
)
|
||||
avg_encounters_per_run = (
|
||||
round(total_encounters / total_runs, 1) if total_runs > 0 else None
|
||||
)
|
||||
|
||||
# --- Top caught pokemon (top 10) ---
|
||||
top_caught_q = await session.execute(
|
||||
@@ -102,7 +106,9 @@ async def get_stats(session: AsyncSession = Depends(get_session)):
|
||||
.limit(10)
|
||||
)
|
||||
top_caught_pokemon = [
|
||||
PokemonRanking(pokemon_id=r.id, name=r.name, sprite_url=r.sprite_url, count=r.count)
|
||||
PokemonRanking(
|
||||
pokemon_id=r.id, name=r.name, sprite_url=r.sprite_url, count=r.count
|
||||
)
|
||||
for r in top_caught_q.all()
|
||||
]
|
||||
|
||||
@@ -120,7 +126,9 @@ async def get_stats(session: AsyncSession = Depends(get_session)):
|
||||
.limit(10)
|
||||
)
|
||||
top_encountered_pokemon = [
|
||||
PokemonRanking(pokemon_id=r.id, name=r.name, sprite_url=r.sprite_url, count=r.count)
|
||||
PokemonRanking(
|
||||
pokemon_id=r.id, name=r.name, sprite_url=r.sprite_url, count=r.count
|
||||
)
|
||||
for r in top_enc_q.all()
|
||||
]
|
||||
|
||||
@@ -149,8 +157,7 @@ async def get_stats(session: AsyncSession = Depends(get_session)):
|
||||
.limit(5)
|
||||
)
|
||||
top_death_causes = [
|
||||
DeathCause(cause=r.death_cause, count=r.count)
|
||||
for r in death_causes_q.all()
|
||||
DeathCause(cause=r.death_cause, count=r.count) for r in death_causes_q.all()
|
||||
]
|
||||
|
||||
# Average levels
|
||||
@@ -179,8 +186,7 @@ async def get_stats(session: AsyncSession = Depends(get_session)):
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
type_distribution = [
|
||||
TypeCount(type=r.type_name, count=r.count)
|
||||
for r in type_q.all()
|
||||
TypeCount(type=r.type_name, count=r.count) for r in type_q.all()
|
||||
]
|
||||
|
||||
return StatsResponse(
|
||||
|
||||
Reference in New Issue
Block a user