This commit is contained in:
2026-03-22 11:42:57 +01:00
parent 2043a1b8b5
commit 7afca6ed04
14 changed files with 300 additions and 22 deletions

View File

@@ -10,6 +10,7 @@ from sqlmodel import Session, select
from app.config import UPLOAD_DIR
from app.database import get_session
from app.models.media import Attachment, Media
from app.models.settings import UserSettings
class MediaPatch(BaseModel):
@@ -102,6 +103,20 @@ def _canonicalize_rows(rows: List[Media], session: Session) -> None:
session.commit()
try:
import pillow_heif
pillow_heif.register_heif_opener()
except ImportError:
pass
def _is_heic(content_type: str, filename: str) -> bool:
if content_type.lower() in ("image/heic", "image/heif"):
return True
fn = (filename or "").lower()
return fn.endswith(".heic") or fn.endswith(".heif")
def _save_webp(data: bytes, max_px: int) -> str:
try:
from PIL import Image
@@ -122,12 +137,28 @@ def _save_webp(data: bytes, max_px: int) -> str:
@router.post("/upload")
async def upload_file(file: UploadFile = File(...)):
async def upload_file(
file: UploadFile = File(...),
session: Session = Depends(get_session),
):
os.makedirs(UPLOAD_DIR, exist_ok=True)
data = await file.read()
ct = file.content_type or ""
if ct.startswith("image/"):
name = _save_webp(data, 1200)
# Lire la largeur max configurée (défaut 1200, 0 = taille originale)
setting = session.exec(select(UserSettings).where(UserSettings.cle == "image_max_width")).first()
max_px = 1200
if setting:
try:
max_px = int(setting.valeur)
except (ValueError, TypeError):
pass
if max_px <= 0:
max_px = 99999
heic = _is_heic(ct, file.filename or "")
if heic or ct.startswith("image/"):
name = _save_webp(data, max_px)
thumb = _save_webp(data, 300)
return {"url": f"/uploads/{name}", "thumbnail_url": f"/uploads/{thumb}"}

View File

@@ -8,7 +8,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from starlette.background import BackgroundTask
from sqlmodel import Session, select
@@ -235,8 +235,8 @@ def get_debug_system_stats() -> dict[str, Any]:
}
@router.get("/settings/backup/download")
def download_backup_zip() -> FileResponse:
def _create_backup_zip() -> tuple[Path, str]:
"""Crée l'archive ZIP de sauvegarde. Retourne (chemin_tmp, nom_fichier)."""
now = datetime.now(timezone.utc)
ts = now.strftime("%Y%m%d_%H%M%S")
db_path = _resolve_sqlite_db_path()
@@ -247,17 +247,12 @@ def download_backup_zip() -> FileResponse:
os.close(fd)
tmp_zip = Path(tmp_zip_path)
stats = {
"database_files": 0,
"upload_files": 0,
"text_files": 0,
}
stats = {"database_files": 0, "upload_files": 0, "text_files": 0}
with zipfile.ZipFile(tmp_zip, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as zipf:
if db_path and db_path.is_file():
zipf.write(db_path, arcname=f"db/{db_path.name}")
stats["database_files"] = 1
stats["upload_files"] = _zip_directory(zipf, uploads_dir, "uploads")
stats["text_files"] = _zip_data_text_files(zipf, data_root, db_path, uploads_dir)
@@ -274,10 +269,66 @@ def download_backup_zip() -> FileResponse:
}
zipf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
download_name = f"jardin_backup_{ts}.zip"
return tmp_zip, f"jardin_backup_{ts}.zip"
@router.get("/settings/backup/download")
def download_backup_zip() -> FileResponse:
tmp_zip, download_name = _create_backup_zip()
return FileResponse(
path=str(tmp_zip),
media_type="application/zip",
filename=download_name,
background=BackgroundTask(_safe_remove, str(tmp_zip)),
)
@router.post("/settings/backup/samba")
def backup_to_samba(session: Session = Depends(get_session)) -> dict[str, Any]:
"""Envoie une sauvegarde ZIP vers un partage Samba/CIFS."""
def _get(key: str, default: str = "") -> str:
row = session.exec(select(UserSettings).where(UserSettings.cle == key)).first()
return row.valeur if row else default
server = _get("samba_serveur").strip()
share = _get("samba_partage").strip()
username = _get("samba_utilisateur").strip()
password = _get("samba_motdepasse")
subfolder = _get("samba_sous_dossier").strip().strip("/\\")
if not server or not share:
raise HTTPException(400, "Configuration Samba incomplète : serveur et partage requis.")
try:
import smbclient # type: ignore
except ImportError:
raise HTTPException(500, "Module smbprotocol non installé dans l'environnement.")
tmp_zip, filename = _create_backup_zip()
try:
smbclient.register_session(server, username=username or None, password=password or None)
remote_dir = f"\\\\{server}\\{share}"
if subfolder:
remote_dir = f"{remote_dir}\\{subfolder}"
try:
smbclient.makedirs(remote_dir, exist_ok=True)
except Exception:
pass
remote_path = f"{remote_dir}\\{filename}"
with open(tmp_zip, "rb") as local_f:
data = local_f.read()
with smbclient.open_file(remote_path, mode="wb") as smb_f:
smb_f.write(data)
return {"ok": True, "fichier": filename, "chemin": remote_path}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(500, f"Erreur Samba : {exc}") from exc
finally:
_safe_remove(str(tmp_zip))

View File

@@ -6,6 +6,8 @@ aiofiles==24.1.0
pytest==8.3.3
httpx==0.28.0
Pillow==11.1.0
pillow-heif==0.21.0
smbprotocol==1.15.0
skyfield==1.49
pytz==2025.1
numpy==2.2.3

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -45,4 +45,6 @@ export const settingsApi = {
}
return { blob: r.data as Blob, filename }
}),
backupSamba: () =>
client.post<{ ok: boolean; fichier: string; chemin: string }>('/api/settings/backup/samba').then(r => r.data),
}

View File

@@ -4,7 +4,7 @@
<span class="text-text-muted text-sm">{{ medias.length }} photo(s)</span>
<label class="cursor-pointer bg-bg-soft text-text-muted hover:text-text px-3 py-1 rounded-lg text-xs border border-bg-hard transition-colors">
+ Photo
<input type="file" accept="image/*" capture="environment" class="hidden" @change="onUpload" />
<input type="file" accept="image/*,.heic,.HEIC,.heif,.HEIF" capture="environment" class="hidden" @change="onUpload" />
</label>
</div>

View File

@@ -16,7 +16,7 @@
Choisir / Photographier
<input
type="file"
accept="image/*"
accept="image/*,.heic,.HEIC,.heif,.HEIF"
capture="environment"
class="hidden"
@change="onFileSelect"

View File

@@ -174,7 +174,7 @@
<div class="grid grid-cols-2 gap-4 pt-2">
<label class="btn-outline border-dashed border-bg-soft flex items-center justify-center gap-2 py-3 cursor-pointer hover:border-yellow hover:text-yellow text-[10px] font-black uppercase transition-all">
{{ uploadingPhotos ? '...' : '📸 Photos' }}
<input type="file" accept="image/*" multiple class="hidden" @change="uploadFiles($event, 'photo')" />
<input type="file" accept="image/*,.heic,.HEIC,.heif,.HEIF" multiple class="hidden" @change="uploadFiles($event, 'photo')" />
</label>
<label class="btn-outline border-dashed border-bg-soft flex items-center justify-center gap-2 py-3 cursor-pointer hover:border-aqua hover:text-aqua text-[10px] font-black uppercase transition-all">
{{ uploadingVideos ? '...' : '🎬 Vidéos' }}

View File

@@ -219,7 +219,7 @@
<div class="bg-bg-soft/30 rounded-3xl p-4 border border-bg-soft">
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-3">Photo de l'espace</label>
<div class="relative group aspect-video rounded-2xl overflow-hidden bg-bg border-2 border-dashed border-bg-soft flex items-center justify-center cursor-pointer hover:border-green transition-all">
<input type="file" accept="image/*" @change="onPhotoSelected" class="absolute inset-0 opacity-0 cursor-pointer z-20" />
<input type="file" accept="image/*,.heic,.HEIC,.heif,.HEIF" @change="onPhotoSelected" class="absolute inset-0 opacity-0 cursor-pointer z-20" />
<img v-if="photoPreview" :src="photoPreview" class="absolute inset-0 w-full h-full object-cover" />
<div v-else class="text-center">
<span class="text-3xl block mb-2">📸</span>

View File

@@ -109,7 +109,7 @@
<!-- Upload Photo -->
<div class="bg-bg-soft/30 p-3 rounded-xl border border-bg-soft">
<label class="text-text-muted text-[9px] font-black uppercase tracking-widest block mb-2">Photo de l'outil</label>
<input type="file" accept="image/*" @change="onPhotoSelected" class="text-[10px] text-text-muted w-full" />
<input type="file" accept="image/*,.heic,.HEIC,.heif,.HEIF" @change="onPhotoSelected" class="text-[10px] text-text-muted w-full" />
<img v-if="photoPreview" :src="photoPreview" class="mt-2 w-full h-24 object-cover rounded border border-bg-hard shadow-lg" />
</div>

View File

@@ -495,7 +495,7 @@
</div>
<!-- Upload Photo -->
<input type="file" ref="fileInput" accept="image/*" class="hidden" @change="handleFileUpload" />
<input type="file" ref="fileInput" accept="image/*,.heic,.HEIC,.heif,.HEIF" class="hidden" @change="handleFileUpload" />
<!-- ====== POPUP FORMULAIRE VARIÉTÉ ====== -->
<div v-if="showFormVariety" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-[70] flex items-center justify-center p-4" @click.self="closeFormVariety">

View File

@@ -112,7 +112,7 @@
</div>
</section>
<!-- Section Sauvegarde -->
<!-- Section Sauvegarde locale -->
<section class="card-jardin flex flex-col h-full border-aqua/20">
<div class="flex items-center gap-3 mb-6 border-b border-bg-hard pb-4">
<span class="text-2xl">📦</span>
@@ -126,7 +126,7 @@
<p class="text-xs text-text-muted leading-relaxed">
Génère une archive complète (.zip) incluant votre base de données SQLite et tous les médias (photos/vidéos) uploadés.
</p>
<button
class="btn-primary !bg-aqua !text-bg w-full py-4 flex flex-col items-center gap-2 shadow-lg hover:shadow-aqua/20"
:disabled="downloadingBackup"
@@ -135,11 +135,119 @@
<span class="text-xl">💾</span>
<span class="text-[10px] font-black uppercase tracking-widest">{{ downloadingBackup ? 'Préparation...' : 'Télécharger le Pack Complet' }}</span>
</button>
<div v-if="backupMsg" class="text-[10px] text-center font-bold text-aqua animate-bounce">{{ backupMsg }}</div>
</div>
</section>
<!-- Section Images -->
<section class="card-jardin flex flex-col h-full border-orange/20">
<div class="flex items-center gap-3 mb-6 border-b border-bg-hard pb-4">
<span class="text-2xl">🖼️</span>
<div>
<h2 class="text-text font-bold uppercase tracking-widest text-xs">Images</h2>
<p class="text-[10px] text-text-muted font-bold">Qualité et taille des photos importées.</p>
</div>
</div>
<div class="flex-1 space-y-6">
<div class="space-y-3">
<div class="flex justify-between items-center">
<label class="text-[10px] font-black uppercase tracking-widest text-text-muted">Largeur max des photos</label>
<span class="text-xs font-mono text-orange">{{ imageMaxWidthLabel }}</span>
</div>
<div class="grid grid-cols-3 gap-2">
<button
v-for="opt in imageWidthOptions"
:key="opt.value"
@click="imageMaxWidth = opt.value"
:class="[
'py-2 px-1 rounded-xl text-[10px] font-black uppercase tracking-widest border transition-all',
imageMaxWidth === opt.value
? 'bg-orange/20 border-orange text-orange'
: 'border-bg-soft text-text-muted hover:border-orange/40'
]"
>{{ opt.label }}</button>
</div>
<p class="text-[10px] text-text-muted leading-relaxed italic">
S'applique aux nouvelles photos importées. Les miniatures restent à 300 px.
</p>
</div>
</div>
<div class="mt-8 pt-4 border-t border-bg-hard flex items-center justify-end gap-3">
<span v-if="imageSavedMsg" class="text-[10px] font-bold text-aqua">{{ imageSavedMsg }}</span>
<button
class="btn-primary !bg-orange !text-bg !py-2 !px-6 text-xs"
:disabled="savingImage"
@click="saveImageSettings"
>{{ savingImage ? '...' : 'Enregistrer' }}</button>
</div>
</section>
<!-- Section Sauvegarde Samba -->
<section class="card-jardin flex flex-col h-full border-purple/20">
<div class="flex items-center gap-3 mb-6 border-b border-bg-hard pb-4">
<span class="text-2xl">🗄</span>
<div>
<h2 class="text-text font-bold uppercase tracking-widest text-xs">Sauvegarde Samba / NAS</h2>
<p class="text-[10px] text-text-muted font-bold">Envoi automatique vers un partage réseau.</p>
</div>
</div>
<div class="flex-1 space-y-4">
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1 col-span-2 sm:col-span-1">
<label class="text-[10px] font-black uppercase tracking-widest text-text-muted">Serveur (IP ou nom)</label>
<input v-model="samba.serveur" type="text" placeholder="192.168.1.10"
class="input-jardin w-full text-xs" />
</div>
<div class="space-y-1 col-span-2 sm:col-span-1">
<label class="text-[10px] font-black uppercase tracking-widest text-text-muted">Partage</label>
<input v-model="samba.partage" type="text" placeholder="Jardin"
class="input-jardin w-full text-xs" />
</div>
<div class="space-y-1 col-span-2">
<label class="text-[10px] font-black uppercase tracking-widest text-text-muted">Sous-dossier (optionnel)</label>
<input v-model="samba.sous_dossier" type="text" placeholder="backups/jardin"
class="input-jardin w-full text-xs" />
</div>
<div class="space-y-1 col-span-2 sm:col-span-1">
<label class="text-[10px] font-black uppercase tracking-widest text-text-muted">Utilisateur</label>
<input v-model="samba.utilisateur" type="text" placeholder="user"
class="input-jardin w-full text-xs" />
</div>
<div class="space-y-1 col-span-2 sm:col-span-1">
<label class="text-[10px] font-black uppercase tracking-widest text-text-muted">Mot de passe</label>
<input v-model="samba.motdepasse" type="password" placeholder="••••••••"
class="input-jardin w-full text-xs" />
</div>
</div>
<div v-if="sambaSendMsg" :class="['text-[10px] text-center font-bold p-2 rounded-lg', sambaError ? 'text-red bg-red/10' : 'text-green bg-green/10']">
{{ sambaSendMsg }}
</div>
</div>
<div class="mt-6 pt-4 border-t border-bg-hard flex flex-col gap-3">
<div class="flex items-center justify-end gap-3">
<span v-if="sambaSavedMsg" class="text-[10px] font-bold text-aqua">{{ sambaSavedMsg }}</span>
<button class="btn-outline !py-2 !px-4 text-xs border-purple/40 text-purple hover:bg-purple/10"
:disabled="savingSamba" @click="saveSambaSettings">
{{ savingSamba ? '...' : 'Enregistrer la config' }}
</button>
</div>
<button
class="btn-primary w-full py-3 flex items-center justify-center gap-2 text-xs"
:disabled="sendingSamba || !samba.serveur || !samba.partage"
@click="sendSambaBackup"
>
<span>{{ sendingSamba ? '⏳' : '📤' }}</span>
<span class="font-black uppercase tracking-widest text-[10px]">{{ sendingSamba ? 'Envoi en cours...' : 'Envoyer un backup maintenant' }}</span>
</button>
</div>
</section>
<!-- Section API Docs (Largeur double sur XL+) -->
<section class="card-jardin xl:col-span-2 flex flex-col border-bg-soft/50">
<div class="flex items-center gap-3 mb-6 border-b border-bg-hard pb-4">
@@ -188,6 +296,32 @@ const downloadingBackup = ref(false)
const backupMsg = ref('')
const apiBaseUrl = detectApiBaseUrl()
// --- Image max width ---
const imageWidthOptions = [
{ value: 400, label: '400 px' },
{ value: 600, label: '600 px' },
{ value: 800, label: '800 px' },
{ value: 1200, label: '1200 px' },
{ value: 1600, label: '1600 px' },
{ value: 2400, label: '2400 px' },
{ value: 0, label: 'Originale' },
]
const imageMaxWidth = ref(1200)
const imageMaxWidthLabel = computed(() => {
const opt = imageWidthOptions.find(o => o.value === imageMaxWidth.value)
return opt ? opt.label : `${imageMaxWidth.value} px`
})
const savingImage = ref(false)
const imageSavedMsg = ref('')
// --- Samba ---
const samba = ref({ serveur: '', partage: '', sous_dossier: '', utilisateur: '', motdepasse: '' })
const savingSamba = ref(false)
const sambaSavedMsg = ref('')
const sendingSamba = ref(false)
const sambaSendMsg = ref('')
const sambaError = ref(false)
// --- UI Size settings ---
const uiSizeSettings = [
{ key: 'ui_font_size', label: 'Corps de texte', min: 12, max: 24, step: 1, unit: 'px' },
@@ -276,11 +410,69 @@ async function loadSettings() {
if (v != null) uiSizes.value[s.key] = Number(v) || UI_SIZE_DEFAULTS[s.key]
}
applyUiSizes()
if (data.image_max_width != null) imageMaxWidth.value = Number(data.image_max_width) || 1200
if (data.samba_serveur != null) samba.value.serveur = data.samba_serveur
if (data.samba_partage != null) samba.value.partage = data.samba_partage
if (data.samba_sous_dossier != null) samba.value.sous_dossier = data.samba_sous_dossier
if (data.samba_utilisateur != null) samba.value.utilisateur = data.samba_utilisateur
if (data.samba_motdepasse != null) samba.value.motdepasse = data.samba_motdepasse
} catch {
// Laisse la valeur locale
}
}
async function saveImageSettings() {
savingImage.value = true
imageSavedMsg.value = ''
try {
await settingsApi.update({ image_max_width: String(imageMaxWidth.value) })
imageSavedMsg.value = 'Enregistré'
setTimeout(() => { imageSavedMsg.value = '' }, 1800)
} catch {
imageSavedMsg.value = 'Erreur.'
setTimeout(() => { imageSavedMsg.value = '' }, 2200)
} finally {
savingImage.value = false
}
}
async function saveSambaSettings() {
savingSamba.value = true
sambaSavedMsg.value = ''
try {
await settingsApi.update({
samba_serveur: samba.value.serveur,
samba_partage: samba.value.partage,
samba_sous_dossier: samba.value.sous_dossier,
samba_utilisateur: samba.value.utilisateur,
samba_motdepasse: samba.value.motdepasse,
})
sambaSavedMsg.value = 'Enregistré'
setTimeout(() => { sambaSavedMsg.value = '' }, 1800)
} catch {
sambaSavedMsg.value = 'Erreur.'
setTimeout(() => { sambaSavedMsg.value = '' }, 2200)
} finally {
savingSamba.value = false
}
}
async function sendSambaBackup() {
sendingSamba.value = true
sambaSendMsg.value = ''
sambaError.value = false
try {
const res = await settingsApi.backupSamba()
sambaSendMsg.value = `Envoyé : ${res.fichier}`
} catch (err: any) {
sambaError.value = true
sambaSendMsg.value = err?.response?.data?.detail || 'Erreur lors de l\'envoi Samba.'
} finally {
sendingSamba.value = false
setTimeout(() => { sambaSendMsg.value = '' }, 4000)
}
}
async function saveSettings() {
saving.value = true
savedMsg.value = ''