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.config import UPLOAD_DIR
from app.database import get_session from app.database import get_session
from app.models.media import Attachment, Media from app.models.media import Attachment, Media
from app.models.settings import UserSettings
class MediaPatch(BaseModel): class MediaPatch(BaseModel):
@@ -102,6 +103,20 @@ def _canonicalize_rows(rows: List[Media], session: Session) -> None:
session.commit() 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: def _save_webp(data: bytes, max_px: int) -> str:
try: try:
from PIL import Image from PIL import Image
@@ -122,12 +137,28 @@ def _save_webp(data: bytes, max_px: int) -> str:
@router.post("/upload") @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) os.makedirs(UPLOAD_DIR, exist_ok=True)
data = await file.read() data = await file.read()
ct = file.content_type or "" 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) thumb = _save_webp(data, 300)
return {"url": f"/uploads/{name}", "thumbnail_url": f"/uploads/{thumb}"} 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 pathlib import Path
from typing import Any from typing import Any
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from starlette.background import BackgroundTask from starlette.background import BackgroundTask
from sqlmodel import Session, select from sqlmodel import Session, select
@@ -235,8 +235,8 @@ def get_debug_system_stats() -> dict[str, Any]:
} }
@router.get("/settings/backup/download") def _create_backup_zip() -> tuple[Path, str]:
def download_backup_zip() -> FileResponse: """Crée l'archive ZIP de sauvegarde. Retourne (chemin_tmp, nom_fichier)."""
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
ts = now.strftime("%Y%m%d_%H%M%S") ts = now.strftime("%Y%m%d_%H%M%S")
db_path = _resolve_sqlite_db_path() db_path = _resolve_sqlite_db_path()
@@ -247,17 +247,12 @@ def download_backup_zip() -> FileResponse:
os.close(fd) os.close(fd)
tmp_zip = Path(tmp_zip_path) tmp_zip = Path(tmp_zip_path)
stats = { stats = {"database_files": 0, "upload_files": 0, "text_files": 0}
"database_files": 0,
"upload_files": 0,
"text_files": 0,
}
with zipfile.ZipFile(tmp_zip, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as zipf: with zipfile.ZipFile(tmp_zip, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as zipf:
if db_path and db_path.is_file(): if db_path and db_path.is_file():
zipf.write(db_path, arcname=f"db/{db_path.name}") zipf.write(db_path, arcname=f"db/{db_path.name}")
stats["database_files"] = 1 stats["database_files"] = 1
stats["upload_files"] = _zip_directory(zipf, uploads_dir, "uploads") stats["upload_files"] = _zip_directory(zipf, uploads_dir, "uploads")
stats["text_files"] = _zip_data_text_files(zipf, data_root, db_path, uploads_dir) 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)) 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( return FileResponse(
path=str(tmp_zip), path=str(tmp_zip),
media_type="application/zip", media_type="application/zip",
filename=download_name, filename=download_name,
background=BackgroundTask(_safe_remove, str(tmp_zip)), 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 pytest==8.3.3
httpx==0.28.0 httpx==0.28.0
Pillow==11.1.0 Pillow==11.1.0
pillow-heif==0.21.0
smbprotocol==1.15.0
skyfield==1.49 skyfield==1.49
pytz==2025.1 pytz==2025.1
numpy==2.2.3 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 } 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> <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"> <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 + 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> </label>
</div> </div>

View File

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

View File

@@ -174,7 +174,7 @@
<div class="grid grid-cols-2 gap-4 pt-2"> <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"> <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' }} {{ 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>
<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"> <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' }} {{ uploadingVideos ? '...' : '🎬 Vidéos' }}

View File

@@ -219,7 +219,7 @@
<div class="bg-bg-soft/30 rounded-3xl p-4 border border-bg-soft"> <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> <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"> <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" /> <img v-if="photoPreview" :src="photoPreview" class="absolute inset-0 w-full h-full object-cover" />
<div v-else class="text-center"> <div v-else class="text-center">
<span class="text-3xl block mb-2">📸</span> <span class="text-3xl block mb-2">📸</span>

View File

@@ -109,7 +109,7 @@
<!-- Upload Photo --> <!-- Upload Photo -->
<div class="bg-bg-soft/30 p-3 rounded-xl border border-bg-soft"> <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> <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" /> <img v-if="photoPreview" :src="photoPreview" class="mt-2 w-full h-24 object-cover rounded border border-bg-hard shadow-lg" />
</div> </div>

View File

@@ -495,7 +495,7 @@
</div> </div>
<!-- Upload Photo --> <!-- 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É ====== --> <!-- ====== 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"> <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> </div>
</section> </section>
<!-- Section Sauvegarde --> <!-- Section Sauvegarde locale -->
<section class="card-jardin flex flex-col h-full border-aqua/20"> <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"> <div class="flex items-center gap-3 mb-6 border-b border-bg-hard pb-4">
<span class="text-2xl">📦</span> <span class="text-2xl">📦</span>
@@ -140,6 +140,114 @@
</div> </div>
</section> </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 API Docs (Largeur double sur XL+) -->
<section class="card-jardin xl:col-span-2 flex flex-col border-bg-soft/50"> <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"> <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 backupMsg = ref('')
const apiBaseUrl = detectApiBaseUrl() 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 --- // --- UI Size settings ---
const uiSizeSettings = [ const uiSizeSettings = [
{ key: 'ui_font_size', label: 'Corps de texte', min: 12, max: 24, step: 1, unit: 'px' }, { 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] if (v != null) uiSizes.value[s.key] = Number(v) || UI_SIZE_DEFAULTS[s.key]
} }
applyUiSizes() 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 { } catch {
// Laisse la valeur locale // 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() { async function saveSettings() {
saving.value = true saving.value = true
savedMsg.value = '' savedMsg.value = ''