Admin: serie-detail, billedhåndtering, dark mode fix, source_url kolonne + verify

- Serie-detail side: sæt under serie + enkelt/bulk opret
- Billedhentning: URL→lokal download med source_url til re-sync
- Advarselsbadge (⚠) på kort med eksterne URLs (ingen streaming)
- Sync/Verify endpoints + knapper i admin UI
- Brugerstyring: opret/slet (self-delete beskyttet)
- Dark mode flash fix: render-blokerende script i <head>
- Persistent volume til billeder (pokedexter_images)
This commit is contained in:
2026-07-01 23:11:55 +02:00
parent 8361066f2f
commit 54465f751c
10 changed files with 678 additions and 113 deletions
+244 -8
View File
@@ -4,6 +4,7 @@ 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
from fastapi.templating import Jinja2Templates
@@ -23,6 +24,38 @@ UPLOAD_DIR = Path("app/static/images")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
# ─── 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 ───────────────────────────────────────────────
class SerieCreate(BaseModel):
@@ -49,8 +82,13 @@ class NewUserForm(BaseModel):
# ─── Admin HTML pages ───────────────────────────────────────────────
@router.get("/admin", response_class=HTMLResponse)
async def admin_dashboard(request: Request, user: User = Depends(require_admin)):
return templates.TemplateResponse("admin/dashboard.html", {"request": request})
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)
@@ -61,6 +99,28 @@ async def admin_series(request: Request, db: AsyncSession = Depends(get_db),
return templates.TemplateResponse("admin/series.html", {"request": request, "series": series})
@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
})
@router.get("/admin/sets", response_class=HTMLResponse)
async def admin_sets_page(request: Request, db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin)):
@@ -105,6 +165,19 @@ async def list_users(db: AsyncSession = Depends(get_db), _: User = Depends(requi
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 ────────────────────────────────────────────────────
@@ -193,6 +266,42 @@ async def list_sets(serie_name: str, db: AsyncSession = Depends(get_db),
return [{"id": s.id, "code": s.code, "name": s.name, "card_count": len(s.cards)}
for s in serie.sets]
@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}
@router.get("/api/admin/sets")
async def list_all_sets(db: AsyncSession = Depends(get_db), _: User = Depends(require_admin)):
result = await db.execute(
@@ -255,13 +364,18 @@ async def import_sets(file: UploadFile = File(...), db: AsyncSession = Depends(g
@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()))
result = await db.execute(
select(Set).where(Set.code == set_code.upper()).options(selectinload(Set.serie))
)
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.image_path = 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
db.add(card)
await db.commit()
return {"ok": True, "id": card.id, "full_code": card.full_code, "name": card.name}
@@ -312,13 +426,20 @@ async def import_cards(file: UploadFile = File(...), db: AsyncSession = Depends(
errors.append(f"Linje {i}: mangler set_code, number eller name")
continue
try:
result = await db.execute(select(Set).where(Set.code == set_code))
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
image = row.get("image", "").strip() or None
card = Card(set_id=s.id, number=number, name=name, image_path=image)
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:
@@ -363,6 +484,121 @@ async def upload_card_image(card_id: int, file: UploadFile = File(...),
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)
@@ -375,4 +611,4 @@ async def sets_csv_template(_: User = Depends(require_admin)):
@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"
return "set_code,number,name,image\nEVS,050,Raichu,\n"