From 8361066f2f1f63c5f739cfce84940ec9bf81a83a Mon Sep 17 00:00:00 2001 From: GlennIgen Date: Wed, 1 Jul 2026 22:15:14 +0200 Subject: [PATCH] Fix: eagerload series.sets i admin og API (MissingGreenlet i async mode) --- .gitignore | 4 +- backend/app/main.py | 4 + backend/app/models.py | 40 +- backend/app/routers/admin.py | 409 ++++++++++++++++----- backend/app/routers/auth.py | 43 +-- backend/app/routers/cards.py | 175 ++++++--- backend/app/templates/admin/cards.html | 212 ++++++----- backend/app/templates/admin/dashboard.html | 105 +----- backend/app/templates/admin/series.html | 105 ++++++ backend/app/templates/admin/sets.html | 176 +++++---- backend/app/templates/base.html | 3 +- backend/app/templates/card_detail.html | 56 +++ backend/app/templates/index.html | 25 +- backend/app/templates/login.html | 13 +- backend/app/templates/search.html | 60 +-- backend/app/templates/serie_detail.html | 39 ++ backend/app/templates/series.html | 31 ++ backend/app/templates/set_detail.html | 93 ++--- backend/app/templates/sets.html | 24 +- docker-compose.yml | 12 +- 20 files changed, 1075 insertions(+), 554 deletions(-) create mode 100644 backend/app/templates/admin/series.html create mode 100644 backend/app/templates/card_detail.html create mode 100644 backend/app/templates/serie_detail.html create mode 100644 backend/app/templates/series.html diff --git a/.gitignore b/.gitignore index a62c2b3..4d55389 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ __pycache__/ *.egg-info/ .env .venv/ -venv/ \ No newline at end of file +venv/ +dev/ +*.tar.gz \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index ac41c47..516b228 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,6 +6,7 @@ from fastapi.templating import Jinja2Templates from contextlib import asynccontextmanager from sqlalchemy import select +from pathlib import Path from .database import engine, async_session, Base from .routers import auth, admin, cards from .auth import get_current_user, hash_password @@ -27,6 +28,9 @@ async def lifespan(_app: FastAPI): role="admin", )) await session.commit() + + # Ensure image upload directory exists + Path("app/static/images").mkdir(parents=True, exist_ok=True) yield diff --git a/backend/app/models.py b/backend/app/models.py index 02687a6..2ac54f6 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -9,20 +9,34 @@ class User(Base): id = Column(Integer, primary_key=True, autoincrement=True) username = Column(String(50), unique=True, nullable=False, index=True) password_hash = Column(String(255), nullable=False) - role = Column(String(20), nullable=False, default="user") # "admin" or "user" + role = Column(String(20), nullable=False, default="user") created_at = Column(DateTime(timezone=True), server_default=func.now()) +class Serie(Base): + __tablename__ = "series" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(200), unique=True, nullable=False) # "Sword & Shield" + description = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + sets = relationship("Set", back_populates="serie", cascade="all, delete-orphan", + order_by="Set.code") + + class Set(Base): __tablename__ = "sets" id = Column(Integer, primary_key=True, autoincrement=True) - code = Column(String(10), unique=True, nullable=False, index=True) # e.g. "CRI" - name = Column(String(200), nullable=False) # e.g. "Chaos Rising" + serie_id = Column(Integer, ForeignKey("series.id", ondelete="CASCADE"), nullable=False, index=True) + code = Column(String(10), unique=True, nullable=False, index=True) # "EVS" + name = Column(String(200), nullable=False) # "Evolving Skies" release_date = Column(String(20), nullable=True) description = Column(Text, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) + serie = relationship("Serie", back_populates="sets") cards = relationship("Card", back_populates="set", cascade="all, delete-orphan", order_by="Card.number") @@ -32,17 +46,21 @@ class Card(Base): id = Column(Integer, primary_key=True, autoincrement=True) set_id = Column(Integer, ForeignKey("sets.id", ondelete="CASCADE"), nullable=False, index=True) - number = Column(String(10), nullable=False) # e.g. "068" - name = Column(String(200), nullable=False) # e.g. "Garchomp EX" - type = Column(String(50), nullable=True) # e.g. "Colorless", "Fire" - rarity = Column(String(50), nullable=True) # e.g. "Rare", "Uncommon" - hp = Column(Integer, nullable=True) - stage = Column(String(50), nullable=True) # e.g. "Basic", "Stage 1", "Stage 2" - description = Column(Text, nullable=True) - image_url = Column(String(500), nullable=True) + number = Column(String(10), nullable=False) # "050" + name = Column(String(200), nullable=False) # "Raichu" + image_path = Column(String(500), nullable=True) # URL eller sti "images/.../050.jpg" set = relationship("Set", back_populates="cards") @property def full_code(self): return f"{self.set.code}-{self.number}" + + @property + def image_url(self): + """Returner URL'en for visning — absolut URL eller /static/ relativ sti.""" + if not self.image_path: + return None + if self.image_path.startswith(("http://", "https://")): + return self.image_path + return f"/static/{self.image_path}" \ No newline at end of file diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index eee2465..745a088 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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" \ No newline at end of file diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 58fa628..78d7298 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException, status, Request -from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.templating import Jinja2Templates from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -7,7 +7,7 @@ from pydantic import BaseModel from ..database import get_db from ..models import User -from ..auth import verify_password, create_token, hash_password, require_admin +from ..auth import verify_password, create_token, hash_password router = APIRouter() templates = Jinja2Templates(directory="app/templates") @@ -37,7 +37,7 @@ async def login(data: LoginForm, db: AsyncSession = Depends(get_db)): raise HTTPException(status_code=401, detail="Forkert brugernavn eller adgangskode") token = create_token(user.username, user.role) - resp = RedirectResponse(url="/", status_code=302) + resp = JSONResponse(content={"ok": True, "redirect": "/"}) resp.set_cookie( key="token", value=token, httponly=True, max_age=30 * 24 * 3600, samesite="lax" @@ -47,41 +47,6 @@ async def login(data: LoginForm, db: AsyncSession = Depends(get_db)): @router.post("/api/logout") async def logout(): - resp = RedirectResponse(url="/login", status_code=302) + resp = JSONResponse(content={"ok": True, "redirect": "/login"}) 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 - ] diff --git a/backend/app/routers/cards.py b/backend/app/routers/cards.py index 6065c43..c139688 100644 --- a/backend/app/routers/cards.py +++ b/backend/app/routers/cards.py @@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from ..database import get_db -from ..models import Set, Card +from ..models import Serie, Set, Card router = APIRouter() templates = Jinja2Templates(directory="app/templates") @@ -14,14 +14,40 @@ 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}) + result = await db.execute( + select(Serie).options(selectinload(Serie.sets).selectinload(Set.cards)).order_by(Serie.name) + ) + series = result.scalars().all() + return templates.TemplateResponse("index.html", {"request": request, "series": series}) + + +@router.get("/series", response_class=HTMLResponse) +async def list_series(request: Request, db: AsyncSession = Depends(get_db)): + result = await db.execute( + select(Serie).options(selectinload(Serie.sets).selectinload(Set.cards)).order_by(Serie.name) + ) + series = result.scalars().all() + return templates.TemplateResponse("series.html", {"request": request, "series": series}) + + +@router.get("/series/{serie_name}", response_class=HTMLResponse) +async def serie_detail(request: Request, serie_name: str, db: AsyncSession = Depends(get_db)): + result = await db.execute( + select(Serie).where(Serie.name == serie_name).options( + selectinload(Serie.sets).selectinload(Set.cards) + ) + ) + serie = result.scalar_one_or_none() + if not serie: + return templates.TemplateResponse("404.html", {"request": request}, status_code=404) + return templates.TemplateResponse("serie_detail.html", {"request": request, "serie": serie}) @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)) + result = await db.execute( + select(Set).options(selectinload(Set.serie), selectinload(Set.cards)).order_by(Set.code) + ) sets = result.scalars().all() return templates.TemplateResponse("sets.html", {"request": request, "sets": sets}) @@ -29,7 +55,9 @@ async def list_sets(request: Request, db: AsyncSession = Depends(get_db)): @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)) + select(Set).where(Set.code == set_code.upper()).options( + selectinload(Set.cards), selectinload(Set.serie) + ) ) s = result.scalar_one_or_none() if not s: @@ -37,65 +65,61 @@ async def set_detail(request: Request, set_code: str, db: AsyncSession = Depends return templates.TemplateResponse("set_detail.html", {"request": request, "set": s}) +@router.get("/card/{set_code}/{number}", response_class=HTMLResponse) +async def card_detail(request: Request, set_code: str, number: str, + db: AsyncSession = Depends(get_db)): + # Prøv med og uden leading zero (robust overfor "50" vs "050") + candidates = set([number, number.zfill(3), number.lstrip("0")]) + for num in candidates: + result = await db.execute( + select(Card) + .join(Card.set) + .where(Set.code == set_code.upper(), Card.number == num) + .options(selectinload(Card.set).selectinload(Set.serie)) + ) + card = result.scalar_one_or_none() + if card: + return templates.TemplateResponse("card_detail.html", {"request": request, "card": card}) + + return templates.TemplateResponse("404.html", {"request": request}, status_code=404) + + @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() +# ─── JSON API ─────────────────────────────────────────────────────── - # 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)) +@router.get("/api/series") +async def api_list_series(db: AsyncSession = Depends(get_db)): + 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.get("/api/series/{serie_name}/sets") +async def api_series_sets(serie_name: str, db: AsyncSession = Depends(get_db)): + result = await db.execute( + select(Serie).where(Serie.name == serie_name).options( + selectinload(Serie.sets).selectinload(Set.cards) ) - 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 - ] + ) + serie = result.scalar_one_or_none() + if not serie: + return {"error": "Serie ikke fundet"} + return [{"code": s.code, "name": s.name, "card_count": len(s.cards)} for s in serie.sets] @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 - ] + result = await db.execute( + select(Set).options(selectinload(Set.serie), selectinload(Set.cards)).order_by(Set.code) + ) + return [{"code": s.code, "name": s.name, "serie": s.serie.name, + "card_count": len(s.cards)} for s in result.scalars().all()] @router.get("/api/sets/{set_code}/cards") @@ -106,8 +130,41 @@ async def api_set_cards(set_code: str, db: AsyncSession = Depends(get_db)): 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 - ] + 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.get("/api/search") +async def search_cards(q: str = Query(""), db: AsyncSession = Depends(get_db)): + """Search cards by full code (CRI-068) or by name.""" + q = q.strip() + if not q: + return [] + + if "\u002D" in q or " " in q or "=" in q: + # Split på bindestreg, mellemrum eller =: "CRI-068" eller "CRI 068" + sep = "\u002D" if "\u002D" in q else (" " if " " in q else "=") + parts = q.split(sep, 1) + code_part = parts[0].strip().upper() + num_part = parts[1].strip() + candidates = set([num_part, num_part.zfill(3), num_part.lstrip("0")]) + stmt = select(Card).join(Card.set).where( + Set.code == code_part, Card.number.in_(list(candidates)) + ).options(selectinload(Card.set).selectinload(Set.serie)) + else: + stmt = ( + select(Card) + .join(Card.set) + .where(Card.name.ilike(f"%{q}%")) + .options(selectinload(Card.set).selectinload(Set.serie)) + .limit(50) + ) + + result = await db.execute(stmt) + cards = result.scalars().all() + return [{ + "id": c.id, "full_code": c.full_code, "name": c.name, + "set_name": c.set.name, "set_code": c.set.code, + "serie_name": c.set.serie.name, + "image_url": c.image_url, + } for c in cards] \ No newline at end of file diff --git a/backend/app/templates/admin/cards.html b/backend/app/templates/admin/cards.html index bc2a68e..257705e 100644 --- a/backend/app/templates/admin/cards.html +++ b/backend/app/templates/admin/cards.html @@ -1,110 +1,146 @@ {% extends "base.html" %} -{% block title %}Admin — Kort i {{ set_code }}{% endblock %} +{% block title %}Admin — {{ set.code }} kort{% endblock %} {% block content %} -

🃏 Kort i {{ set_code }}

-

Tilføj eller fjern kort fra dette set.

+← Sæt +

+ {{ set.code }} + {{ set.name }} +

+

{{ set.serie.name }}

-
-

Tilføj nyt kort

-
- - - - - - - - - -
-

+
+
+

Opret kort

+
+ + + + +
+
+ +
+

CSV-import

+

+ Kolonner: set_code,number,name,image +
Download skabelon +

+
+ + +
+
+
-

Eksisterende kort

-
Indlæser...
-← Tilbage til serier +

Kort i {{ set.code }}

+ + + + + + {% for card in set.cards %} + + + + + + + + + {% endfor %} + +
#NavnKodeBilledeUploadHandling
{{ card.number }}{{ card.name }}{{ card.full_code }} + {% if card.image_url %} + + {{ card.name }} + + {% else %} + + {% endif %} + +
+ + +
+
+ +
+ +{% if set.cards|length == 0 %} +
+

Ingen kort i {{ set.code }} endnu.

+
+{% endif %} {% endblock %} \ No newline at end of file diff --git a/backend/app/templates/admin/dashboard.html b/backend/app/templates/admin/dashboard.html index 49fc764..94f80c8 100644 --- a/backend/app/templates/admin/dashboard.html +++ b/backend/app/templates/admin/dashboard.html @@ -1,95 +1,30 @@ {% extends "base.html" %} -{% block title %}Admin panel{% endblock %} +{% block title %}Admin{% endblock %} {% block content %} -

⚙️ Admin panel

-

Velkommen, {{ user.username }}!

+

🔧 Adminpanel

+

Velkommen, {{ request.state.user.username }}!

- -
-
📦
-

Administrér serier

-

Opret, redigér og slet serier/sets

+
+
+

📦

+

Serier

+

Administrér serier

- - - -
-

👥 Brugere

- - - - - - - -
BrugernavnRolleOprettet
Indlæser...
-
-

Opret ny bruger

-
- - - - -
-

-
-
- - {% endblock %} \ No newline at end of file diff --git a/backend/app/templates/admin/series.html b/backend/app/templates/admin/series.html new file mode 100644 index 0000000..efe8477 --- /dev/null +++ b/backend/app/templates/admin/series.html @@ -0,0 +1,105 @@ +{% extends "base.html" %} +{% block title %}Admin — Serier{% endblock %} +{% block content %} +← Admin +

📦 Serier

+ +
+
+

Opret serie

+
+ + + +
+
+ +
+

CSV-import

+

+ Kolonner: name,description +
Download skabelon +

+
+ + +
+
+
+
+ +

Eksisterende serier

+ + + + + + {% for s in series %} + + + + + + {% endfor %} + +
NavnSætHandling
{{ s.name }}{{ s.sets|length }} + +
+ +{% if series|length == 0 %} +
+

Ingen serier endnu.

+
+{% endif %} + + +{% endblock %} \ No newline at end of file diff --git a/backend/app/templates/admin/sets.html b/backend/app/templates/admin/sets.html index e280c1e..225b7af 100644 --- a/backend/app/templates/admin/sets.html +++ b/backend/app/templates/admin/sets.html @@ -1,95 +1,119 @@ {% extends "base.html" %} -{% block title %}Admin — Serier{% endblock %} +{% block title %}Admin — Sæt{% endblock %} {% block content %} -

📦 Administrér serier

-

Opret og slet Pokémon TCG serier/sets.

+← Admin +

📁 Sæt

-
-

Opret ny serie

-
- - - - -
-

+
+
+

Opret sæt

+
+ + + + + + +
+
+ +
+

CSV-import

+

+ Kolonner: serie,code,name,release_date,description +
Download skabelon +

+
+ + +
+
+
-

Eksisterende serier

-
+

Eksisterende sæt

+ + + + + + {% for s in sets %} + + + + + + + + {% endfor %} + +
KodeNavnSerieKortHandling
{{ s.code }}{{ s.name }}{{ s.serie.name }}{{ s.cards|length }} + Kort + +
+ +{% if sets|length == 0 %} +
+

Ingen sæt endnu.

+
+{% endif %} {% endblock %} \ No newline at end of file diff --git a/backend/app/templates/base.html b/backend/app/templates/base.html index bf4a7fa..502aa96 100644 --- a/backend/app/templates/base.html +++ b/backend/app/templates/base.html @@ -239,7 +239,8 @@