Files

615 lines
26 KiB
Python
Raw Permalink Normal View History

import csv
import io
import os
import uuid
from pathlib import Path
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form, status
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
2026-07-01 21:10:06 +02:00
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
2026-07-01 21:10:06 +02:00
from pydantic import BaseModel
from ..database import get_db
from ..models import User, Serie, Set, Card
from ..auth import require_admin, hash_password
2026-07-01 21:10:06 +02:00
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
2026-07-01 21:10:06 +02:00
UPLOAD_DIR = Path("app/static/images")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
2026-07-01 21:10:06 +02:00
# ─── Hjælpefunktioner ───────────────────────────────────────────────
async def download_image_from_url(url: str, serie_name: str, set_code: str, number: str) -> str | None:
"""Download billede fra URL og gem lokalt. Returnerer relativ sti eller None ved fejl."""
if not url or not url.startswith(("http://", "https://")):
return url # Allerede lokal sti eller None
try:
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
resp = await client.get(url)
resp.raise_for_status()
# Bestem filtype ud fra content-type
ct = resp.headers.get("content-type", "")
ext_map = {"image/jpeg": ".jpg", "image/png": ".png",
"image/gif": ".gif", "image/webp": ".webp"}
ext = ext_map.get(ct.split(";")[0].strip(), ".jpg")
serie_folder = serie_name.replace(" ", "_").replace("/", "_")
folder = UPLOAD_DIR / serie_folder / set_code
folder.mkdir(parents=True, exist_ok=True)
filename = f"{number}{ext}"
filepath = folder / filename
filepath.write_bytes(resp.content)
return f"images/{serie_folder}/{set_code}/{filename}"
except Exception as e:
print(f"Kunne ikke downloade billede {url}: {e}")
return None # Ingen fallback til URL — offline only
# ─── Pydantic schemas ───────────────────────────────────────────────
2026-07-01 21:10:06 +02:00
class SerieCreate(BaseModel):
name: str
description: str | None = None
class SetCreate(BaseModel):
2026-07-01 21:10:06 +02:00
code: str
name: str
release_date: str | None = None
description: str | None = None
class CardCreate(BaseModel):
2026-07-01 21:10:06 +02:00
number: str
name: str
image: str | None = None # URL
class NewUserForm(BaseModel):
username: str
password: str
role: str = "user"
2026-07-01 21:10:06 +02:00
# ─── Admin HTML pages ───────────────────────────────────────────────
2026-07-01 21:10:06 +02:00
@router.get("/admin", response_class=HTMLResponse)
async def admin_dashboard(request: Request, db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin)):
result = await db.execute(select(User).order_by(User.username))
users = result.scalars().all()
return templates.TemplateResponse("admin/dashboard.html", {
"request": request, "users": users
})
@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})
2026-07-01 21:10:06 +02:00
@router.get("/admin/series/{serie_name}", response_class=HTMLResponse)
async def admin_series_detail(request: Request, serie_name: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin)):
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)
# Hent serier til dropdown (til CSV import)
series_result = await db.execute(select(Serie).order_by(Serie.name))
all_series = series_result.scalars().all()
return templates.TemplateResponse("admin/serie_detail.html", {
"request": request, "serie": serie, "all_series": all_series
})
2026-07-01 21:10:06 +02:00
@router.get("/admin/sets", response_class=HTMLResponse)
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})
2026-07-01 21:10:06 +02:00
@router.get("/admin/cards/{set_code}", response_class=HTMLResponse)
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) ─────────────────────────────────────────────────
2026-07-01 21:10:06 +02:00
@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()]
@router.delete("/api/admin/users/{user_id}")
async def delete_user(user_id: int, db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_admin)):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="Bruger ikke fundet")
if user.id == current_user.id:
raise HTTPException(status_code=400, detail="Du kan ikke slette dig selv")
await db.delete(user)
await db.commit()
return {"ok": True}
# ─── API: Series ────────────────────────────────────────────────────
@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}
2026-07-01 21:10:06 +02:00
# ─── 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")
2026-07-01 21:10:06 +02:00
exists = await db.execute(select(Set).where(Set.code == data.code.upper()))
if exists.scalar_one_or_none():
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)
2026-07-01 21:10:06 +02:00
db.add(s)
await db.commit()
return {"ok": True, "id": s.id, "code": s.code, "name": s.name}
2026-07-01 21:10:06 +02:00
@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]
2026-07-01 21:10:06 +02:00
@router.post("/api/admin/series/{serie_name}/sets/import")
async def import_sets_for_serie(serie_name: str, file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin)):
"""CSV-import af sets til en specifik serie — 'serie'-kolonne ignoreres/overskrives."""
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")
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()
if not code or not name:
errors.append(f"Linje {i}: mangler kode eller navn")
continue
try:
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}
2026-07-01 21:10:06 +02:00
@router.get("/api/admin/sets")
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)
)
2026-07-01 21:10:06 +02:00
sets = result.scalars().all()
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]
2026-07-01 21:10:06 +02:00
@router.delete("/api/admin/sets/{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()
2026-07-01 21:10:06 +02:00
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}
2026-07-01 21:10:06 +02:00
# ─── API: Cards ─────────────────────────────────────────────────────
2026-07-01 21:10:06 +02:00
@router.post("/api/admin/sets/{set_code}/cards")
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()).options(selectinload(Set.serie))
)
2026-07-01 21:10:06 +02:00
s = result.scalar_one_or_none()
if not s:
raise HTTPException(status_code=404, detail="Set ikke fundet")
card = Card(set_id=s.id, number=data.number, name=data.name)
if data.image:
card.source_url = data.image # Gem original URL til evt. re-sync
local_path = await download_image_from_url(data.image, s.serie.name, s.code, data.number)
card.image_path = local_path or data.image # Behold URL hvis download fejler
2026-07-01 21:10:06 +02:00
db.add(card)
await db.commit()
return {"ok": True, "id": card.id, "full_code": card.full_code, "name": card.name}
2026-07-01 21:10:06 +02:00
@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)
)
)
2026-07-01 21:10:06 +02:00
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]
2026-07-01 21:10:06 +02:00
@router.delete("/api/admin/cards/{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()
2026-07-01 21:10:06 +02:00
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()
2026-07-01 21:10:06 +02:00
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).options(selectinload(Set.serie))
)
s = result.scalar_one_or_none()
if not s:
errors.append(f"Linje {i}: set '{set_code}' ikke fundet")
continue
raw_url = row.get("image", "").strip() or None
if raw_url:
local_path = await download_image_from_url(raw_url, s.serie.name, s.code, number)
image = local_path or raw_url # Behold URL hvis download fejler
else:
image = None
card = Card(set_id=s.id, number=number, name=name, image_path=image, source_url=raw_url)
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}
# ─── Synkronisér billeder ──────────────────────────────────────────
@router.post("/api/admin/sets/{set_code}/sync-images")
async def sync_set_images(set_code: str, db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin)):
"""Download alle eksterne billeder for et bestemt sæt til lokal lagring."""
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")
downloaded = 0
skipped = 0
errors = []
for card in s.cards:
if not card.image_path or not card.image_path.startswith(("http://", "https://")):
skipped += 1
continue
try:
if not card.source_url:
card.source_url = card.image_path # Gem URL hvis ikke allerede gemt
rel_path = await download_image_from_url(card.image_path, s.serie.name, s.code, card.number)
if rel_path:
card.image_path = rel_path
downloaded += 1
else:
errors.append(f"{card.full_code}: download fejlede")
except Exception as e:
errors.append(f"{card.full_code}: {str(e)}")
await db.commit()
return {"ok": True, "set": s.code, "downloaded": downloaded, "skipped": skipped, "errors": errors}
@router.post("/api/admin/sync-all-images")
async def sync_all_images(db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin)):
"""Download ALLE eksterne billeder på tværs af alle sæt."""
result = await db.execute(
select(Card).options(selectinload(Card.set).selectinload(Set.serie))
)
cards = result.scalars().all()
downloaded = 0
skipped = 0
errors = []
for card in cards:
if not card.image_path or not card.image_path.startswith(("http://", "https://")):
skipped += 1
continue
try:
serie_name = card.set.serie.name
set_code = card.set.code
if not card.source_url:
card.source_url = card.image_path # Gem URL hvis ikke allerede gemt
rel_path = await download_image_from_url(card.image_path, serie_name, set_code, card.number)
if rel_path:
card.image_path = rel_path
downloaded += 1
else:
errors.append(f"{card.full_code}: download fejlede")
except Exception as e:
errors.append(f"{card.full_code}: {str(e)}")
await db.commit()
return {"ok": True, "downloaded": downloaded, "skipped": skipped, "errors": errors}
@router.post("/api/admin/verify-images")
async def verify_images(force: bool = False, db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin)):
"""Tjek at alle billedfiler findes på disken. Re-download fra source_url hvis mangler."""
result = await db.execute(
select(Card).where(Card.source_url.isnot(None)).options(
selectinload(Card.set).selectinload(Set.serie)
)
)
cards = result.scalars().all()
restored = 0
skipped = 0
errors = []
for card in cards:
# Bestem om filen findes lokalt
is_local = card.image_path and not card.image_path.startswith(("http://", "https://"))
file_exists = False
if is_local:
fp = Path("app/static") / card.image_path
file_exists = fp.exists()
if not force and is_local and file_exists:
skipped += 1
continue
# Mangler eller force — prøv at downloade fra source_url
try:
serie_name = card.set.serie.name
set_code = card.set.code
rel_path = await download_image_from_url(card.source_url, serie_name, set_code, card.number)
if rel_path:
card.image_path = rel_path
restored += 1
else:
errors.append(f"{card.full_code}: download fra source_url fejlede")
except Exception as e:
errors.append(f"{card.full_code}: {str(e)}")
await db.commit()
return {"ok": True, "restored": restored, "skipped": skipped, "errors": errors}
# ─── 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"