feat: badges imprimé (✅ copies) dans galeries admin+public; fix: timestamp_to_date dans print.html
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
"""Routes du dashboard admin -- authentification requise."""
|
"""Routes du dashboard admin -- authentification requise."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Form
|
from fastapi import APIRouter, Request, Form
|
||||||
@@ -141,6 +142,13 @@ async def admin_print(request: Request):
|
|||||||
queue = await printer_svc.get_queue()
|
queue = await printer_svc.get_queue()
|
||||||
printers = await printer_svc.get_printers_status()
|
printers = await printer_svc.get_printers_status()
|
||||||
|
|
||||||
|
# Formate les dates pour le template Jinja2
|
||||||
|
for q in queue:
|
||||||
|
ts = q.get("requested_at")
|
||||||
|
q["requested_at_str"] = (
|
||||||
|
datetime.fromtimestamp(ts).strftime("%d/%m %H:%M") if ts else "—"
|
||||||
|
)
|
||||||
|
|
||||||
return _templates.TemplateResponse(request, "admin/print.html", {
|
return _templates.TemplateResponse(request, "admin/print.html", {
|
||||||
"config": cfg,
|
"config": cfg,
|
||||||
"queue": queue,
|
"queue": queue,
|
||||||
|
|||||||
@@ -93,21 +93,25 @@ async def admin_get_photos(
|
|||||||
p["thumb_url"] = pb.thumbnail_url(pid)
|
p["thumb_url"] = pb.thumbnail_url(pid)
|
||||||
p["download_url"] = f"/api/gallery/download/{pid}"
|
p["download_url"] = f"/api/gallery/download/{pid}"
|
||||||
|
|
||||||
# Croise avec la file d'impression en attente
|
# Croise avec les stats d'impression (pending, printing, done)
|
||||||
try:
|
try:
|
||||||
pending_map = await printer_svc.get_pending_by_photo_id()
|
stats_map = await printer_svc.get_print_stats_by_photo_id()
|
||||||
for p in page_photos:
|
for p in page_photos:
|
||||||
pid = p.get("photo_id", "")
|
pid = p.get("photo_id", "")
|
||||||
requests = pending_map.get(pid, [])
|
info = stats_map.get(pid, {})
|
||||||
p["print_requests"] = requests
|
p["print_requests"] = info.get("requests", [])
|
||||||
p["print_pending"] = len([r for r in requests if r["status"] == "pending"])
|
p["print_pending"] = info.get("pending", 0)
|
||||||
p["print_printing"] = len([r for r in requests if r["status"] == "printing"])
|
p["print_printing"] = info.get("printing", 0)
|
||||||
|
p["print_done"] = info.get("done", 0)
|
||||||
|
p["print_done_copies"] = info.get("copies_done", 0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Impossible de croiser avec print_queue: %s", e)
|
logger.warning("Impossible de croiser avec print_queue: %s", e)
|
||||||
for p in page_photos:
|
for p in page_photos:
|
||||||
p["print_requests"] = []
|
p["print_requests"] = []
|
||||||
p["print_pending"] = 0
|
p["print_pending"] = 0
|
||||||
p["print_printing"] = 0
|
p["print_printing"] = 0
|
||||||
|
p["print_done"] = 0
|
||||||
|
p["print_done_copies"] = 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"photos": page_photos,
|
"photos": page_photos,
|
||||||
|
|||||||
@@ -74,6 +74,22 @@ async def api_gallery_photos(
|
|||||||
p["thumb_url"] = pb.thumbnail_url(pid)
|
p["thumb_url"] = pb.thumbnail_url(pid)
|
||||||
p["download_url"] = f"/api/gallery/download/{pid}"
|
p["download_url"] = f"/api/gallery/download/{pid}"
|
||||||
|
|
||||||
|
# Ajoute les stats d'impression (copies réussies) pour les badges
|
||||||
|
printer_svc = getattr(request.app.state, "printer_service", None)
|
||||||
|
if printer_svc:
|
||||||
|
try:
|
||||||
|
stats_map = await printer_svc.get_print_stats_by_photo_id()
|
||||||
|
for p in page_photos:
|
||||||
|
pid = _get_id(p)
|
||||||
|
info = stats_map.get(pid, {})
|
||||||
|
p["print_done_copies"] = info.get("copies_done", 0)
|
||||||
|
except Exception:
|
||||||
|
for p in page_photos:
|
||||||
|
p["print_done_copies"] = 0
|
||||||
|
else:
|
||||||
|
for p in page_photos:
|
||||||
|
p["print_done_copies"] = 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"photos": page_photos,
|
"photos": page_photos,
|
||||||
"total": total,
|
"total": total,
|
||||||
|
|||||||
@@ -111,18 +111,44 @@ class PrinterService:
|
|||||||
return await self.get_queue("pending")
|
return await self.get_queue("pending")
|
||||||
|
|
||||||
async def get_pending_by_photo_id(self) -> dict[str, list]:
|
async def get_pending_by_photo_id(self) -> dict[str, list]:
|
||||||
"""Retourne un dict {photo_id: [entries]} pour toutes les demandes actives.
|
"""Retourne {photo_id: [entries actives]}. Conservé pour compatibilité."""
|
||||||
|
stats = await self.get_print_stats_by_photo_id()
|
||||||
|
return {pid: info["requests"] for pid, info in stats.items() if info["requests"]}
|
||||||
|
|
||||||
Utilise le champ photo_id (UUID photobooth-app) s'il est renseigné,
|
async def get_print_stats_by_photo_id(self) -> dict[str, dict]:
|
||||||
sinon fall-back sur le stem du filename.
|
"""Stats complètes d'impression par photo_id.
|
||||||
|
|
||||||
|
Retourne {photo_id: {requests, pending, printing, done, copies_done}}.
|
||||||
|
'requests' contient uniquement les entrées pending/printing (pour la lightbox).
|
||||||
|
'done' et 'copies_done' comptent les impressions réussies.
|
||||||
"""
|
"""
|
||||||
rows = await self.get_queue("pending")
|
async with self._db.execute(
|
||||||
rows += await self.get_queue("printing")
|
"SELECT id, photo_id, filename, status, copies, requested_at, thumb_url "
|
||||||
|
"FROM print_queue WHERE status IN ('pending','printing','done') "
|
||||||
|
"ORDER BY requested_at"
|
||||||
|
) as cur:
|
||||||
|
rows = await cur.fetchall()
|
||||||
|
|
||||||
result: dict[str, list] = {}
|
result: dict[str, dict] = {}
|
||||||
for r in rows:
|
for r in rows:
|
||||||
key = r.get("photo_id") or Path(r["filename"]).stem
|
key = r["photo_id"] or Path(r["filename"]).stem
|
||||||
result.setdefault(key, []).append(r)
|
if key not in result:
|
||||||
|
result[key] = {
|
||||||
|
"requests": [],
|
||||||
|
"pending": 0,
|
||||||
|
"printing": 0,
|
||||||
|
"done": 0,
|
||||||
|
"copies_done": 0,
|
||||||
|
}
|
||||||
|
if r["status"] in ("pending", "printing"):
|
||||||
|
result[key]["requests"].append(dict(r))
|
||||||
|
if r["status"] == "pending":
|
||||||
|
result[key]["pending"] += 1
|
||||||
|
elif r["status"] == "printing":
|
||||||
|
result[key]["printing"] += 1
|
||||||
|
elif r["status"] == "done":
|
||||||
|
result[key]["done"] += 1
|
||||||
|
result[key]["copies_done"] += r["copies"] or 1
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def cancel_by_photo_id(self, photo_id: str) -> int:
|
async def cancel_by_photo_id(self, photo_id: str) -> int:
|
||||||
|
|||||||
@@ -71,6 +71,15 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.print-badge.printing { background: rgba(25,108,176,.9); }
|
.print-badge.printing { background: rgba(25,108,176,.9); }
|
||||||
|
.print-badge.done { background: rgba(30,160,70,.9); }
|
||||||
|
.print-badge.done-secondary {
|
||||||
|
position: absolute; top: 5px; left: 5px;
|
||||||
|
background: rgba(30,160,70,.85); color: #fff;
|
||||||
|
border-radius: 12px; padding: .15rem .45rem;
|
||||||
|
font-size: .72rem; font-weight: 700;
|
||||||
|
display: flex; align-items: center; gap: .2rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.date-badge {
|
.date-badge {
|
||||||
position: absolute; bottom: 0; left: 0; right: 0;
|
position: absolute; bottom: 0; left: 0; right: 0;
|
||||||
@@ -445,11 +454,23 @@ function renderGrid(photos) {
|
|||||||
const pid = p.photo_id || p.id || '';
|
const pid = p.photo_id || p.id || '';
|
||||||
const hasPending = p.print_pending > 0;
|
const hasPending = p.print_pending > 0;
|
||||||
const hasPrinting = p.print_printing > 0;
|
const hasPrinting = p.print_printing > 0;
|
||||||
|
const hasDone = (p.print_done || 0) > 0;
|
||||||
|
const copies = p.print_done_copies || 0;
|
||||||
|
|
||||||
|
// Badge principal (coin haut droite) — priorité : impression en cours > attente > terminé
|
||||||
const badge = hasPrinting
|
const badge = hasPrinting
|
||||||
? `<div class="print-badge printing">🔵 Impression…</div>`
|
? `<div class="print-badge printing">🔵 Impression…</div>`
|
||||||
: hasPending
|
: hasPending
|
||||||
? `<div class="print-badge">🖨 ${p.print_pending}</div>`
|
? `<div class="print-badge">🖨 ${p.print_pending} att.</div>`
|
||||||
: '';
|
: hasDone
|
||||||
|
? `<div class="print-badge done">✅ ${copies} cop.</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Si en attente ET déjà imprimé → badge "imprimé" coin haut gauche aussi
|
||||||
|
const doneBadgeLeft = (hasPending || hasPrinting) && hasDone
|
||||||
|
? `<div class="print-badge done-secondary">✅ ${copies}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
const dateLabel = p.date_label
|
const dateLabel = p.date_label
|
||||||
? `<div class="date-badge">${p.date_label}</div>` : '';
|
? `<div class="date-badge">${p.date_label}</div>` : '';
|
||||||
|
|
||||||
@@ -457,7 +478,7 @@ function renderGrid(photos) {
|
|||||||
<div class="photo-card ${hasPending || hasPrinting ? 'has-print' : ''}" id="card-${pid}"
|
<div class="photo-card ${hasPending || hasPrinting ? 'has-print' : ''}" id="card-${pid}"
|
||||||
onclick="openLightboxIdx(${idx})">
|
onclick="openLightboxIdx(${idx})">
|
||||||
<img src="${p.thumb_url}" loading="lazy" alt="">
|
<img src="${p.thumb_url}" loading="lazy" alt="">
|
||||||
${badge}${dateLabel}
|
${badge}${doneBadgeLeft}${dateLabel}
|
||||||
<div class="card-overlay">
|
<div class="card-overlay">
|
||||||
${hasPending
|
${hasPending
|
||||||
? `<button class="ov-btn ov-cancel" onclick="event.stopPropagation();quickCancel('${pid}')">✕</button>`
|
? `<button class="ov-btn ov-cancel" onclick="event.stopPropagation();quickCancel('${pid}')">✕</button>`
|
||||||
|
|||||||
@@ -308,7 +308,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style="font-size:.78rem;color:var(--text-muted)">
|
<td style="font-size:.78rem;color:var(--text-muted)">
|
||||||
{{ q.requested_at|int|timestamp_to_date if q.requested_at else '—' }}
|
{{ q.requested_at_str }}
|
||||||
</td>
|
</td>
|
||||||
<td style="font-size:.82rem">{{ q.printer or '—' }}</td>
|
<td style="font-size:.82rem">{{ q.printer or '—' }}</td>
|
||||||
<td style="display:flex;gap:.4rem;align-items:center">
|
<td style="display:flex;gap:.4rem;align-items:center">
|
||||||
|
|||||||
@@ -5,6 +5,19 @@
|
|||||||
<a href="/gallery" class="active">📷 Galerie photos</a>
|
<a href="/gallery" class="active">📷 Galerie photos</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.pub-print-badge {
|
||||||
|
position: absolute; top: 6px; left: 6px;
|
||||||
|
background: rgba(30,160,70,.9); color: #fff;
|
||||||
|
border-radius: 12px; padding: .15rem .5rem;
|
||||||
|
font-size: .72rem; font-weight: 700;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.photo-card { position: relative; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
@@ -71,14 +84,20 @@ async function loadPhotos(page = 1) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
grid.innerHTML = photos.map(p => `
|
grid.innerHTML = photos.map(p => {
|
||||||
<div class="photo-card" onclick="openLightbox('${p.full_url}', '${p.download_url}')">
|
const copies = p.print_done_copies || 0;
|
||||||
<img src="${p.thumb_url}" loading="lazy" alt="Photo" onerror="this.src='${p.full_url}'">
|
const printedBadge = copies > 0
|
||||||
<div class="photo-card-actions">
|
? `<div class="pub-print-badge">🖨 ${copies}×</div>`
|
||||||
<a class="btn btn-primary btn-sm" href="${p.download_url}" onclick="event.stopPropagation()">⬇ Télécharger</a>
|
: '';
|
||||||
</div>
|
return `
|
||||||
</div>
|
<div class="photo-card" onclick="openLightbox('${p.full_url}', '${p.download_url}')">
|
||||||
`).join('');
|
<img src="${p.thumb_url}" loading="lazy" alt="Photo" onerror="this.src='${p.full_url}'">
|
||||||
|
${printedBadge}
|
||||||
|
<div class="photo-card-actions">
|
||||||
|
<a class="btn btn-primary btn-sm" href="${p.download_url}" onclick="event.stopPropagation()">⬇ Télécharger</a>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
document.getElementById('photo-grid').innerHTML = `
|
document.getElementById('photo-grid').innerHTML = `
|
||||||
<div class="empty-state" style="grid-column:1/-1">
|
<div class="empty-state" style="grid-column:1/-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user