Fix: eagerload series.sets i admin og API (MissingGreenlet i async mode)
This commit is contained in:
+316
-93
@@ -1,155 +1,378 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form, status
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import Set, Card, User
|
||||
from ..auth import require_admin
|
||||
from ..models import User, Serie, Set, Card
|
||||
from ..auth import require_admin, hash_password
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def require_admin_html(request: Request) -> User:
|
||||
"""Admin check from cookie-based auth (HTML pages)."""
|
||||
user = request.state.user
|
||||
if not user or user.role != "admin":
|
||||
raise HTTPException(status_code=401, detail="Admin login required")
|
||||
return user
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
UPLOAD_DIR = Path("app/static/images")
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
class SetForm(BaseModel):
|
||||
|
||||
# ─── Pydantic schemas ───────────────────────────────────────────────
|
||||
|
||||
class SerieCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
class SetCreate(BaseModel):
|
||||
code: str
|
||||
name: str
|
||||
release_date: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class CardForm(BaseModel):
|
||||
class CardCreate(BaseModel):
|
||||
number: str
|
||||
name: str
|
||||
type: str | None = None
|
||||
rarity: str | None = None
|
||||
hp: int | None = None
|
||||
stage: str | None = None
|
||||
description: str | None = None
|
||||
image_url: str | None = None
|
||||
image: str | None = None # URL
|
||||
|
||||
class NewUserForm(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
role: str = "user"
|
||||
|
||||
|
||||
# --- Admin pages ---
|
||||
# ─── Admin HTML pages ───────────────────────────────────────────────
|
||||
|
||||
@router.get("/admin", response_class=HTMLResponse)
|
||||
async def admin_dashboard(request: Request, user: User = Depends(require_admin_html)):
|
||||
return templates.TemplateResponse("admin/dashboard.html", {"request": request, "user": user})
|
||||
async def admin_dashboard(request: Request, user: User = Depends(require_admin)):
|
||||
return templates.TemplateResponse("admin/dashboard.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/admin/series", response_class=HTMLResponse)
|
||||
async def admin_series(request: Request, db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_admin)):
|
||||
result = await db.execute(select(Serie).options(selectinload(Serie.sets)).order_by(Serie.name))
|
||||
series = result.scalars().all()
|
||||
return templates.TemplateResponse("admin/series.html", {"request": request, "series": series})
|
||||
|
||||
|
||||
@router.get("/admin/sets", response_class=HTMLResponse)
|
||||
async def admin_sets(request: Request, user: User = Depends(require_admin_html)):
|
||||
return templates.TemplateResponse("admin/sets.html", {"request": request, "user": user})
|
||||
async def admin_sets_page(request: Request, db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_admin)):
|
||||
result = await db.execute(
|
||||
select(Set).options(selectinload(Set.serie), selectinload(Set.cards)).order_by(Set.code)
|
||||
)
|
||||
sets = result.scalars().all()
|
||||
series_result = await db.execute(select(Serie).order_by(Serie.name))
|
||||
series = series_result.scalars().all()
|
||||
return templates.TemplateResponse("admin/sets.html", {"request": request, "sets": sets, "series": series})
|
||||
|
||||
|
||||
@router.get("/admin/cards/{set_code}", response_class=HTMLResponse)
|
||||
async def admin_cards(request: Request, set_code: str, user: User = Depends(require_admin_html)):
|
||||
return templates.TemplateResponse("admin/cards.html", {"request": request, "user": user, "set_code": set_code})
|
||||
async def admin_cards_page(request: Request, set_code: str, db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_admin)):
|
||||
result = await db.execute(
|
||||
select(Set).where(Set.code == set_code.upper()).options(
|
||||
selectinload(Set.cards), selectinload(Set.serie)
|
||||
)
|
||||
)
|
||||
s = result.scalar_one_or_none()
|
||||
if not s:
|
||||
return templates.TemplateResponse("404.html", {"request": request}, status_code=404)
|
||||
return templates.TemplateResponse("admin/cards.html", {"request": request, "set": s})
|
||||
|
||||
# ─── Users (admin) ─────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/admin/users")
|
||||
async def create_user(data: NewUserForm, db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin)):
|
||||
exists = await db.execute(select(User).where(User.username == data.username))
|
||||
if exists.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Bruger findes allerede")
|
||||
user = User(username=data.username, password_hash=hash_password(data.password), role=data.role)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
return {"ok": True, "username": user.username, "role": user.role}
|
||||
|
||||
@router.get("/api/admin/users")
|
||||
async def list_users(db: AsyncSession = Depends(get_db), _: User = Depends(require_admin)):
|
||||
result = await db.execute(select(User).order_by(User.username))
|
||||
return [{"id": u.id, "username": u.username, "role": u.role, "created_at": str(u.created_at)}
|
||||
for u in result.scalars().all()]
|
||||
|
||||
|
||||
# --- CRUD: Sets ---
|
||||
# ─── API: Series ────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/admin/sets")
|
||||
async def create_set(
|
||||
data: SetForm,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
):
|
||||
@router.post("/api/admin/series")
|
||||
async def create_serie(data: SerieCreate, db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin)):
|
||||
result = await db.execute(select(Serie).where(Serie.name == data.name))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Serie findes allerede")
|
||||
serie = Serie(name=data.name, description=data.description)
|
||||
db.add(serie)
|
||||
await db.commit()
|
||||
return {"ok": True, "id": serie.id, "name": serie.name}
|
||||
|
||||
@router.get("/api/admin/series")
|
||||
async def list_series(db: AsyncSession = Depends(get_db), _: User = Depends(require_admin)):
|
||||
result = await db.execute(select(Serie).options(selectinload(Serie.sets)).order_by(Serie.name))
|
||||
return [{"id": s.id, "name": s.name, "description": s.description or "",
|
||||
"set_count": len(s.sets)} for s in result.scalars().all()]
|
||||
|
||||
@router.delete("/api/admin/series/{serie_id}")
|
||||
async def delete_serie(serie_id: int, db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin)):
|
||||
result = await db.execute(select(Serie).where(Serie.id == serie_id))
|
||||
s = result.scalar_one_or_none()
|
||||
if not s:
|
||||
raise HTTPException(status_code=404, detail="Serie ikke fundet")
|
||||
await db.delete(s)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/api/admin/series/import")
|
||||
async def import_series(file: UploadFile = File(...), db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin)):
|
||||
content = await file.read()
|
||||
text = content.decode("utf-8-sig")
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
created = 0
|
||||
errors = []
|
||||
for i, row in enumerate(reader, start=2):
|
||||
name = row.get("name", "").strip()
|
||||
if not name:
|
||||
errors.append(f"Linje {i}: mangler navn")
|
||||
continue
|
||||
try:
|
||||
exists = await db.execute(select(Serie).where(Serie.name == name))
|
||||
if exists.scalar_one_or_none():
|
||||
errors.append(f"Linje {i}: '{name}' findes allerede")
|
||||
continue
|
||||
serie = Serie(name=name, description=row.get("description", "").strip() or None)
|
||||
db.add(serie)
|
||||
created += 1
|
||||
except Exception as e:
|
||||
errors.append(f"Linje {i}: {str(e)}")
|
||||
await db.commit()
|
||||
return {"ok": True, "created": created, "errors": errors}
|
||||
|
||||
|
||||
# ─── API: Sets ──────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/admin/series/{serie_name}/sets")
|
||||
async def create_set(serie_name: str, data: SetCreate, db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin)):
|
||||
result = await db.execute(select(Serie).where(Serie.name == serie_name))
|
||||
serie = result.scalar_one_or_none()
|
||||
if not serie:
|
||||
raise HTTPException(status_code=404, detail="Serie ikke fundet")
|
||||
exists = await db.execute(select(Set).where(Set.code == data.code.upper()))
|
||||
if exists.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail=f"Set '{data.code.upper()}' findes allerede")
|
||||
|
||||
s = Set(code=data.code.upper(), name=data.name, release_date=data.release_date, description=data.description)
|
||||
raise HTTPException(status_code=400, detail="Set-kode findes allerede")
|
||||
s = Set(serie_id=serie.id, code=data.code.upper(), name=data.name,
|
||||
release_date=data.release_date, description=data.description)
|
||||
db.add(s)
|
||||
await db.commit()
|
||||
await db.refresh(s)
|
||||
return {"ok": True, "id": s.id, "code": s.code}
|
||||
return {"ok": True, "id": s.id, "code": s.code, "name": s.name}
|
||||
|
||||
@router.get("/api/admin/series/{serie_name}/sets")
|
||||
async def list_sets(serie_name: str, db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin)):
|
||||
result = await db.execute(
|
||||
select(Serie).where(Serie.name == serie_name).options(selectinload(Serie.sets))
|
||||
)
|
||||
serie = result.scalar_one_or_none()
|
||||
if not serie:
|
||||
raise HTTPException(status_code=404, detail="Serie ikke fundet")
|
||||
return [{"id": s.id, "code": s.code, "name": s.name, "card_count": len(s.cards)}
|
||||
for s in serie.sets]
|
||||
|
||||
@router.get("/api/admin/sets")
|
||||
async def list_sets_admin(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
):
|
||||
result = await db.execute(select(Set).order_by(Set.code))
|
||||
async def list_all_sets(db: AsyncSession = Depends(get_db), _: User = Depends(require_admin)):
|
||||
result = await db.execute(
|
||||
select(Set).options(selectinload(Set.serie), selectinload(Set.cards)).order_by(Set.code)
|
||||
)
|
||||
sets = result.scalars().all()
|
||||
return [{"id": s.id, "code": s.code, "name": s.name, "release_date": s.release_date or ""} for s in sets]
|
||||
|
||||
return [{"id": s.id, "code": s.code, "name": s.name, "serie": s.serie.name,
|
||||
"card_count": len(s.cards), "release_date": s.release_date or ""}
|
||||
for s in sets]
|
||||
|
||||
@router.delete("/api/admin/sets/{set_id}")
|
||||
async def delete_set(
|
||||
set_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
):
|
||||
s = await db.get(Set, set_id)
|
||||
async def delete_set(set_id: int, db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin)):
|
||||
result = await db.execute(select(Set).where(Set.id == set_id))
|
||||
s = result.scalar_one_or_none()
|
||||
if not s:
|
||||
raise HTTPException(status_code=404, detail="Set ikke fundet")
|
||||
await db.delete(s)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/api/admin/sets/import")
|
||||
async def import_sets(file: UploadFile = File(...), db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin)):
|
||||
content = await file.read()
|
||||
text = content.decode("utf-8-sig")
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
created = 0
|
||||
errors = []
|
||||
for i, row in enumerate(reader, start=2):
|
||||
code = row.get("code", "").strip().upper()
|
||||
name = row.get("name", "").strip()
|
||||
serie_name = row.get("serie", "").strip()
|
||||
if not code or not name or not serie_name:
|
||||
errors.append(f"Linje {i}: mangler kode, navn eller serie")
|
||||
continue
|
||||
try:
|
||||
serie = await db.execute(select(Serie).where(Serie.name == serie_name))
|
||||
serie = serie.scalar_one_or_none()
|
||||
if not serie:
|
||||
errors.append(f"Linje {i}: serie '{serie_name}' ikke fundet")
|
||||
continue
|
||||
exists = await db.execute(select(Set).where(Set.code == code))
|
||||
if exists.scalar_one_or_none():
|
||||
errors.append(f"Linje {i}: kode '{code}' findes allerede")
|
||||
continue
|
||||
s = Set(serie_id=serie.id, code=code, name=name,
|
||||
release_date=row.get("release_date", "").strip() or None,
|
||||
description=row.get("description", "").strip() or None)
|
||||
db.add(s)
|
||||
created += 1
|
||||
except Exception as e:
|
||||
errors.append(f"Linje {i}: {str(e)}")
|
||||
await db.commit()
|
||||
return {"ok": True, "created": created, "errors": errors}
|
||||
|
||||
# --- CRUD: Cards ---
|
||||
|
||||
# ─── API: Cards ─────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/admin/sets/{set_code}/cards")
|
||||
async def create_card(
|
||||
set_code: str,
|
||||
data: CardForm,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
):
|
||||
result = await db.execute(select(Set).where(Set.code == set_code.upper()))
|
||||
s = result.scalar_one_or_none()
|
||||
if not s:
|
||||
raise HTTPException(status_code=404, detail=f"Set '{set_code.upper()}' ikke fundet")
|
||||
|
||||
card = Card(
|
||||
set_id=s.id, number=data.number, name=data.name,
|
||||
type=data.type, rarity=data.rarity, hp=data.hp,
|
||||
stage=data.stage, description=data.description, image_url=data.image_url,
|
||||
)
|
||||
db.add(card)
|
||||
await db.commit()
|
||||
await db.refresh(card)
|
||||
return {"ok": True, "id": card.id, "full_code": card.full_code}
|
||||
|
||||
|
||||
@router.get("/api/admin/sets/{set_code}/cards")
|
||||
async def list_cards_admin(
|
||||
set_code: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
):
|
||||
async def create_card(set_code: str, data: CardCreate, db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin)):
|
||||
result = await db.execute(select(Set).where(Set.code == set_code.upper()))
|
||||
s = result.scalar_one_or_none()
|
||||
if not s:
|
||||
raise HTTPException(status_code=404, detail="Set ikke fundet")
|
||||
return [
|
||||
{"id": c.id, "number": c.number, "name": c.name, "type": c.type or "",
|
||||
"rarity": c.rarity or "", "hp": c.hp, "full_code": c.full_code}
|
||||
for c in s.cards
|
||||
]
|
||||
card = Card(set_id=s.id, number=data.number, name=data.name)
|
||||
if data.image:
|
||||
card.image_path = data.image
|
||||
db.add(card)
|
||||
await db.commit()
|
||||
return {"ok": True, "id": card.id, "full_code": card.full_code, "name": card.name}
|
||||
|
||||
@router.get("/api/admin/sets/{set_code}/cards")
|
||||
async def list_cards(set_code: str, db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin)):
|
||||
result = await db.execute(
|
||||
select(Set).where(Set.code == set_code.upper()).options(
|
||||
selectinload(Set.cards), selectinload(Set.serie)
|
||||
)
|
||||
)
|
||||
s = result.scalar_one_or_none()
|
||||
if not s:
|
||||
raise HTTPException(status_code=404, detail="Set ikke fundet")
|
||||
return [{"id": c.id, "number": c.number, "name": c.name, "full_code": c.full_code,
|
||||
"image_url": c.image_url} for c in s.cards]
|
||||
|
||||
@router.delete("/api/admin/cards/{card_id}")
|
||||
async def delete_card(
|
||||
card_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
):
|
||||
card = await db.get(Card, card_id)
|
||||
async def delete_card(card_id: int, db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin)):
|
||||
result = await db.execute(select(Card).where(Card.id == card_id))
|
||||
card = result.scalar_one_or_none()
|
||||
if not card:
|
||||
raise HTTPException(status_code=404, detail="Kort ikke fundet")
|
||||
# Slet billedfil hvis lokal
|
||||
if card.image_path and not card.image_path.startswith(("http://", "https://")):
|
||||
fp = Path("app/static") / card.image_path
|
||||
if fp.exists():
|
||||
fp.unlink()
|
||||
await db.delete(card)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/api/admin/cards/import")
|
||||
async def import_cards(file: UploadFile = File(...), db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin)):
|
||||
content = await file.read()
|
||||
text = content.decode("utf-8-sig")
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
created = 0
|
||||
errors = []
|
||||
for i, row in enumerate(reader, start=2):
|
||||
set_code = row.get("set_code", "").strip().upper()
|
||||
number = row.get("number", "").strip()
|
||||
name = row.get("name", "").strip()
|
||||
if not set_code or not number or not name:
|
||||
errors.append(f"Linje {i}: mangler set_code, number eller name")
|
||||
continue
|
||||
try:
|
||||
result = await db.execute(select(Set).where(Set.code == set_code))
|
||||
s = result.scalar_one_or_none()
|
||||
if not s:
|
||||
errors.append(f"Linje {i}: set '{set_code}' ikke fundet")
|
||||
continue
|
||||
image = row.get("image", "").strip() or None
|
||||
card = Card(set_id=s.id, number=number, name=name, image_path=image)
|
||||
db.add(card)
|
||||
created += 1
|
||||
except Exception as e:
|
||||
errors.append(f"Linje {i}: {str(e)}")
|
||||
await db.commit()
|
||||
return {"ok": True, "created": created, "errors": errors}
|
||||
|
||||
@router.post("/api/admin/cards/{card_id}/upload")
|
||||
async def upload_card_image(card_id: int, file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin)):
|
||||
result = await db.execute(
|
||||
select(Card).where(Card.id == card_id).options(
|
||||
selectinload(Card.set).selectinload(Set.serie)
|
||||
)
|
||||
)
|
||||
card = result.scalar_one_or_none()
|
||||
if not card:
|
||||
raise HTTPException(status_code=404, detail="Kort ikke fundet")
|
||||
|
||||
# Bestem filtype
|
||||
ext = os.path.splitext(file.filename or "image.png")[1].lower()
|
||||
if ext not in (".jpg", ".jpeg", ".png", ".gif", ".webp"):
|
||||
raise HTTPException(status_code=400, detail="Kun .jpg, .png, .gif, .webp tilladt")
|
||||
|
||||
serie_name = card.set.serie.name.replace(" ", "_").replace("/", "_")
|
||||
set_code = card.set.code
|
||||
number = card.number
|
||||
folder = UPLOAD_DIR / serie_name / set_code
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filename = f"{number}{ext}"
|
||||
filepath = folder / filename
|
||||
content = await file.read()
|
||||
filepath.write_bytes(content)
|
||||
|
||||
# Gem relativ sti
|
||||
rel_path = f"images/{serie_name}/{set_code}/{filename}"
|
||||
card.image_path = rel_path
|
||||
await db.commit()
|
||||
|
||||
return {"ok": True, "image_url": f"/static/{rel_path}", "path": rel_path}
|
||||
|
||||
|
||||
# ─── CSV Templates ──────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/admin/templates/series.csv", response_class=PlainTextResponse)
|
||||
async def series_csv_template(_: User = Depends(require_admin)):
|
||||
return "name,description\nSword & Shield,The Sword & Shield series\n"
|
||||
|
||||
@router.get("/api/admin/templates/sets.csv", response_class=PlainTextResponse)
|
||||
async def sets_csv_template(_: User = Depends(require_admin)):
|
||||
return "serie,code,name,release_date,description\nSword & Shield,EVS,Evolving Skies,2021-08-27,\n"
|
||||
|
||||
@router.get("/api/admin/templates/cards.csv", response_class=PlainTextResponse)
|
||||
async def cards_csv_template(_: User = Depends(require_admin)):
|
||||
return "set_code,number,name,image\nEVS,050,Raichu,\n"
|
||||
Reference in New Issue
Block a user