Files
photostation/src/services/upload_server.py
Jules d8d7cbde7c Config admin complète + IMAP polling
Config admin :
- Serveur upload : IP d'écoute, port, URL publique (QR code)
- Email IMAP : activer/désactiver, serveur, port, user, password,
  dossier, intervalle de polling
- Checkbox pour activer IMAP

Service IMAP :
- Thread daemon, polling configurable (défaut 30s)
- Connexion IMAP SSL, récupère mails UNSEEN
- Extrait les PJ photos (jpg/png/etc.)
- Sauvegarde dans data/photos/, notifie l'app via signal Qt
- Bouton Email passe au vert + Continuer quand photos reçues

main.py :
- Lit la config pour IP/port du serveur upload
- Démarre IMAP polling si activé dans la config

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 22:34:37 +01:00

329 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Serveur Flask d'upload photos — tourne en thread à côté de PyQt6."""
import os
import threading
import time
from flask import Flask, request, jsonify, render_template_string
from PyQt6.QtCore import QObject, pyqtSignal
UPLOAD_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "data", "photos")
ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png", "bmp", "tiff", "tif", "webp", "heic"}
UPLOAD_PAGE = """<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Photostation — Envoi de photos</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
h1 {
font-size: 28px;
margin: 20px 0 10px;
text-align: center;
}
.subtitle {
color: #666;
font-size: 16px;
margin-bottom: 30px;
text-align: center;
}
.drop-zone {
width: 100%;
max-width: 500px;
min-height: 200px;
border: 3px dashed #2196F3;
border-radius: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 30px;
margin-bottom: 20px;
transition: all 0.3s;
cursor: pointer;
background: white;
}
.drop-zone.dragover {
background: #E3F2FD;
border-color: #1565C0;
transform: scale(1.02);
}
.drop-zone-icon { font-size: 60px; margin-bottom: 10px; }
.drop-zone-text { font-size: 18px; color: #666; text-align: center; }
input[type="file"] { display: none; }
.btn-upload {
width: 100%;
max-width: 500px;
padding: 18px;
font-size: 20px;
font-weight: bold;
color: white;
background: #4CAF50;
border: none;
border-radius: 15px;
cursor: pointer;
margin-bottom: 20px;
transition: background 0.2s;
}
.btn-upload:active { background: #388E3C; }
.btn-upload:disabled { background: #ccc; cursor: default; }
.preview-grid {
width: 100%;
max-width: 500px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 20px;
}
.preview-item {
position: relative;
aspect-ratio: 1;
border-radius: 10px;
overflow: hidden;
background: #eee;
}
.preview-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-item .remove {
position: absolute;
top: 4px;
right: 4px;
width: 28px;
height: 28px;
background: rgba(244, 67, 54, 0.9);
color: white;
border: none;
border-radius: 50%;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.status {
text-align: center;
font-size: 18px;
padding: 15px;
border-radius: 10px;
width: 100%;
max-width: 500px;
margin-bottom: 20px;
display: none;
}
.status.success { display: block; background: #E8F5E9; color: #2E7D32; }
.status.error { display: block; background: #FFEBEE; color: #C62828; }
.status.uploading { display: block; background: #E3F2FD; color: #1565C0; }
.progress-bar {
width: 100%;
max-width: 500px;
height: 8px;
background: #eee;
border-radius: 4px;
overflow: hidden;
margin-bottom: 20px;
display: none;
}
.progress-bar.active { display: block; }
.progress-bar-fill {
height: 100%;
background: #4CAF50;
width: 0%;
transition: width 0.3s;
}
</style>
</head>
<body>
<h1>Photostation</h1>
<p class="subtitle">Sélectionnez vos photos à imprimer</p>
<div class="drop-zone" id="dropZone">
<div class="drop-zone-icon">📷</div>
<div class="drop-zone-text">Appuyez ici pour choisir vos photos<br>ou glissez-les</div>
</div>
<input type="file" id="fileInput" multiple accept="image/*">
<div class="preview-grid" id="previewGrid"></div>
<div class="progress-bar" id="progressBar">
<div class="progress-bar-fill" id="progressFill"></div>
</div>
<div class="status" id="status"></div>
<button class="btn-upload" id="uploadBtn" disabled>Envoyer les photos</button>
<script>
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const previewGrid = document.getElementById('previewGrid');
const uploadBtn = document.getElementById('uploadBtn');
const statusEl = document.getElementById('status');
const progressBar = document.getElementById('progressBar');
const progressFill = document.getElementById('progressFill');
let selectedFiles = [];
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('dragover');
addFiles(e.dataTransfer.files);
});
fileInput.addEventListener('change', () => addFiles(fileInput.files));
function addFiles(files) {
for (const f of files) {
if (f.type.startsWith('image/')) selectedFiles.push(f);
}
updatePreviews();
}
function updatePreviews() {
previewGrid.innerHTML = '';
selectedFiles.forEach((f, i) => {
const div = document.createElement('div');
div.className = 'preview-item';
const img = document.createElement('img');
img.src = URL.createObjectURL(f);
const btn = document.createElement('button');
btn.className = 'remove';
btn.textContent = '×';
btn.onclick = () => { selectedFiles.splice(i, 1); updatePreviews(); };
div.appendChild(img);
div.appendChild(btn);
previewGrid.appendChild(div);
});
uploadBtn.disabled = selectedFiles.length === 0;
uploadBtn.textContent = selectedFiles.length > 0
? `Envoyer ${selectedFiles.length} photo${selectedFiles.length > 1 ? 's' : ''}`
: 'Envoyer les photos';
}
uploadBtn.addEventListener('click', async () => {
if (selectedFiles.length === 0) return;
uploadBtn.disabled = true;
statusEl.className = 'status uploading';
statusEl.textContent = 'Envoi en cours...';
progressBar.classList.add('active');
progressFill.style.width = '0%';
const formData = new FormData();
selectedFiles.forEach(f => formData.append('photos', f));
try {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload');
xhr.upload.onprogress = e => {
if (e.lengthComputable) {
progressFill.style.width = (e.loaded / e.total * 100) + '%';
}
};
xhr.onload = () => {
if (xhr.status === 200) {
const res = JSON.parse(xhr.responseText);
statusEl.className = 'status success';
statusEl.textContent = `${res.count} photo${res.count > 1 ? 's' : ''} envoyée${res.count > 1 ? 's' : ''} ! Vous pouvez retourner à la borne.`;
selectedFiles = [];
updatePreviews();
} else {
statusEl.className = 'status error';
statusEl.textContent = 'Erreur lors de l\\'envoi. Réessayez.';
}
uploadBtn.disabled = false;
progressBar.classList.remove('active');
};
xhr.onerror = () => {
statusEl.className = 'status error';
statusEl.textContent = 'Erreur de connexion. Vérifiez le WiFi.';
uploadBtn.disabled = false;
progressBar.classList.remove('active');
};
xhr.send(formData);
} catch (err) {
statusEl.className = 'status error';
statusEl.textContent = 'Erreur : ' + err.message;
uploadBtn.disabled = false;
progressBar.classList.remove('active');
}
});
</script>
</body>
</html>"""
class UploadSignals(QObject):
"""Signaux Qt pour communiquer du thread Flask vers le thread principal."""
photos_received = pyqtSignal(list) # liste de chemins
class UploadServer:
"""Serveur Flask d'upload, tourne dans un thread séparé."""
def __init__(self, host="0.0.0.0", port=80):
self.host = host
self.port = port
self.signals = UploadSignals()
self.app = Flask(__name__)
self._setup_routes()
self._thread = None
os.makedirs(UPLOAD_DIR, exist_ok=True)
def _setup_routes(self):
@self.app.route("/")
@self.app.route("/upload")
def index():
return render_template_string(UPLOAD_PAGE)
@self.app.route("/upload", methods=["POST"])
def upload():
files = request.files.getlist("photos")
saved = []
for f in files:
if not f.filename:
continue
ext = f.filename.rsplit(".", 1)[-1].lower() if "." in f.filename else ""
if ext not in ALLOWED_EXTENSIONS:
continue
# Nom unique basé sur le timestamp
name = f"{int(time.time() * 1000)}_{f.filename}"
path = os.path.join(UPLOAD_DIR, name)
f.save(path)
saved.append(path)
if saved:
self.signals.photos_received.emit(saved)
return jsonify({"ok": True, "count": len(saved)})
def start(self):
"""Démarre le serveur dans un thread daemon."""
self._thread = threading.Thread(
target=lambda: self.app.run(
host=self.host, port=self.port,
debug=False, use_reloader=False
),
daemon=True
)
self._thread.start()
def stop(self):
"""Le thread daemon s'arrête avec le processus principal."""
pass