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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user