Enforce run ownership and show owner info #74

Merged
TheFurya merged 6 commits from feature/enforce-run-ownership-on-all-mutation-endpoints into develop 2026-03-22 09:16:59 +01:00
6 changed files with 171 additions and 8 deletions
Showing only changes of commit a3f332f82b - Show all commits

View File

@@ -0,0 +1,43 @@
---
# nuzlocke-tracker-2fp1
title: Show owner info in admin pages
status: in-progress
type: feature
priority: normal
created_at: 2026-03-21T12:18:51Z
updated_at: 2026-03-21T12:37:36Z
parent: nuzlocke-tracker-wwnu
---
## Problem
Admin pages (`AdminRuns.tsx`, `AdminGenlockes.tsx`) don't show which user owns each run or genlocke. This makes it hard for admins to manage content.
## Approach
### Backend
- The `/api/runs` list endpoint already returns run data — verify it includes `owner` (id + email). If not, add it to the response schema.
- For genlockes, ownership is inferred from the first leg's run owner. Add an `owner` field to the genlocke list response that resolves from the first leg's run.
### Frontend
- `AdminRuns.tsx`: Add an "Owner" column showing the owner's email (or "No owner" for legacy runs)
- `AdminGenlockes.tsx`: Add an "Owner" column showing the inferred owner from the first leg's run
- Add owner filter dropdown to both pages
## Files to modify
- `backend/src/app/api/runs.py` — verify owner is included in list response
- `backend/src/app/api/genlockes.py` — add owner resolution to list endpoint
- `backend/src/app/schemas/genlocke.py` — add owner field to `GenlockeListItem`
- `frontend/src/pages/admin/AdminRuns.tsx` — add Owner column + filter
- `frontend/src/pages/admin/AdminGenlockes.tsx` — add Owner column + filter
- `frontend/src/types/game.ts` — update types if needed
## Checklist
- [x] Verify runs list API includes owner info; add if missing
- [x] Add owner resolution to genlocke list endpoint (from first leg's run)
- [x] Update `GenlockeListItem` schema to include owner
- [x] Add Owner column to `AdminRuns.tsx`
- [x] Add Owner column to `AdminGenlockes.tsx`
- [x] Add owner filter to both admin pages

View File

@@ -28,6 +28,7 @@ from app.schemas.genlocke import (
GenlockeLegDetailResponse, GenlockeLegDetailResponse,
GenlockeLineageResponse, GenlockeLineageResponse,
GenlockeListItem, GenlockeListItem,
GenlockeOwnerResponse,
GenlockeResponse, GenlockeResponse,
GenlockeStatsResponse, GenlockeStatsResponse,
GenlockeUpdate, GenlockeUpdate,
@@ -81,7 +82,9 @@ async def list_genlockes(session: AsyncSession = Depends(get_session)):
result = await session.execute( result = await session.execute(
select(Genlocke) select(Genlocke)
.options( .options(
selectinload(Genlocke.legs).selectinload(GenlockeLeg.run), selectinload(Genlocke.legs)
.selectinload(GenlockeLeg.run)
.selectinload(NuzlockeRun.owner),
) )
.order_by(Genlocke.created_at.desc()) .order_by(Genlocke.created_at.desc())
) )
@@ -91,6 +94,16 @@ async def list_genlockes(session: AsyncSession = Depends(get_session)):
for g in genlockes: for g in genlockes:
completed_legs = 0 completed_legs = 0
current_leg_order = None current_leg_order = None
owner = None
# Find first leg (leg_order == 1) to get owner
first_leg = next((leg for leg in g.legs if leg.leg_order == 1), None)
if first_leg and first_leg.run and first_leg.run.owner:
owner = GenlockeOwnerResponse(
id=first_leg.run.owner.id,
display_name=first_leg.run.owner.display_name,
)
for leg in g.legs: for leg in g.legs:
if leg.run and leg.run.status == "completed": if leg.run and leg.run.status == "completed":
completed_legs += 1 completed_legs += 1
@@ -106,6 +119,7 @@ async def list_genlockes(session: AsyncSession = Depends(get_session)):
total_legs=len(g.legs), total_legs=len(g.legs),
completed_legs=completed_legs, completed_legs=completed_legs,
current_leg_order=current_leg_order, current_leg_order=current_leg_order,
owner=owner,
) )
) )
return items return items

View File

@@ -1,10 +1,16 @@
from datetime import datetime from datetime import datetime
from uuid import UUID
from app.schemas.base import CamelModel from app.schemas.base import CamelModel
from app.schemas.game import GameResponse from app.schemas.game import GameResponse
from app.schemas.pokemon import PokemonResponse from app.schemas.pokemon import PokemonResponse
class GenlockeOwnerResponse(CamelModel):
id: UUID
display_name: str | None = None
class GenlockeCreate(CamelModel): class GenlockeCreate(CamelModel):
name: str name: str
game_ids: list[int] game_ids: list[int]
@@ -92,6 +98,7 @@ class GenlockeListItem(CamelModel):
total_legs: int total_legs: int
completed_legs: int completed_legs: int
current_leg_order: int | None = None current_leg_order: int | None = None
owner: GenlockeOwnerResponse | None = None
class GenlockeDetailResponse(CamelModel): class GenlockeDetailResponse(CamelModel):

View File

@@ -13,14 +13,46 @@ export function AdminGenlockes() {
const [deleting, setDeleting] = useState<GenlockeListItem | null>(null) const [deleting, setDeleting] = useState<GenlockeListItem | null>(null)
const [statusFilter, setStatusFilter] = useState('') const [statusFilter, setStatusFilter] = useState('')
const [ownerFilter, setOwnerFilter] = useState('')
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!statusFilter) return genlockes let result = genlockes
return genlockes.filter((g) => g.status === statusFilter) if (statusFilter) result = result.filter((g) => g.status === statusFilter)
}, [genlockes, statusFilter]) if (ownerFilter) {
if (ownerFilter === '__none__') {
result = result.filter((g) => !g.owner)
} else {
result = result.filter((g) => g.owner?.id === ownerFilter)
}
}
return result
}, [genlockes, statusFilter, ownerFilter])
const genlockeOwners = useMemo(() => {
const owners = new Map<string, string>()
let hasUnowned = false
for (const g of genlockes) {
if (g.owner) {
owners.set(g.owner.id, g.owner.displayName ?? g.owner.id)
} else {
hasUnowned = true
}
}
const sorted = [...owners.entries()].sort((a, b) => a[1].localeCompare(b[1]))
return { owners: sorted, hasUnowned }
}, [genlockes])
const columns: Column<GenlockeListItem>[] = [ const columns: Column<GenlockeListItem>[] = [
{ header: 'Name', accessor: (g) => g.name, sortKey: (g) => g.name }, { header: 'Name', accessor: (g) => g.name, sortKey: (g) => g.name },
{
header: 'Owner',
accessor: (g) => (
<span className={g.owner ? '' : 'text-text-tertiary'}>
{g.owner?.displayName ?? g.owner?.id ?? 'No owner'}
</span>
),
sortKey: (g) => g.owner?.displayName ?? g.owner?.id ?? '',
},
{ {
header: 'Status', header: 'Status',
accessor: (g) => ( accessor: (g) => (
@@ -67,9 +99,25 @@ export function AdminGenlockes() {
<option value="completed">Completed</option> <option value="completed">Completed</option>
<option value="failed">Failed</option> <option value="failed">Failed</option>
</select> </select>
{statusFilter && ( <select
value={ownerFilter}
onChange={(e) => setOwnerFilter(e.target.value)}
className="px-3 py-2 border rounded-md bg-surface-2 border-border-default"
>
<option value="">All owners</option>
{genlockeOwners.hasUnowned && <option value="__none__">No owner</option>}
{genlockeOwners.owners.map(([id, name]) => (
<option key={id} value={id}>
{name}
</option>
))}
</select>
{(statusFilter || ownerFilter) && (
<button <button
onClick={() => setStatusFilter('')} onClick={() => {
setStatusFilter('')
setOwnerFilter('')
}}
className="text-sm text-text-tertiary hover:text-text-primary" className="text-sm text-text-tertiary hover:text-text-primary"
> >
Clear filters Clear filters

View File

@@ -13,6 +13,7 @@ export function AdminRuns() {
const [deleting, setDeleting] = useState<NuzlockeRun | null>(null) const [deleting, setDeleting] = useState<NuzlockeRun | null>(null)
const [statusFilter, setStatusFilter] = useState('') const [statusFilter, setStatusFilter] = useState('')
const [gameFilter, setGameFilter] = useState('') const [gameFilter, setGameFilter] = useState('')
const [ownerFilter, setOwnerFilter] = useState('')
const gameMap = useMemo(() => new Map(games.map((g) => [g.id, g.name])), [games]) const gameMap = useMemo(() => new Map(games.map((g) => [g.id, g.name])), [games])
@@ -20,8 +21,15 @@ export function AdminRuns() {
let result = runs let result = runs
if (statusFilter) result = result.filter((r) => r.status === statusFilter) if (statusFilter) result = result.filter((r) => r.status === statusFilter)
if (gameFilter) result = result.filter((r) => r.gameId === Number(gameFilter)) if (gameFilter) result = result.filter((r) => r.gameId === Number(gameFilter))
if (ownerFilter) {
if (ownerFilter === '__none__') {
result = result.filter((r) => !r.owner)
} else {
result = result.filter((r) => r.owner?.id === ownerFilter)
}
}
return result return result
}, [runs, statusFilter, gameFilter]) }, [runs, statusFilter, gameFilter, ownerFilter])
const runGames = useMemo( const runGames = useMemo(
() => () =>
@@ -33,6 +41,20 @@ export function AdminRuns() {
[runs, gameMap] [runs, gameMap]
) )
const runOwners = useMemo(() => {
const owners = new Map<string, string>()
let hasUnowned = false
for (const r of runs) {
if (r.owner) {
owners.set(r.owner.id, r.owner.displayName ?? r.owner.id)
} else {
hasUnowned = true
}
}
const sorted = [...owners.entries()].sort((a, b) => a[1].localeCompare(b[1]))
return { owners: sorted, hasUnowned }
}, [runs])
const columns: Column<NuzlockeRun>[] = [ const columns: Column<NuzlockeRun>[] = [
{ header: 'Run Name', accessor: (r) => r.name, sortKey: (r) => r.name }, { header: 'Run Name', accessor: (r) => r.name, sortKey: (r) => r.name },
{ {
@@ -40,6 +62,15 @@ export function AdminRuns() {
accessor: (r) => gameMap.get(r.gameId) ?? `Game #${r.gameId}`, accessor: (r) => gameMap.get(r.gameId) ?? `Game #${r.gameId}`,
sortKey: (r) => gameMap.get(r.gameId) ?? '', sortKey: (r) => gameMap.get(r.gameId) ?? '',
}, },
{
header: 'Owner',
accessor: (r) => (
<span className={r.owner ? '' : 'text-text-tertiary'}>
{r.owner?.displayName ?? r.owner?.id ?? 'No owner'}
</span>
),
sortKey: (r) => r.owner?.displayName ?? r.owner?.id ?? '',
},
{ {
header: 'Status', header: 'Status',
accessor: (r) => ( accessor: (r) => (
@@ -93,11 +124,25 @@ export function AdminRuns() {
</option> </option>
))} ))}
</select> </select>
{(statusFilter || gameFilter) && ( <select
value={ownerFilter}
onChange={(e) => setOwnerFilter(e.target.value)}
className="px-3 py-2 border rounded-md bg-surface-2 border-border-default"
>
<option value="">All owners</option>
{runOwners.hasUnowned && <option value="__none__">No owner</option>}
{runOwners.owners.map(([id, name]) => (
<option key={id} value={id}>
{name}
</option>
))}
</select>
{(statusFilter || gameFilter || ownerFilter) && (
<button <button
onClick={() => { onClick={() => {
setStatusFilter('') setStatusFilter('')
setGameFilter('') setGameFilter('')
setOwnerFilter('')
}} }}
className="text-sm text-text-tertiary hover:text-text-primary" className="text-sm text-text-tertiary hover:text-text-primary"
> >

View File

@@ -320,6 +320,11 @@ export interface GenlockeStats {
totalLegs: number totalLegs: number
} }
export interface GenlockeOwner {
id: string
displayName: string | null
}
export interface GenlockeListItem { export interface GenlockeListItem {
id: number id: number
name: string name: string
@@ -328,6 +333,7 @@ export interface GenlockeListItem {
totalLegs: number totalLegs: number
completedLegs: number completedLegs: number
currentLegOrder: number | null currentLegOrder: number | null
owner: GenlockeOwner | null
} }
export interface RetiredPokemon { export interface RetiredPokemon {