Add bulk import for evolutions, routes, and bosses
Add three new bulk import endpoints that accept the same JSON format as
their corresponding export endpoints, enabling round-trip compatibility:
- POST /evolutions/bulk-import (upsert by from/to pokemon pair)
- POST /games/{id}/routes/bulk-import (reuses seed loader for hierarchy)
- POST /games/{id}/bosses/bulk-import (reuses seed loader with team data)
Generalize BulkImportModal to support all entity types with configurable
title, example, and result labels. Wire up Bulk Import buttons on
AdminEvolutions, and AdminGameDetail routes/bosses tabs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,8 @@ from app.models.boss_pokemon import BossPokemon
|
||||
from app.models.boss_result import BossResult
|
||||
from app.models.game import Game
|
||||
from app.models.nuzlocke_run import NuzlockeRun
|
||||
from app.models.pokemon import Pokemon
|
||||
from app.models.route import Route
|
||||
from app.schemas.boss import (
|
||||
BossBattleCreate,
|
||||
BossBattleResponse,
|
||||
@@ -20,6 +22,8 @@ from app.schemas.boss import (
|
||||
BossResultCreate,
|
||||
BossResultResponse,
|
||||
)
|
||||
from app.schemas.pokemon import BulkBossItem, BulkImportResult
|
||||
from app.seeds.loader import upsert_bosses
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -164,6 +168,34 @@ async def delete_boss(
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/games/{game_id}/bosses/bulk-import", response_model=BulkImportResult)
|
||||
async def bulk_import_bosses(
|
||||
game_id: int,
|
||||
items: list[BulkBossItem],
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
vg_id = await _get_version_group_id(session, game_id)
|
||||
|
||||
# Build pokeapi_id -> id mapping
|
||||
result = await session.execute(select(Pokemon.pokeapi_id, Pokemon.id))
|
||||
dex_to_id = {row.pokeapi_id: row.id for row in result}
|
||||
|
||||
# Build route name -> id mapping for after_route_name resolution
|
||||
result = await session.execute(
|
||||
select(Route.name, Route.id).where(Route.version_group_id == vg_id)
|
||||
)
|
||||
route_name_to_id = {row.name: row.id for row in result}
|
||||
|
||||
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)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to import bosses: {e}")
|
||||
|
||||
await session.commit()
|
||||
return BulkImportResult(created=count, updated=0, errors=[])
|
||||
|
||||
|
||||
@router.put(
|
||||
"/games/{game_id}/bosses/{boss_id}/pokemon",
|
||||
response_model=BossBattleResponse,
|
||||
|
||||
@@ -7,6 +7,8 @@ from app.core.database import get_session
|
||||
from app.models.evolution import Evolution
|
||||
from app.models.pokemon import Pokemon
|
||||
from app.schemas.pokemon import (
|
||||
BulkEvolutionItem,
|
||||
BulkImportResult,
|
||||
EvolutionAdminResponse,
|
||||
EvolutionCreate,
|
||||
EvolutionUpdate,
|
||||
@@ -144,3 +146,65 @@ async def delete_evolution(
|
||||
|
||||
await session.delete(evolution)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.post("/evolutions/bulk-import", response_model=BulkImportResult)
|
||||
async def bulk_import_evolutions(
|
||||
items: list[BulkEvolutionItem],
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
# Build pokeapi_id -> id mapping
|
||||
result = await session.execute(select(Pokemon.pokeapi_id, Pokemon.id))
|
||||
dex_to_id = {row.pokeapi_id: row.id for row in result}
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
errors: list[str] = []
|
||||
|
||||
for item in items:
|
||||
from_id = dex_to_id.get(item.from_pokeapi_id)
|
||||
to_id = dex_to_id.get(item.to_pokeapi_id)
|
||||
|
||||
if from_id is None:
|
||||
errors.append(f"Pokemon with pokeapi_id {item.from_pokeapi_id} not found")
|
||||
continue
|
||||
if to_id is None:
|
||||
errors.append(f"Pokemon with pokeapi_id {item.to_pokeapi_id} not found")
|
||||
continue
|
||||
|
||||
try:
|
||||
# Check if evolution already exists
|
||||
existing = await session.execute(
|
||||
select(Evolution).where(
|
||||
Evolution.from_pokemon_id == from_id,
|
||||
Evolution.to_pokemon_id == to_id,
|
||||
)
|
||||
)
|
||||
evolution = existing.scalar_one_or_none()
|
||||
|
||||
if evolution is not None:
|
||||
evolution.trigger = item.trigger
|
||||
evolution.min_level = item.min_level
|
||||
evolution.item = item.item
|
||||
evolution.held_item = item.held_item
|
||||
evolution.condition = item.condition
|
||||
evolution.region = item.region
|
||||
updated += 1
|
||||
else:
|
||||
evolution = Evolution(
|
||||
from_pokemon_id=from_id,
|
||||
to_pokemon_id=to_id,
|
||||
trigger=item.trigger,
|
||||
min_level=item.min_level,
|
||||
item=item.item,
|
||||
held_item=item.held_item,
|
||||
condition=item.condition,
|
||||
region=item.region,
|
||||
)
|
||||
session.add(evolution)
|
||||
created += 1
|
||||
except Exception as 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)
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.core.database import get_session
|
||||
from app.models.boss_battle import BossBattle
|
||||
from app.models.game import Game
|
||||
from app.models.pokemon import Pokemon
|
||||
from app.models.route import Route
|
||||
from app.models.route_encounter import RouteEncounter
|
||||
from app.schemas.game import (
|
||||
@@ -19,6 +20,8 @@ from app.schemas.game import (
|
||||
RouteUpdate,
|
||||
RouteWithChildrenResponse,
|
||||
)
|
||||
from app.schemas.pokemon import BulkImportResult, BulkRouteItem
|
||||
from app.seeds.loader import upsert_route_encounters, upsert_routes
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -332,3 +335,68 @@ async def delete_route(
|
||||
|
||||
await session.delete(route)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.post("/{game_id}/routes/bulk-import", response_model=BulkImportResult)
|
||||
async def bulk_import_routes(
|
||||
game_id: int,
|
||||
items: list[BulkRouteItem],
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
vg_id = await _get_version_group_id(session, game_id)
|
||||
|
||||
# Build pokeapi_id -> id mapping for encounter resolution
|
||||
result = await session.execute(select(Pokemon.pokeapi_id, Pokemon.id))
|
||||
dex_to_id = {row.pokeapi_id: row.id for row in result}
|
||||
|
||||
errors: list[str] = []
|
||||
|
||||
# Upsert routes using the seed loader (handles parent/child hierarchy)
|
||||
routes_data = [item.model_dump() for item in items]
|
||||
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}")
|
||||
|
||||
# Upsert encounters for each route
|
||||
encounter_count = 0
|
||||
for item in items:
|
||||
route_id = route_name_to_id.get(item.name)
|
||||
if route_id is None:
|
||||
errors.append(f"Route '{item.name}' not found after upsert")
|
||||
continue
|
||||
|
||||
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,
|
||||
)
|
||||
encounter_count += count
|
||||
except Exception as e:
|
||||
errors.append(f"Encounters for '{item.name}': {e}")
|
||||
|
||||
for child in item.children:
|
||||
child_id = route_name_to_id.get(child.name)
|
||||
if child_id is None:
|
||||
errors.append(f"Child route '{child.name}' not found after upsert")
|
||||
continue
|
||||
|
||||
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,
|
||||
)
|
||||
encounter_count += count
|
||||
except Exception as e:
|
||||
errors.append(f"Encounters for '{child.name}': {e}")
|
||||
|
||||
await session.commit()
|
||||
|
||||
route_count = len(route_name_to_id)
|
||||
return BulkImportResult(
|
||||
created=route_count,
|
||||
updated=encounter_count,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -158,3 +158,60 @@ class EvolutionUpdate(CamelModel):
|
||||
held_item: str | None = None
|
||||
condition: str | None = None
|
||||
region: str | None = None
|
||||
|
||||
|
||||
# --- Bulk import schemas (match export format, snake_case) ---
|
||||
|
||||
|
||||
class BulkEvolutionItem(BaseModel):
|
||||
from_pokeapi_id: int
|
||||
to_pokeapi_id: int
|
||||
trigger: str
|
||||
min_level: int | None = None
|
||||
item: str | None = None
|
||||
held_item: str | None = None
|
||||
condition: str | None = None
|
||||
region: str | None = None
|
||||
|
||||
|
||||
class BulkRouteEncounterItem(BaseModel):
|
||||
pokeapi_id: int
|
||||
method: str
|
||||
encounter_rate: int
|
||||
min_level: int
|
||||
max_level: int
|
||||
|
||||
|
||||
class BulkRouteChildItem(BaseModel):
|
||||
name: str
|
||||
order: int
|
||||
pinwheel_zone: int | None = None
|
||||
encounters: list[BulkRouteEncounterItem] = []
|
||||
|
||||
|
||||
class BulkRouteItem(BaseModel):
|
||||
name: str
|
||||
order: int
|
||||
encounters: list[BulkRouteEncounterItem] = []
|
||||
children: list[BulkRouteChildItem] = []
|
||||
|
||||
|
||||
class BulkBossPokemonItem(BaseModel):
|
||||
pokeapi_id: int
|
||||
level: int
|
||||
order: int
|
||||
|
||||
|
||||
class BulkBossItem(BaseModel):
|
||||
name: str
|
||||
boss_type: str
|
||||
specialty_type: str | None = None
|
||||
badge_name: str | None = None
|
||||
badge_image_url: str | None = None
|
||||
level_cap: int
|
||||
order: int
|
||||
after_route_name: str | None = None
|
||||
location: str
|
||||
section: str | None = None
|
||||
sprite_url: str | None = None
|
||||
pokemon: list[BulkBossPokemonItem] = []
|
||||
|
||||
Reference in New Issue
Block a user