Initial: Pokedexter TCG-Collector webapp

This commit is contained in:
2026-07-01 21:10:06 +02:00
commit c0952a3bd3
27 changed files with 1684 additions and 0 deletions
View File
+155
View File
@@ -0,0 +1,155 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from ..database import get_db
from ..models import Set, Card, User
from ..auth import require_admin
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")
class SetForm(BaseModel):
code: str
name: str
release_date: str | None = None
description: str | None = None
class CardForm(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
# --- Admin 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})
@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})
@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})
# --- CRUD: Sets ---
@router.post("/api/admin/sets")
async def create_set(
data: SetForm,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
):
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)
db.add(s)
await db.commit()
await db.refresh(s)
return {"ok": True, "id": s.id, "code": s.code}
@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))
sets = result.scalars().all()
return [{"id": s.id, "code": s.code, "name": s.name, "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)
if not s:
raise HTTPException(status_code=404, detail="Set ikke fundet")
await db.delete(s)
await db.commit()
return {"ok": True}
# --- CRUD: 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),
):
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
]
@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)
if not card:
raise HTTPException(status_code=404, detail="Kort ikke fundet")
await db.delete(card)
await db.commit()
return {"ok": True}
+87
View File
@@ -0,0 +1,87 @@
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from ..database import get_db
from ..models import User
from ..auth import verify_password, create_token, hash_password, require_admin
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
class LoginForm(BaseModel):
username: str
password: str
class CreateUserForm(BaseModel):
username: str
password: str
role: str = "user"
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
return templates.TemplateResponse("login.html", {"request": request})
@router.post("/api/login")
async def login(data: LoginForm, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.username == data.username))
user = result.scalar_one_or_none()
if not user or not verify_password(data.password, user.password_hash):
raise HTTPException(status_code=401, detail="Forkert brugernavn eller adgangskode")
token = create_token(user.username, user.role)
resp = RedirectResponse(url="/", status_code=302)
resp.set_cookie(
key="token", value=token, httponly=True, max_age=30 * 24 * 3600,
samesite="lax"
)
return resp
@router.post("/api/logout")
async def logout():
resp = RedirectResponse(url="/login", status_code=302)
resp.delete_cookie("token")
return resp
# --- Admin-only: user management ---
@router.post("/api/admin/users")
async def create_user(
data: CreateUserForm,
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))
users = result.scalars().all()
return [
{"id": u.id, "username": u.username, "role": u.role, "created_at": str(u.created_at)}
for u in users
]
+113
View File
@@ -0,0 +1,113 @@
from fastapi import APIRouter, Depends, Request, Query
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..database import get_db
from ..models import Set, Card
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@router.get("/", response_class=HTMLResponse)
async def index(request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Set).order_by(Set.code))
sets = result.scalars().all()
return templates.TemplateResponse("index.html", {"request": request, "sets": sets})
@router.get("/sets", response_class=HTMLResponse)
async def list_sets(request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Set).order_by(Set.code))
sets = result.scalars().all()
return templates.TemplateResponse("sets.html", {"request": request, "sets": sets})
@router.get("/sets/{set_code}", response_class=HTMLResponse)
async def set_detail(request: Request, set_code: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Set).where(Set.code == set_code.upper()).options(selectinload(Set.cards))
)
s = result.scalar_one_or_none()
if not s:
return templates.TemplateResponse("404.html", {"request": request}, status_code=404)
return templates.TemplateResponse("set_detail.html", {"request": request, "set": s})
@router.get("/search", response_class=HTMLResponse)
async def search_page(request: Request):
return templates.TemplateResponse("search.html", {"request": request})
@router.get("/api/search")
async def search_cards(
q: str = Query(""),
db: AsyncSession = Depends(get_db),
):
"""Search cards by full code (e.g. CRI-068) or by name."""
q = q.strip()
# Try full-code search: CODE-NUMBER
if "-" in q:
parts = q.split("-", 1)
code_part = parts[0].strip().upper()
num_part = parts[1].strip().lstrip("0") # remove leading zeros
stmt = (
select(Card)
.join(Card.set)
.where(Set.code == code_part, Card.number == num_part)
.options(selectinload(Card.set))
)
else:
# Search by name
stmt = (
select(Card)
.join(Card.set)
.where(Card.name.ilike(f"%{q}%"))
.options(selectinload(Card.set))
.limit(50)
)
result = await db.execute(stmt)
cards = result.scalars().all()
return [
{
"id": c.id,
"full_code": c.full_code,
"name": c.name,
"type": c.type or "",
"rarity": c.rarity or "",
"set_name": c.set.name,
}
for c in cards
]
@router.get("/api/sets")
async def api_list_sets(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Set).order_by(Set.code))
sets = result.scalars().all()
return [
{"code": s.code, "name": s.name, "release_date": s.release_date or "",
"card_count": len(s.cards)}
for s in sets
]
@router.get("/api/sets/{set_code}/cards")
async def api_set_cards(set_code: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Set).where(Set.code == set_code.upper()).options(selectinload(Set.cards))
)
s = result.scalar_one_or_none()
if not s:
return []
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
]