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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user