diff --git a/README.md b/README.md index b15f4d0..0d9d53d 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,51 @@ # Pokedexter ⚡ -Pokémon TCG-samlerwebapp — bygget med **FastAPI**, **PostgreSQL**, **Nginx** og **Docker**. +Pokémon TCG-samlerwebapp — bygget med **FastAPI**, **PostgreSQL 16**, **Nginx** og **Podman/Docker**. -Administratorer opretter serier, kort og brugere. Brugere kan logge ind, browse og søge. Ingen åben registrering. +Administratorer opretter serier, sæt og kort. Brugere kan logge ind, browse og søge. Ingen åben registrering. --- ## Arkitektur ``` -Browser ──► Nginx (:8080) ──► FastAPI (:8000) ──► PostgreSQL (:5432) +Browser ──► Nginx (:8085) ──► FastAPI (:8000) ──► PostgreSQL (:5432) ▲ │ └─── static/ ───────┘ ``` -- **Nginx** — reverse proxy, serverer statiske filer +- **Nginx** — reverse proxy, serverer statiske filer på port 8085 - **FastAPI** — async Python backend, Jinja2 templates -- **PostgreSQL 16** — database med SQLAlchemy (async) +- **PostgreSQL 16** — database med SQLAlchemy (async/asyncpg) --- ## Kom i gang -### Docker +Billeder bygges lokalt først, deploy sker via Portainer. + +### Byg lokalt (podman) ```bash -docker compose up -d --build +podman build -t pokedexter-backend:latest -f backend/Dockerfile backend/ +podman build -t pokedexter-nginx:latest -f nginx/Dockerfile nginx/ ``` -### Podman +### Start lokalt ```bash -./start.sh +podman-compose up -d # podman +docker compose up -d # docker ``` ### Stop ```bash -./stop.sh # podman -docker compose down # docker +podman-compose down +docker compose down ``` -Data i `pgdata`-volumen bevares mellem genstarter. +Data i volumes (`pokedexter_pgdata`, `pokedexter_images`) bevares mellem genstarter. --- @@ -57,54 +61,77 @@ Brugeren oprettes automatisk ved første opstart. Skift adgangskoden efter førs ## Sådan bruges det -### Opret en serie (set) +### Opret en serie 1. Log ind på `/login` med `admin` / `admin123` 2. Gå til **Admin → Serier** -3. Indtast **kode** (f.eks. `CRI`) og **navn** (f.eks. `Chaos Rising`) +3. Indtast **navn** (f.eks. `Sword & Shield`) og evt. beskrivelse 4. Tryk **Opret** -### Tilføj kort til en serie +### Opret sæt under en serie 1. Gå til **Admin → Serier** +2. Klik på serienavnet (link) +3. Indtast **kode** (f.eks. `EVS`), **navn** (`Evolving Skies`) og evt. **release_date** +4. Tryk **Opret sæt** + +Eller **bulk import**: indsæt CSV direkte i tekstfeltet: + +``` +Stellar Crown,SCR,Stellar Crown,2024-09-13, +Surging Sparks,SSP,Surging Sparks,2024-11-08, +``` + +### Tilføj kort + +1. Gå til **Admin → Sæt** 2. Klik **Vis kort** på det ønskede sæt -3. Udfyld kortets detaljer (nummer, navn, type, sjældenhed, HP, stadie) -4. Tryk **Tilføj kort** +3. Udfyld **nummer**, **navn** og evt. **image URL** +4. Tryk **Opret kort** -Eksempel: +Eller **bulk import** via CSV: -| Felt | Værdi | -|------------|-----------------| -| Nummer | `068` | -| Navn | `Chaos Dragon` | -| Type | `Dragon` | -| Rarity | `Rare` | -| HP | `120` | -| Stadie | `Basic` | +``` +SCR,001,Caterpie,https://images.pokemontcg.io/scarlet_ivy/1.png +SCR,002,Metapod, +``` -Kortet får automatisk koden `CRI-068`. +Kortet får automatisk koden `SCR-001`. + +### Billedhåndtering + +Når en URL angives ved kortoprettelse: +- Billedet **downloades automatisk** og gemmes lokalt i volume'et +- Hvis download fejler, bevares URL'en som `image_path` og et ⚠-badge vises +- `Card.image_url` returnerer **aldrig eksterne URLs** — ingen streaming + +#### Synkronisér billeder + +På **Admin → Sæt → Vis kort** findes en **Synkronisér billeder**-knap, der downloader alle eksterne billeder for sættet til lokal lagring. + +#### Verificér & genopret + +På **Admin**-dashboardet findes: + +- **Verificér & genopret** — tjekker om alle lokale billedfiler findes på disken. Manglende filer re-downloades fra `source_url` +- **Force-genhent alle** — genhenter ALLE billeder fra deres originale kilder (uanset om de findes) + +Dette er nyttigt hvis volume'et afmonteres ved en fejl, eller billeder tabes ved genstart. ### Opret brugere (admin) -API — ingen HTML-side endnu: - -```bash -curl -X POST http://localhost:8080/api/admin/users \ - -H "Content-Type: application/json" \ - -b "token=DIN_JWT_TOKEN" \ - -d '{"username":"glenn","password":"hemmeligt","role":"user"}' -``` +På **Admin**-dashboardet under "Brugere" kan du oprette og slette brugere. Du kan ikke slette dig selv. ### Browse kort -- `/sets` — liste alle serier -- `/sets/CRI` — se alle kort i serie `CRI` -- `/card/CRI-068` — detaljer om et specifikt kort +- `/sets` — liste alle serier med sæt +- `/sets/{code}` — se alle kort i sæt `EVS` +- `/card/{full_code}` — detaljer om et specifikt kort ### Søg -- `/search?q=CRI-068` — søg på fuld kortkode -- `/search?q=dragon` — søg på kortnavn +- `/search?q=SCR-001` — søg på fuld kortkode +- `/search?q=pikachu` — søg på kortnavn --- @@ -112,13 +139,13 @@ curl -X POST http://localhost:8080/api/admin/users \ ### Offentlige endpoints -| Metode | Sti | Beskrivelse | -|--------|----------------|------------------------| -| GET | `/` | Forside | -| GET | `/sets` | Liste alle serier | -| GET | `/sets/{code}` | Kort i en serie | -| GET | `/search` | Søgeside | -| GET | `/api/search` | Søg (JSON) | +| Metode | Sti | Beskrivelse | +|--------|----------------------|------------------------| +| GET | `/` | Forside | +| GET | `/sets` | Liste alle serier | +| GET | `/sets/{code}` | Kort i et sæt | +| GET | `/search` | Søgeside | +| GET | `/api/search` | Søg (JSON) | | GET | `/api/cards/{full_code}` | Enkelt kort (JSON) | ### Auth endpoints @@ -131,19 +158,28 @@ curl -X POST http://localhost:8080/api/admin/users \ ### Admin endpoints (kræver admin) -| Metode | Sti | Beskrivelse | -|--------|-------------------------------------|--------------------------------| -| GET | `/admin` | Admin dashboard | -| GET | `/admin/sets` | Administrér serier | -| GET | `/admin/cards/{code}` | Administrér kort i serie | -| POST | `/api/admin/users` | Opret bruger | -| GET | `/api/admin/users` | List brugere | -| POST | `/api/admin/sets` | Opret serie | -| GET | `/api/admin/sets` | List serier | -| DELETE | `/api/admin/sets/{id}` | Slet serie | -| POST | `/api/admin/sets/{code}/cards` | Opret kort | -| GET | `/api/admin/sets/{code}/cards` | List kort i serie | -| DELETE | `/api/admin/cards/{id}` | Slet kort | +| Metode | Sti | Beskrivelse | +|--------|--------------------------------------------|-------------------------------------| +| GET | `/admin` | Admin dashboard | +| GET | `/admin/series` | Administrér serier | +| GET | `/admin/series/{name}` | Serie-detail med sæt | +| GET | `/admin/sets` | Administrér sæt | +| GET | `/admin/sets/{code}` | Administrér kort i sæt | +| POST | `/api/admin/users` | Opret bruger | +| DELETE | `/api/admin/users/{id}` | Slet bruger (ikke dig selv) | +| POST | `/api/admin/series` | Opret serie | +| POST | `/api/admin/series/csv` | Bulk import af serier (CSV) | +| DELETE | `/api/admin/series/{id}` | Slet serie | +| POST | `/api/admin/series/{name}/sets` | Opret sæt under serie | +| POST | `/api/admin/series/{name}/sets/csv` | Bulk import af sæt (CSV) | +| POST | `/api/admin/sets/{code}/cards` | Opret kort | +| POST | `/api/admin/sets/{code}/cards/csv` | Bulk import af kort (CSV) | +| DELETE | `/api/admin/cards/{id}` | Slet kort | +| POST | `/api/admin/cards/{id}/upload` | Upload billede (fil) | +| POST | `/api/admin/sets/{code}/sync-images` | Synkronisér billeder for sæt | +| POST | `/api/admin/sync-all-images` | Synkronisér alle billeder | +| POST | `/api/admin/verify-images` | Verificér og genopret billeder | +| POST | `/api/admin/verify-images?force=true` | Force-genhent alle billeder | --- @@ -157,38 +193,44 @@ User role String ("admin" | "user") created_at DateTime +Serie + id Integer (PK) + name String (unique) — "Sword & Shield" + description Text (optional) + created_at DateTime + sets ► Set[] + Set id Integer (PK) - code String (unique) — "CRI" - name String — "Chaos Rising" + serie_id Integer (FK → series.id) + code String (unique) — "EVS" + name String — "Evolving Skies" release_date String (optional) description Text (optional) created_at DateTime + serie ► Serie cards ► Card[] Card id Integer (PK) set_id Integer (FK → sets.id) - number String — "068" - name String — "Chaos Dragon" - type String — "Dragon", "Fire", ... - rarity String — "Common", "Uncommon", "Rare", ... - hp Integer - stage String — "Basic", "Stage 1", "Stage 2" - description Text - image_url String (optional) + number String — "050" + name String — "Raichu" + image_path String (optional) — lokal sti eller URL + source_url String (optional) — original ekstern URL til re-sync set ► Set - full_code [property] — "CRI-068" + full_code [property] — "EVS-050" + image_url [property] — "/static/..." eller None (ingen eksterne URLs) ``` --- ## Miljøvariable -| Variabel | Standard | Beskrivelse | -|---------------|----------------------------------|----------------------| +| Variabel | Standard | Beskrivelse | +|----------------|----------------------------------|----------------------| | `DATABASE_URL` | `postgresql+asyncpg://...` | PostgreSQL forbindelse | -| `SECRET_KEY` | `skift-mig-til-en-sikker-noegle` | JWT signeringsnøgle | +| `SECRET_KEY` | `skift-mig-til-en-sikker-noegle` | JWT signeringsnøgle | --- @@ -197,22 +239,26 @@ Card ``` pokedexter/ ├── docker-compose.yml -├── start.sh # podman start -├── stop.sh # podman stop +├── start.sh # podman start +├── stop.sh # podman stop +├── README.md ├── backend/ │ ├── Dockerfile │ ├── requirements.txt │ └── app/ -│ ├── main.py # FastAPI app, middleware -│ ├── database.py # async engine + session -│ ├── models.py # SQLAlchemy modeller -│ ├── auth.py # bcrypt + JWT -│ ├── routers/ -│ │ ├── auth.py # login/logout + admin user mgmt -│ │ ├── admin.py # admin CRUD for sets/cards -│ │ └── cards.py # browse + search -│ ├── templates/ # Jinja2 HTML templates -│ └── static/ # CSS, JS, billeder +│ ├── main.py # FastAPI app, middleware +│ ├── database.py # async engine + session +│ ├── models.py # SQLAlchemy modeller +│ ├── auth.py # bcrypt + JWT +│ ├── static/ +│ │ └── images/ # uploadet/downloadede billeder (volume) +│ ├── templates/ # Jinja2 HTML templates +│ │ ├── base.html # layout m. dark mode toggle +│ │ └── admin/ # admin UI templates +│ └── routers/ +│ ├── auth.py # login/logout + admin user mgmt +│ ├── admin.py # admin CRUD for series/sets/cards +│ └── cards.py # browse + search ├── nginx/ │ ├── Dockerfile │ └── nginx.conf @@ -224,22 +270,33 @@ pokedexter/ ## Udvikling -Genstart backend efter kodeændringer: +### Genstart backend efter kodeændringer ```bash -docker compose up -d --build backend -# eller med podman: +# Byg nyt image +podman build -t pokedexter-backend:latest -f backend/Dockerfile backend/ + +# Genstart via Portainer, eller: podman stop pokedexter-backend && podman rm pokedexter-backend -podman build -t pokedexter-backend ./backend -# ... og kør igen med podman run ... +podman-compose up -d backend ``` +### Dark mode + +Dark mode gemmes i `localStorage` og sættes via render-blokerende script i `
` — ingen flash ved side-skift. + +### Billeder og volumes + +Billeder gemmes i volume `pokedexter_images` mountet på `/app/static/images`. +Volume'et skal være mountet før første hentning — ellers går billeder tabt ved container-genstart. + +Hvis billeder mangler: brug **Verificér & genopret** på admin-dashboardet. + --- ## Planlagte features - [ ] Personlig mappe — brugere kan markere kort de ejer/samler -- [ ] Kortbilleder (upload eller URL) - [ ] Filtrering på type, sjældenhed, HP -- [ ] Dark mode toggle gemt i localStorage *(done)* -- [ ] Mobilvenlig navigation *(done)* \ No newline at end of file +- [ ] Kort-detalje side med artwork +- [ ] Eksport af samling til CSV \ No newline at end of file diff --git a/backend/app/models.py b/backend/app/models.py index 2ac54f6..1d04f41 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -48,7 +48,8 @@ class Card(Base): set_id = Column(Integer, ForeignKey("sets.id", ondelete="CASCADE"), nullable=False, index=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" + image_path = Column(String(500), nullable=True) # Sti: "images/.../050.jpg" + source_url = Column(String(2000), nullable=True) # Original ekstern URL (til re-sync) set = relationship("Set", back_populates="cards") @@ -58,9 +59,9 @@ class Card(Base): @property def image_url(self): - """Returner URL'en for visning — absolut URL eller /static/ relativ sti.""" + """Returner URL'en for visning — kun lokale stier. Ingen eksterne URLs.""" if not self.image_path: return None if self.image_path.startswith(("http://", "https://")): - return self.image_path + return None # Må ikke streames fra ekstern server 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 745a088..23d8252 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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" \ No newline at end of file + return "set_code,number,name,image\nEVS,050,Raichu,\n" diff --git a/backend/app/templates/admin/cards.html b/backend/app/templates/admin/cards.html index 257705e..b6e5dea 100644 --- a/backend/app/templates/admin/cards.html +++ b/backend/app/templates/admin/cards.html @@ -31,6 +31,15 @@ + ++ Hent alle eksterne billeder for {{ set.code }} og gem lokalt. +
+ + +' + msg + '
'; + setTimeout(() => location.reload(), 1500); + } else { + el.innerHTML = 'Fejl: ' + (data.detail || 'Ukendt') + '
'; + } + btn.disabled = false; + btn.textContent = '\ud83d\udce5 Synkronis\u00e9r'; +} {% endblock %} \ No newline at end of file diff --git a/backend/app/templates/admin/dashboard.html b/backend/app/templates/admin/dashboard.html index 94f80c8..4e8bfa6 100644 --- a/backend/app/templates/admin/dashboard.html +++ b/backend/app/templates/admin/dashboard.html @@ -27,4 +27,114 @@ + +| Brugernavn | Rolle | Oprettet | Handling |
|---|---|---|---|
| {{ u.username }} | +{{ u.role }} | +{{ u.created_at.strftime('%Y-%m-%d %H:%M') if u.created_at else '—' }} | ++ {% if u.username != request.state.user.username %} + + {% else %} + dig + {% endif %} + | +
{{ serie.description }}
+{% endif %} + +
+ Kolonner: code,name,release_date,description
+ — {{ serie.name }} udfyldes automatisk.
+
Download skabelon
+
| Kode | Navn | Kort | Udgivet | Handling |
|---|---|---|---|---|
| {{ set.code }} | +{{ set.name }} | +{{ set.cards|length }} | +{{ set.release_date or '—' }} | ++ + + | +
Ingen sæt i {{ serie.name }} endnu.
+