Add genlocke admin panel with CRUD endpoints and UI

Backend: PATCH/DELETE genlocke, POST/DELETE legs with order
re-numbering. Frontend: admin list page with status filter,
detail page with inline editing, legs table, and stats display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Julian Tabel
2026-02-09 10:51:47 +01:00
parent 08f6857451
commit a81a17c485
11 changed files with 685 additions and 12 deletions

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy import delete as sa_delete, func, select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -12,12 +12,14 @@ from app.models.genlocke import Genlocke, GenlockeLeg
from app.models.nuzlocke_run import NuzlockeRun
from app.models.pokemon import Pokemon
from app.schemas.genlocke import (
AddLegRequest,
GenlockeCreate,
GenlockeDetailResponse,
GenlockeLegDetailResponse,
GenlockeListItem,
GenlockeResponse,
GenlockeStatsResponse,
GenlockeUpdate,
RetiredPokemonResponse,
)
from app.services.families import build_families
@@ -393,3 +395,150 @@ async def get_retired_families(
retired_pokemon_ids=sorted(cumulative),
by_leg=by_leg,
)
@router.patch("/{genlocke_id}", response_model=GenlockeResponse)
async def update_genlocke(
genlocke_id: int,
data: GenlockeUpdate,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(
select(Genlocke)
.where(Genlocke.id == genlocke_id)
.options(
selectinload(Genlocke.legs).selectinload(GenlockeLeg.game),
)
)
genlocke = result.scalar_one_or_none()
if genlocke is None:
raise HTTPException(status_code=404, detail="Genlocke not found")
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",
)
for field, value in update_data.items():
setattr(genlocke, field, value)
await session.commit()
await session.refresh(genlocke)
return genlocke
@router.delete("/{genlocke_id}", status_code=204)
async def delete_genlocke(
genlocke_id: int,
session: AsyncSession = Depends(get_session),
):
genlocke = await session.get(Genlocke, genlocke_id)
if genlocke is None:
raise HTTPException(status_code=404, detail="Genlocke not found")
# Unlink runs from legs so runs are preserved
await session.execute(
sa_update(GenlockeLeg)
.where(GenlockeLeg.genlocke_id == genlocke_id)
.values(run_id=None)
)
# 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)
)
await session.delete(genlocke)
await session.commit()
@router.post(
"/{genlocke_id}/legs",
response_model=GenlockeResponse,
status_code=201,
)
async def add_leg(
genlocke_id: int,
data: AddLegRequest,
session: AsyncSession = Depends(get_session),
):
genlocke = await session.get(Genlocke, genlocke_id)
if genlocke is None:
raise HTTPException(status_code=404, detail="Genlocke not found")
# Validate game exists
game = await session.get(Game, data.game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
# Find max leg_order
max_order_result = await session.execute(
select(func.max(GenlockeLeg.leg_order)).where(
GenlockeLeg.genlocke_id == genlocke_id
)
)
max_order = max_order_result.scalar() or 0
leg = GenlockeLeg(
genlocke_id=genlocke_id,
game_id=data.game_id,
leg_order=max_order + 1,
)
session.add(leg)
await session.commit()
# Reload with relationships
result = await session.execute(
select(Genlocke)
.where(Genlocke.id == genlocke_id)
.options(
selectinload(Genlocke.legs).selectinload(GenlockeLeg.game),
)
)
return result.scalar_one()
@router.delete("/{genlocke_id}/legs/{leg_id}", status_code=204)
async def remove_leg(
genlocke_id: int,
leg_id: int,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(
select(GenlockeLeg).where(
GenlockeLeg.id == leg_id,
GenlockeLeg.genlocke_id == genlocke_id,
)
)
leg = result.scalar_one_or_none()
if leg is None:
raise HTTPException(status_code=404, detail="Leg not found")
if leg.run_id is not None:
raise HTTPException(
status_code=400,
detail="Cannot remove a leg that has a linked run. Delete or unlink the run first.",
)
removed_order = leg.leg_order
await session.delete(leg)
# Re-number remaining legs to keep leg_order contiguous
remaining_result = await session.execute(
select(GenlockeLeg)
.where(
GenlockeLeg.genlocke_id == genlocke_id,
GenlockeLeg.leg_order > removed_order,
)
.order_by(GenlockeLeg.leg_order)
)
for remaining_leg in remaining_result.scalars().all():
remaining_leg.leg_order -= 1
await session.commit()