feat: impression depuis galerie publique (quota session, modal copies)
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled

This commit is contained in:
2026-07-17 17:52:52 +02:00
parent d6642c2519
commit 0b612e1302
2 changed files with 326 additions and 10 deletions
+84
View File
@@ -148,6 +148,90 @@ async def get_user_print_config(request: Request):
} }
@router.get("/print/user-print-status")
async def get_user_print_status(request: Request):
"""Statut d'impression de la session courante (quota restant, config active)."""
cfg = request.app.state.config.print
count = request.session.get("gallery_print_count", 0)
remaining = (cfg.user_print_quota - count) if cfg.user_print_quota > 0 else -1
return {
"user_print_enabled": cfg.user_print_enabled,
"user_print_quota": cfg.user_print_quota,
"user_print_max_copies": cfg.user_print_max_copies,
"session_print_count": count,
"remaining_quota": remaining, # -1 = illimité
}
@router.post("/print/request-gallery")
async def gallery_print_request(request: Request, body: dict = Body(...)):
"""Demande d'impression depuis la galerie publique.
Vérifie que l'impression est activée et que le quota session n'est pas dépassé.
"""
from pathlib import Path as _Path
cfg_print = request.app.state.config.print
# ── Activation ────────────────────────────────────────────────────────────
if not cfg_print.user_print_enabled:
return JSONResponse({"error": "L'impression est désactivée"}, status_code=403)
photo_id = str(body.get("photo_id", "")).strip()
if not photo_id:
return JSONResponse({"error": "photo_id requis"}, status_code=400)
copies = max(1, min(cfg_print.user_print_max_copies, int(body.get("copies", 1))))
# ── Quota session ─────────────────────────────────────────────────────────
if cfg_print.user_print_quota > 0:
count = request.session.get("gallery_print_count", 0)
if count >= cfg_print.user_print_quota:
return JSONResponse({
"error": f"Quota atteint ({cfg_print.user_print_quota} impression(s) maximum par session)",
"quota_exceeded": True,
}, status_code=429)
# ── Résolution du fichier ─────────────────────────────────────────────────
media_dir = _Path(request.app.state.config.photobooth.media_dir)
candidates = sorted(media_dir.glob(f"{photo_id}*"))
if not candidates:
return JSONResponse({"error": "Photo introuvable"}, status_code=404)
filepath = str(candidates[0])
# ── Enqueue ───────────────────────────────────────────────────────────────
pb = request.app.state.photobooth_service
printer_svc = request.app.state.printer_service
ws = request.app.state.ws_manager
led = request.app.state.led_service
thumb_url = pb.thumbnail_url(photo_id)
entry = await printer_svc.add_request(filepath, thumb_url, copies)
await ws.broadcast({"type": "print_request", "entry": entry})
# ── Incrémenter compteur session ──────────────────────────────────────────
new_count = request.session.get("gallery_print_count", 0) + 1
request.session["gallery_print_count"] = new_count
remaining = (cfg_print.user_print_quota - new_count) if cfg_print.user_print_quota > 0 else -1
# ── Stats événement ───────────────────────────────────────────────────────
event_svc = getattr(request.app.state, "event_service", None)
if event_svc:
slug = request.app.state.config.event.slug
asyncio.create_task(event_svc.increment(slug, "print_requests"))
# ── LED si mode direct ────────────────────────────────────────────────────
if request.app.state.config.print.mode == "direct":
led.play("printing")
logger.info("Impression galerie: photo_id=%s copies=%d session_count=%d", photo_id, copies, new_count)
return {
"ok": True,
"id": entry["id"],
"mode": request.app.state.config.print.mode,
"remaining_quota": remaining,
}
# ── Gestion avancée des imprimantes CUPS ────────────────────────────────────── # ── Gestion avancée des imprimantes CUPS ──────────────────────────────────────
@router.post("/print/printers/{printer_name}/enable") @router.post("/print/printers/{printer_name}/enable")
+239 -7
View File
@@ -11,6 +11,59 @@
pointer-events: none; pointer-events: none;
} }
.photo-card { position: relative; } .photo-card { position: relative; }
/* ── Bouton imprimer sur la card ── */
.photo-card-actions { display: flex; gap: .35rem; flex-wrap: wrap; }
/* ── Bandeau quota ── */
#quota-bar {
display: none;
align-items: center;
gap: .6rem;
background: rgba(25,108,176,.12);
border: 1px solid rgba(25,108,176,.3);
border-radius: 8px;
padding: .45rem .85rem;
font-size: .88rem;
margin-bottom: .75rem;
}
#quota-bar.quota-warn {
background: rgba(224,123,0,.12);
border-color: rgba(224,123,0,.35);
}
#quota-bar.quota-empty {
background: rgba(192,0,0,.1);
border-color: rgba(192,0,0,.3);
}
/* ── Modal copies ── */
.modal-overlay {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,.65);
z-index: 2000;
align-items: center;
justify-content: center;
}
.modal-overlay.open { display: flex; }
.modal-box {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1.5rem;
max-width: 340px;
width: 90%;
display: flex;
flex-direction: column;
gap: 1rem;
}
.modal-title { font-size: 1.05rem; font-weight: 700; }
.modal-preview { width: 100%; border-radius: 8px; object-fit: cover; max-height: 180px; }
.copies-row { display: flex; align-items: center; gap: .75rem; }
.copies-btn { background: var(--bg-muted); border: 1px solid var(--border); color: var(--text);
width: 2rem; height: 2rem; border-radius: 6px; cursor: pointer; font-size: 1.1rem;
display: flex; align-items: center; justify-content: center; }
.copies-val { font-size: 1.2rem; font-weight: 700; min-width: 1.5rem; text-align: center; }
</style> </style>
{% endblock %} {% endblock %}
@@ -27,6 +80,12 @@
Retrouvez vos photos ci-dessous. Scannez le QR code depuis la galerie du photomaton pour télécharger votre photo directement sur votre téléphone. Retrouvez vos photos ci-dessous. Scannez le QR code depuis la galerie du photomaton pour télécharger votre photo directement sur votre téléphone.
</p> </p>
<!-- Bandeau quota -->
<div id="quota-bar">
<span id="quota-icon">🖨</span>
<span id="quota-text"></span>
</div>
<!-- Navigation pages --> <!-- Navigation pages -->
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<button class="btn btn-ghost btn-sm" id="prev-btn" onclick="changePage(-1)" disabled>← Préc.</button> <button class="btn btn-ghost btn-sm" id="prev-btn" onclick="changePage(-1)" disabled>← Préc.</button>
@@ -45,16 +104,83 @@
<img id="lightbox-img" src="" alt="Photo"> <img id="lightbox-img" src="" alt="Photo">
<div class="lightbox-actions"> <div class="lightbox-actions">
<a class="btn btn-primary" id="lb-download">⬇ Télécharger la photo</a> <a class="btn btn-primary" id="lb-download">⬇ Télécharger la photo</a>
<button class="btn btn-primary" id="lb-print" style="display:none" onclick="openPrintModal()">🖨 Imprimer</button>
</div> </div>
<div class="text-xs text-muted">Connectez-vous au WiFi Photomaton pour télécharger</div> <div class="text-xs text-muted">Connectez-vous au WiFi Photomaton pour télécharger</div>
</div> </div>
<!-- Modal sélection copies -->
<div class="modal-overlay" id="print-modal" onclick="closePrintModal(event)">
<div class="modal-box" onclick="event.stopPropagation()">
<div class="modal-title">🖨 Demande d'impression</div>
<img id="pm-preview" src="" alt="" class="modal-preview">
<div id="pm-copies-row" class="copies-row" style="display:none">
<span style="flex:1;font-size:.9rem">Nombre de copies</span>
<button class="copies-btn" onclick="changeCopies(-1)"></button>
<span class="copies-val" id="pm-copies">1</span>
<button class="copies-btn" onclick="changeCopies(1)">+</button>
</div>
<p class="text-sm text-muted" id="pm-quota-info" style="margin:0"></p>
<div class="flex gap-1">
<button class="btn btn-primary" onclick="confirmPrint()" id="pm-confirm">🖨 Confirmer</button>
<button class="btn btn-ghost" onclick="closePrintModal()">Annuler</button>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
// ── État ──────────────────────────────────────────────────────────────────────
let currentPage = 1; let currentPage = 1;
let totalPages = 1; let totalPages = 1;
let printCfg = { user_print_enabled: false, user_print_quota: 0,
user_print_max_copies: 1, remaining_quota: -1 };
let currentPrintPhotoId = null;
let currentPrintThumb = null;
let currentCopies = 1;
// ── Quota ─────────────────────────────────────────────────────────────────────
async function loadPrintStatus() {
try {
const s = await api('GET', '/api/print/user-print-status');
printCfg = s;
updateQuotaBar();
} catch(e) {}
}
function updateQuotaBar() {
const bar = document.getElementById('quota-bar');
const text = document.getElementById('quota-text');
const icon = document.getElementById('quota-icon');
if (!printCfg.user_print_enabled) { bar.style.display = 'none'; return; }
bar.style.display = 'flex';
bar.className = '';
if (printCfg.user_print_quota === 0) {
// Illimité — pas besoin d'afficher le bandeau
bar.style.display = 'none';
return;
}
const rem = printCfg.remaining_quota;
if (rem <= 0) {
bar.classList.add('quota-empty');
icon.textContent = '🚫';
text.textContent = `Vous avez utilisé votre quota d'impression (${printCfg.user_print_quota} max).`;
} else if (rem === 1) {
bar.classList.add('quota-warn');
icon.textContent = '⚠️';
text.textContent = `Dernière impression disponible pour cette session.`;
} else {
icon.textContent = '🖨';
text.textContent = `${rem} impression(s) restante(s) sur ${printCfg.user_print_quota}.`;
}
}
// ── Photos ────────────────────────────────────────────────────────────────────
async function loadPhotos(page = 1) { async function loadPhotos(page = 1) {
try { try {
const data = await api('GET', `/api/gallery/photos?page=${page}&limit=24`); const data = await api('GET', `/api/gallery/photos?page=${page}&limit=24`);
@@ -80,17 +206,27 @@ async function loadPhotos(page = 1) {
return; return;
} }
const canPrint = printCfg.user_print_enabled &&
(printCfg.user_print_quota === 0 || printCfg.remaining_quota > 0);
grid.innerHTML = photos.map(p => { grid.innerHTML = photos.map(p => {
const copies = p.print_done_copies || 0; const copies = p.print_done_copies || 0;
const printedBadge = copies > 0 const printedBadge = copies > 0
? `<div class="pub-print-badge">🖨 ${copies}×</div>` ? `<div class="pub-print-badge">🖨 ${copies}×</div>`
: ''; : '';
const printBtn = printCfg.user_print_enabled
? `<button class="btn btn-sm ${canPrint ? 'btn-ghost' : ''}"
style="${canPrint ? '' : 'opacity:.4;cursor:not-allowed'}"
onclick="event.stopPropagation();${canPrint ? `askPrint('${p.id}','${p.thumb_url}')` : 'showToast(\'Quota atteint\',\'error\')'}"
title="Imprimer cette photo">🖨</button>`
: '';
return ` return `
<div class="photo-card" onclick="openLightbox('${p.full_url}', '${p.download_url}')"> <div class="photo-card" onclick="openLightbox('${p.full_url}','${p.download_url}','${p.id}','${p.thumb_url}')">
<img src="${p.thumb_url}" loading="lazy" alt="Photo" onerror="this.src='${p.full_url}'"> <img src="${p.thumb_url}" loading="lazy" alt="Photo" onerror="this.src='${p.full_url}'">
${printedBadge} ${printedBadge}
<div class="photo-card-actions"> <div class="photo-card-actions">
<a class="btn btn-primary btn-sm" href="${p.download_url}" onclick="event.stopPropagation()">⬇ Télécharger</a> <a class="btn btn-primary btn-sm" href="${p.download_url}" onclick="event.stopPropagation()">⬇ Télécharger</a>
${printBtn}
</div> </div>
</div>`; </div>`;
}).join(''); }).join('');
@@ -105,9 +241,22 @@ async function loadPhotos(page = 1) {
function changePage(delta) { loadPhotos(currentPage + delta); } function changePage(delta) { loadPhotos(currentPage + delta); }
function openLightbox(fullUrl, downloadUrl) { // ── Lightbox ──────────────────────────────────────────────────────────────────
function openLightbox(fullUrl, downloadUrl, photoId, thumbUrl) {
document.getElementById('lightbox-img').src = fullUrl; document.getElementById('lightbox-img').src = fullUrl;
document.getElementById('lb-download').href = downloadUrl; document.getElementById('lb-download').href = downloadUrl;
const lbPrint = document.getElementById('lb-print');
if (printCfg.user_print_enabled) {
lbPrint.style.display = '';
const canPrint = printCfg.user_print_quota === 0 || printCfg.remaining_quota > 0;
lbPrint.disabled = !canPrint;
lbPrint.title = canPrint ? 'Demander une impression' : 'Quota atteint';
lbPrint.onclick = () => askPrint(photoId, thumbUrl);
} else {
lbPrint.style.display = 'none';
}
document.getElementById('lightbox').classList.add('open'); document.getElementById('lightbox').classList.add('open');
} }
@@ -116,19 +265,102 @@ function closeLightbox(e) {
document.getElementById('lightbox').classList.remove('open'); document.getElementById('lightbox').classList.remove('open');
} }
} }
document.querySelector('.lightbox-close').addEventListener('click', closeLightbox); document.querySelector('.lightbox-close').addEventListener('click', closeLightbox);
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbox({}); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeLightbox({}); closePrintModal(); } });
// ── Modal impression ──────────────────────────────────────────────────────────
function askPrint(photoId, thumbUrl) {
currentPrintPhotoId = photoId;
currentPrintThumb = thumbUrl;
currentCopies = 1;
document.getElementById('pm-preview').src = thumbUrl;
document.getElementById('pm-copies').textContent = '1';
// Afficher le sélecteur de copies seulement si max > 1
const copiesRow = document.getElementById('pm-copies-row');
copiesRow.style.display = printCfg.user_print_max_copies > 1 ? 'flex' : 'none';
// Info quota
const info = document.getElementById('pm-quota-info');
if (printCfg.user_print_quota > 0) {
const rem = printCfg.remaining_quota;
info.textContent = `Il vous reste ${rem} impression(s) pour cette session.`;
} else {
info.textContent = '';
}
document.getElementById('print-modal').classList.add('open');
}
function openPrintModal() { /* appelé depuis le bouton lightbox — déjà configuré via askPrint */ }
function closePrintModal(e) {
if (!e || e.target.id === 'print-modal') {
document.getElementById('print-modal').classList.remove('open');
}
}
function changeCopies(delta) {
currentCopies = Math.max(1, Math.min(printCfg.user_print_max_copies, currentCopies + delta));
document.getElementById('pm-copies').textContent = currentCopies;
}
async function confirmPrint() {
if (!currentPrintPhotoId) return;
const btn = document.getElementById('pm-confirm');
btn.disabled = true;
btn.textContent = '⏳ Envoi…';
try {
const r = await api('POST', '/api/print/request-gallery', {
photo_id: currentPrintPhotoId,
copies: currentCopies,
});
// Mettre à jour le quota local
printCfg.remaining_quota = r.remaining_quota;
printCfg.session_print_count = (printCfg.session_print_count || 0) + 1;
updateQuotaBar();
const modeMsg = r.mode === 'direct'
? 'L\'impression va démarrer.'
: 'Votre demande a bien été transmise à l\'admin.';
showToast(`✅ Demande envoyée ! ${modeMsg}`, 'success', 5000);
closePrintModal();
document.getElementById('lightbox').classList.remove('open');
// Recharger les photos pour mettre à jour le badge imprimé
setTimeout(() => loadPhotos(currentPage), 1500);
} catch(e) {
const msg = e.message || 'Erreur';
if (msg.includes('429')) {
showToast('🚫 Quota atteint — vous ne pouvez plus imprimer pour cette session.', 'error', 6000);
printCfg.remaining_quota = 0;
updateQuotaBar();
} else if (msg.includes('403')) {
showToast('L\'impression est désactivée.', 'error');
} else {
showToast('❌ Erreur : ' + msg, 'error');
}
closePrintModal();
} finally {
btn.disabled = false;
btn.textContent = '🖨 Confirmer';
}
}
// ── WebSocket ─────────────────────────────────────────────────────────────────
function onWsMessage(msg) { function onWsMessage(msg) {
// Rafraîchit la galerie quand une nouvelle photo est ajoutée
if (msg.type === 'photobooth_event' && msg.event === 'finished') { if (msg.type === 'photobooth_event' && msg.event === 'finished') {
setTimeout(() => loadPhotos(1), 2000); setTimeout(() => loadPhotos(1), 2000);
} }
} }
// ── Init ──────────────────────────────────────────────────────────────────────
(async () => {
await loadPrintStatus();
loadPhotos(1); loadPhotos(1);
// Auto-refresh toutes les 30 secondes })();
setInterval(() => loadPhotos(1), 30000);
setInterval(() => loadPhotos(currentPage), 30000);
</script> </script>
{% endblock %} {% endblock %}