feat: gestion événements — historique, export ZIP, filtre galerie
This commit is contained in:
@@ -120,6 +120,15 @@ async def admin_settings(request: Request):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/events", response_class=HTMLResponse)
|
||||||
|
async def admin_events(request: Request):
|
||||||
|
redirect = _require_auth(request)
|
||||||
|
if redirect:
|
||||||
|
return redirect
|
||||||
|
cfg = request.app.state.config
|
||||||
|
return _templates.TemplateResponse(request, "admin/events.html", {"config": cfg})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/print", response_class=HTMLResponse)
|
@router.get("/admin/print", response_class=HTMLResponse)
|
||||||
async def admin_print(request: Request):
|
async def admin_print(request: Request):
|
||||||
redirect = _require_auth(request)
|
redirect = _require_auth(request)
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
|
import zipfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import StreamingResponse, JSONResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from backend.services.event_service import slugify
|
from backend.services.event_service import slugify
|
||||||
@@ -81,6 +85,55 @@ async def update_event(request: Request, payload: EventUpdate):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/event/{slug}/export")
|
||||||
|
async def export_event_zip(request: Request, slug: str):
|
||||||
|
"""Exporte toutes les photos d'un événement dans un fichier ZIP.
|
||||||
|
|
||||||
|
Les photos sont sélectionnées par timestamp de fichier (mtime) entre
|
||||||
|
started_at et ended_at de l'événement. Pour l'événement en cours,
|
||||||
|
ended_at = maintenant.
|
||||||
|
"""
|
||||||
|
event_svc = request.app.state.event_service
|
||||||
|
cfg = request.app.state.config
|
||||||
|
|
||||||
|
ev = await event_svc.get_event_by_slug(slug)
|
||||||
|
if not ev:
|
||||||
|
return JSONResponse({"error": f"Événement '{slug}' introuvable"}, status_code=404)
|
||||||
|
|
||||||
|
started_at = ev["started_at"] or 0.0
|
||||||
|
ended_at = ev["ended_at"] or datetime.now().timestamp()
|
||||||
|
|
||||||
|
media_dir = Path(cfg.photobooth.media_dir)
|
||||||
|
if not media_dir.exists():
|
||||||
|
return JSONResponse({"error": "Dossier média introuvable"}, status_code=500)
|
||||||
|
|
||||||
|
# Sélectionner les photos dans la plage de l'événement
|
||||||
|
photos = []
|
||||||
|
for f in sorted(media_dir.iterdir()):
|
||||||
|
if f.suffix.lower() not in (".jpg", ".jpeg", ".png"):
|
||||||
|
continue
|
||||||
|
mtime = f.stat().st_mtime
|
||||||
|
if started_at <= mtime <= ended_at:
|
||||||
|
photos.append(f)
|
||||||
|
|
||||||
|
if not photos:
|
||||||
|
return JSONResponse({"error": "Aucune photo pour cet événement"}, status_code=404)
|
||||||
|
|
||||||
|
# Créer le ZIP en mémoire
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for photo in photos:
|
||||||
|
zf.write(photo, photo.name)
|
||||||
|
buf.seek(0)
|
||||||
|
|
||||||
|
filename = f"{slug}_By_LSDW_{datetime.fromtimestamp(started_at).strftime('%Y-%m-%d')}.zip"
|
||||||
|
return StreamingResponse(
|
||||||
|
buf,
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/event/history")
|
@router.get("/event/history")
|
||||||
async def get_event_history(request: Request):
|
async def get_event_history(request: Request):
|
||||||
"""Retourne tous les événements passés avec leurs statistiques."""
|
"""Retourne tous les événements passés avec leurs statistiques."""
|
||||||
|
|||||||
@@ -34,12 +34,37 @@ async def api_gallery_photos(
|
|||||||
request: Request,
|
request: Request,
|
||||||
page: int = Query(default=1, ge=1),
|
page: int = Query(default=1, ge=1),
|
||||||
limit: int = Query(default=24, ge=1, le=100),
|
limit: int = Query(default=24, ge=1, le=100),
|
||||||
|
event_slug: str = Query(default=None, description="Filtrer par slug d'événement"),
|
||||||
):
|
):
|
||||||
pb = request.app.state.photobooth_service
|
pb = request.app.state.photobooth_service
|
||||||
|
cfg = request.app.state.config
|
||||||
|
|
||||||
all_photos = await pb.get_media_collection(limit=500)
|
all_photos = await pb.get_media_collection(limit=500)
|
||||||
photos = [p for p in all_photos if _is_image(p)]
|
photos = [p for p in all_photos if _is_image(p)]
|
||||||
|
|
||||||
|
# Filtre par événement si demandé
|
||||||
|
if event_slug:
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
event_svc = getattr(request.app.state, "event_service", None)
|
||||||
|
if event_svc:
|
||||||
|
ev = await event_svc.get_event_by_slug(event_slug)
|
||||||
|
if ev:
|
||||||
|
started_at = ev["started_at"] or 0.0
|
||||||
|
ended_at = ev["ended_at"] or datetime.now().timestamp()
|
||||||
|
media_dir = Path(cfg.photobooth.media_dir)
|
||||||
|
# Construire un index mtime par nom de fichier
|
||||||
|
mtime_index: dict[str, float] = {}
|
||||||
|
if media_dir.exists():
|
||||||
|
for f in media_dir.iterdir():
|
||||||
|
mtime_index[f.name] = f.stat().st_mtime
|
||||||
|
# Filtrer les photos par mtime
|
||||||
|
def _in_event(p: dict) -> bool:
|
||||||
|
pid = _get_id(p)
|
||||||
|
mtime = mtime_index.get(pid, mtime_index.get(pid + ".jpg", 0.0))
|
||||||
|
return started_at <= mtime <= ended_at
|
||||||
|
photos = [p for p in photos if _in_event(p)]
|
||||||
|
|
||||||
total = len(photos)
|
total = len(photos)
|
||||||
start = (page - 1) * limit
|
start = (page - 1) * limit
|
||||||
end = start + limit
|
end = start + limit
|
||||||
@@ -56,6 +81,7 @@ async def api_gallery_photos(
|
|||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"pages": max(1, (total + limit - 1) // limit),
|
"pages": max(1, (total + limit - 1) // limit),
|
||||||
|
"event_slug": event_slug,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,17 @@ class EventService:
|
|||||||
)
|
)
|
||||||
await self._db.commit()
|
await self._db.commit()
|
||||||
|
|
||||||
|
async def get_event_by_slug(self, slug: str) -> dict | None:
|
||||||
|
"""Retourne un événement complet par son slug."""
|
||||||
|
async with self._db.execute(
|
||||||
|
"""SELECT id, name, slug, started_at, ended_at,
|
||||||
|
photos_taken, print_requests, prints_done, downloads
|
||||||
|
FROM events WHERE slug = ?""",
|
||||||
|
(slug,),
|
||||||
|
) as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
async def get_stats(self, slug: str) -> dict:
|
async def get_stats(self, slug: str) -> dict:
|
||||||
"""Retourne les stats pour un slug donné."""
|
"""Retourne les stats pour un slug donné."""
|
||||||
async with self._db.execute(
|
async with self._db.execute(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<a href="/admin/actions" class="active">Actions</a>
|
<a href="/admin/actions" class="active">Actions</a>
|
||||||
<a href="/admin/print">Impression</a>
|
<a href="/admin/print">Impression</a>
|
||||||
<a href="/admin/settings">Réglages</a>
|
<a href="/admin/settings">Réglages</a>
|
||||||
|
<a href="/admin/events">Événements</a>
|
||||||
<a href="/admin/logout">Déconnexion</a>
|
<a href="/admin/logout">Déconnexion</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<a href="/admin/actions">Actions</a>
|
<a href="/admin/actions">Actions</a>
|
||||||
<a href="/admin/print">Impression</a>
|
<a href="/admin/print">Impression</a>
|
||||||
<a href="/admin/settings">Réglages</a>
|
<a href="/admin/settings">Réglages</a>
|
||||||
|
<a href="/admin/events">Événements</a>
|
||||||
<a href="/admin/logout">Déconnexion</a>
|
<a href="/admin/logout">Déconnexion</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -330,3 +331,4 @@ setInterval(loadQueue, 15000);
|
|||||||
setInterval(loadEvent, 60000);
|
setInterval(loadEvent, 60000);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
| ||||||