Add game_id FK to BossBattle for version-exclusive bosses
All checks were successful
CI / backend-lint (pull_request) Successful in 8s
CI / frontend-lint (pull_request) Successful in 30s

Version-exclusive bosses (e.g., Bea in Sword, Allister in Shield) were
using the section field to indicate which game they belong to. This adds
a proper game_id foreign key so the API can filter bosses per game,
keeping section free for visual grouping like "Main Story".

- Alembic migration adds nullable game_id column with FK and index
- API list_bosses filters by game_id unless ?all=true is passed
- Seed data updated to use game_slug instead of section overloading
- Admin form adds "Game (version exclusive)" dropdown
- Export endpoints include game_slug for exclusive bosses

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 11:00:48 +01:00
parent 3e7a6c9221
commit d8fb6c328c
17 changed files with 250 additions and 53 deletions

View File

@@ -0,0 +1,76 @@
"""add game_id to boss battles
Revision ID: f7a8b9c0d1e2
Revises: e5f70a1ca323
Create Date: 2026-02-14 12:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "f7a8b9c0d1e2"
down_revision: str | Sequence[str] | None = "e5f70a1ca323"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"boss_battles",
sa.Column("game_id", sa.Integer(), nullable=True),
)
op.create_foreign_key(
"fk_boss_battles_game_id",
"boss_battles",
"games",
["game_id"],
["id"],
)
op.create_index("ix_boss_battles_game_id", "boss_battles", ["game_id"])
# Data migration: for bosses where section is a game name,
# look up the game ID, set game_id, and reset section to null.
conn = op.get_bind()
rows = conn.execute(
sa.text(
"SELECT bb.id, g.id AS gid "
"FROM boss_battles bb "
"JOIN games g ON LOWER(bb.section) = LOWER(g.name) "
"WHERE bb.section IS NOT NULL"
)
).fetchall()
for row in rows:
conn.execute(
sa.text(
"UPDATE boss_battles SET game_id = :gid, section = NULL WHERE id = :bid"
),
{"gid": row.gid, "bid": row.id},
)
def downgrade() -> None:
# Reverse data migration: restore section from game name
conn = op.get_bind()
rows = conn.execute(
sa.text(
"SELECT bb.id, g.name "
"FROM boss_battles bb "
"JOIN games g ON bb.game_id = g.id "
"WHERE bb.game_id IS NOT NULL"
)
).fetchall()
for row in rows:
conn.execute(
sa.text(
"UPDATE boss_battles SET section = :name, game_id = NULL WHERE id = :bid"
),
{"name": row.name, "bid": row.id},
)
op.drop_index("ix_boss_battles_game_id", table_name="boss_battles")
op.drop_constraint("fk_boss_battles_game_id", "boss_battles", type_="foreignkey")
op.drop_column("boss_battles", "game_id")

View File

@@ -1,7 +1,7 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import select
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -43,15 +43,26 @@ 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,
all: bool = False,
session: AsyncSession = Depends(get_session),
):
vg_id = await _get_version_group_id(session, game_id)
result = await session.execute(
query = (
select(BossBattle)
.where(BossBattle.version_group_id == vg_id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
.order_by(BossBattle.order)
)
if not all:
query = query.where(
or_(BossBattle.game_id.is_(None), BossBattle.game_id == game_id)
)
result = await session.execute(query)
return result.scalars().all()
@@ -106,6 +117,14 @@ async def create_boss(
):
vg_id = await _get_version_group_id(session, game_id)
if data.game_id is not None:
game = await session.get(Game, data.game_id)
if game is None or game.version_group_id != vg_id:
raise HTTPException(
status_code=400,
detail="game_id does not belong to this version group",
)
boss = BossBattle(version_group_id=vg_id, **data.model_dump())
session.add(boss)
await session.commit()
@@ -128,6 +147,14 @@ async def update_boss(
):
vg_id = await _get_version_group_id(session, game_id)
if data.game_id is not None:
game = await session.get(Game, data.game_id)
if game is None or game.version_group_id != vg_id:
raise HTTPException(
status_code=400,
detail="game_id does not belong to this version group",
)
result = await session.execute(
select(BossBattle)
.where(BossBattle.id == boss_id, BossBattle.version_group_id == vg_id)
@@ -192,10 +219,16 @@ async def bulk_import_bosses(
)
route_name_to_id = {row.name: row.id for row in result}
# Build game slug -> id mapping for game_slug resolution
result = await session.execute(
select(Game.slug, Game.id).where(Game.version_group_id == vg_id)
)
slug_to_game_id = {row.slug: 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
session, vg_id, bosses_data, dex_to_id, route_name_to_id, slug_to_game_id
)
except Exception as e:
raise HTTPException(

View File

@@ -126,6 +126,7 @@ async def export_game_bosses(
.options(
selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon),
selectinload(BossBattle.after_route),
selectinload(BossBattle.game),
)
.order_by(BossBattle.order)
)
@@ -146,6 +147,7 @@ async def export_game_bosses(
"location": b.location,
"section": b.section,
"sprite_url": b.sprite_url,
**({"game_slug": b.game.slug} if b.game_id else {}),
"pokemon": [
{
"pokeapi_id": bp.pokemon.pokeapi_id,

View File

@@ -33,9 +33,13 @@ class BossBattle(Base):
location: Mapped[str] = mapped_column(String(200))
section: Mapped[str | None] = mapped_column(String(100), default=None)
sprite_url: Mapped[str | None] = mapped_column(String(500))
game_id: Mapped[int | None] = mapped_column(
ForeignKey("games.id"), index=True, default=None
)
version_group: Mapped["VersionGroup"] = relationship(back_populates="boss_battles")
after_route: Mapped["Route | None"] = relationship()
game: Mapped["Game | None"] = relationship()
pokemon: Mapped[list["BossPokemon"]] = relationship(
back_populates="boss_battle", cascade="all, delete-orphan"
)

View File

@@ -27,6 +27,7 @@ class BossBattleResponse(CamelModel):
location: str
section: str | None
sprite_url: str | None
game_id: int | None
pokemon: list[BossPokemonResponse] = []
@@ -54,6 +55,7 @@ class BossBattleCreate(CamelModel):
location: str
section: str | None = None
sprite_url: str | None = None
game_id: int | None = None
class BossBattleUpdate(CamelModel):
@@ -68,6 +70,7 @@ class BossBattleUpdate(CamelModel):
location: str | None = None
section: str | None = None
sprite_url: str | None = None
game_id: int | None = None
class BossPokemonInput(CamelModel):

View File

@@ -215,4 +215,5 @@ class BulkBossItem(BaseModel):
location: str
section: str | None = None
sprite_url: str | None = None
game_slug: str | None = None
pokemon: list[BulkBossPokemonItem] = []

View File

@@ -107,8 +107,9 @@
"order": 8,
"after_route_name": null,
"location": "Opelucid Gym",
"section": "Black",
"section": null,
"sprite_url": "/boss-sprites/black/drayden.png",
"game_slug": "black",
"pokemon": []
},
{
@@ -121,8 +122,9 @@
"order": 9,
"after_route_name": null,
"location": "Opelucid Gym",
"section": "White",
"section": null,
"sprite_url": "/boss-sprites/black/iris.png",
"game_slug": "white",
"pokemon": []
},
{

View File

@@ -51,8 +51,9 @@
"order": 4,
"after_route_name": null,
"location": "Stow-on-Side Stadium",
"section": "Sword",
"section": null,
"sprite_url": "/boss-sprites/sword/bea.png",
"game_slug": "sword",
"pokemon": []
},
{
@@ -65,8 +66,9 @@
"order": 5,
"after_route_name": null,
"location": "Stow-on-Side Stadium",
"section": "Shield",
"section": null,
"sprite_url": "/boss-sprites/sword/allister.png",
"game_slug": "shield",
"pokemon": []
},
{
@@ -93,8 +95,9 @@
"order": 7,
"after_route_name": null,
"location": "Circhester Stadium",
"section": "Sword",
"section": null,
"sprite_url": "/boss-sprites/sword/gordie.png",
"game_slug": "sword",
"pokemon": []
},
{
@@ -107,8 +110,9 @@
"order": 8,
"after_route_name": null,
"location": "Circhester Stadium",
"section": "Shield",
"section": null,
"sprite_url": "/boss-sprites/sword/melony.png",
"game_slug": "shield",
"pokemon": []
},
{

View File

@@ -239,6 +239,7 @@ async def upsert_bosses(
bosses: list[dict],
dex_to_id: dict[int, int],
route_name_to_id: dict[str, int] | None = None,
slug_to_game_id: dict[str, int] | None = None,
) -> int:
"""Upsert boss battles for a version group, return count of bosses upserted."""
count = 0
@@ -253,6 +254,16 @@ async def upsert_bosses(
f" Warning: route '{after_route_name}' not found for boss '{boss['name']}'"
)
# Resolve game_slug to game_id
game_id = None
game_slug = boss.get("game_slug")
if game_slug and slug_to_game_id:
game_id = slug_to_game_id.get(game_slug)
if game_id is None:
print(
f" Warning: game '{game_slug}' not found for boss '{boss['name']}'"
)
# Upsert the boss battle on (version_group_id, order) conflict
stmt = (
insert(BossBattle)
@@ -269,6 +280,7 @@ async def upsert_bosses(
location=boss["location"],
section=boss.get("section"),
sprite_url=boss.get("sprite_url"),
game_id=game_id,
)
.on_conflict_do_update(
constraint="uq_boss_battles_version_group_order",
@@ -283,6 +295,7 @@ async def upsert_bosses(
"location": boss["location"],
"section": boss.get("section"),
"sprite_url": boss.get("sprite_url"),
"game_id": game_id,
},
)
.returning(BossBattle.id)

View File

@@ -160,7 +160,7 @@ async def seed():
route_name_to_id = route_maps_by_vg.get(vg_id, {})
boss_count = await upsert_bosses(
session, vg_id, bosses_data, dex_to_id, route_name_to_id
session, vg_id, bosses_data, dex_to_id, route_name_to_id, slug_to_id
)
total_bosses += boss_count
print(f" {vg_slug}: {boss_count} bosses")
@@ -491,6 +491,7 @@ async def _export_bosses(session: AsyncSession, vg_data: dict):
.options(
selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon),
selectinload(BossBattle.after_route),
selectinload(BossBattle.game),
)
.order_by(BossBattle.order)
)
@@ -525,30 +526,31 @@ async def _export_bosses(session: AsyncSession, vg_data: dict):
downloaded_sprites,
)
data.append(
{
"name": b.name,
"boss_type": b.boss_type,
"specialty_type": b.specialty_type,
"badge_name": b.badge_name,
"badge_image_url": badge_image_url,
"level_cap": b.level_cap,
"order": b.order,
"after_route_name": b.after_route.name if b.after_route else None,
"location": b.location,
"section": b.section,
"sprite_url": sprite_url,
"pokemon": [
{
"pokeapi_id": bp.pokemon.pokeapi_id,
"pokemon_name": bp.pokemon.name,
"level": bp.level,
"order": bp.order,
}
for bp in sorted(b.pokemon, key=lambda p: p.order)
],
}
)
boss_dict: dict = {
"name": b.name,
"boss_type": b.boss_type,
"specialty_type": b.specialty_type,
"badge_name": b.badge_name,
"badge_image_url": badge_image_url,
"level_cap": b.level_cap,
"order": b.order,
"after_route_name": b.after_route.name if b.after_route else None,
"location": b.location,
"section": b.section,
"sprite_url": sprite_url,
"pokemon": [
{
"pokeapi_id": bp.pokemon.pokeapi_id,
"pokemon_name": bp.pokemon.name,
"level": bp.level,
"order": bp.order,
}
for bp in sorted(b.pokemon, key=lambda p: p.order)
],
}
if b.game_id:
boss_dict["game_slug"] = b.game.slug
data.append(boss_dict)
_write_json(f"{first_game_slug}-bosses.json", data)
exported += 1