feat: export Markdown notes (ARQ/Redis) + backup/restore BDD — v0.5.2
- Volume data/ (bind mount ./data) remplace le volume uploads nommé
data/notes/ → .md auto-générés, data/uploads/ → médias, data/backup/ → dumps
- Service Redis (redis:7-alpine) + worker ARQ (backend-worker)
- notes_markdown.py : frontmatter YAML + contenu + pièces jointes (liens relatifs)
Nom : YYYY-MM-DD_slug-titre_shortid.md, rotation si titre modifié
- api/notes.py : publie export_note_markdown / remove_note_markdown sur Redis
après chaque create / update / delete / add_attachment / delete_attachment
- api/admin.py : POST /backup, GET /backups, POST /restore/{filename} (pg_dump/pg_restore)
- Backend Dockerfile : postgresql-client ; requirements : arq==0.26.1
- ConfigPage : section "Base de données" avec sauvegarde + liste + restauration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,3 +8,7 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
screenshot.png
|
screenshot.png
|
||||||
*.png
|
*.png
|
||||||
|
# Données utilisateur — ignorées sauf la structure
|
||||||
|
data/notes/*.md
|
||||||
|
data/backup/*.dump
|
||||||
|
data/uploads/
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ FROM python:3.12-slim
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libpq-dev gcc \
|
libpq-dev gcc postgresql-client \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _pg_env() -> dict:
|
||||||
|
url = urlparse(settings.database_url.replace("+asyncpg", ""))
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PGPASSWORD"] = url.password or ""
|
||||||
|
return env, url
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/backup")
|
||||||
|
async def create_backup():
|
||||||
|
backup_dir = settings.backup_path
|
||||||
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M")
|
||||||
|
filename = f"homehub_{timestamp}.dump"
|
||||||
|
filepath = backup_dir / filename
|
||||||
|
|
||||||
|
env, url = _pg_env()
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"pg_dump", "-Fc",
|
||||||
|
"-h", url.hostname or "db",
|
||||||
|
"-p", str(url.port or 5432),
|
||||||
|
"-U", url.username or "homehub",
|
||||||
|
"-d", (url.path or "/homehub").lstrip("/"),
|
||||||
|
"-f", str(filepath),
|
||||||
|
env=env,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
_, stderr = await proc.communicate()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise HTTPException(500, f"Échec du backup : {stderr.decode()}")
|
||||||
|
|
||||||
|
stat = filepath.stat()
|
||||||
|
return {
|
||||||
|
"filename": filename,
|
||||||
|
"size": stat.st_size,
|
||||||
|
"created_at": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/backups")
|
||||||
|
async def list_backups():
|
||||||
|
backup_dir = settings.backup_path
|
||||||
|
if not backup_dir.exists():
|
||||||
|
return []
|
||||||
|
files = sorted(backup_dir.glob("*.dump"), key=lambda f: f.stat().st_mtime, reverse=True)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"filename": f.name,
|
||||||
|
"size": f.stat().st_size,
|
||||||
|
"created_at": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
|
||||||
|
}
|
||||||
|
for f in files
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/restore/{filename}")
|
||||||
|
async def restore_backup(filename: str):
|
||||||
|
# Sécurité : interdit les chemins relatifs
|
||||||
|
if "/" in filename or ".." in filename or not filename.endswith(".dump"):
|
||||||
|
raise HTTPException(400, "Nom de fichier invalide")
|
||||||
|
|
||||||
|
filepath = settings.backup_path / filename
|
||||||
|
if not filepath.exists():
|
||||||
|
raise HTTPException(404, "Fichier introuvable")
|
||||||
|
|
||||||
|
env, url = _pg_env()
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"pg_restore", "--clean", "--if-exists", "--no-owner", "--no-privileges",
|
||||||
|
"-h", url.hostname or "db",
|
||||||
|
"-p", str(url.port or 5432),
|
||||||
|
"-U", url.username or "homehub",
|
||||||
|
"-d", (url.path or "/homehub").lstrip("/"),
|
||||||
|
str(filepath),
|
||||||
|
env=env,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
_, stderr = await proc.communicate()
|
||||||
|
# pg_restore retourne 1 pour des warnings non-fatals (DROP IF EXISTS sur objets manquants)
|
||||||
|
if proc.returncode not in (0, 1):
|
||||||
|
raise HTTPException(500, f"Échec de la restauration : {stderr.decode()}")
|
||||||
|
|
||||||
|
return {"message": "Restauration réussie"}
|
||||||
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.core.database import get_session
|
from app.core.database import get_session
|
||||||
|
from app.core.redis import enqueue
|
||||||
from app.models.notes import NoteItem, NoteAttachment
|
from app.models.notes import NoteItem, NoteAttachment
|
||||||
from app.schemas.notes import NoteCreate, NoteUpdate, NoteResponse
|
from app.schemas.notes import NoteCreate, NoteUpdate, NoteResponse
|
||||||
from app.services.media import save_image, save_audio, delete_media, ALLOWED_IMAGE_TYPES, ALLOWED_AUDIO_TYPES
|
from app.services.media import save_image, save_audio, delete_media, ALLOWED_IMAGE_TYPES, ALLOWED_AUDIO_TYPES
|
||||||
@@ -75,6 +76,7 @@ async def create_note(payload: NoteCreate, session: AsyncSession = Depends(get_s
|
|||||||
await session.refresh(note, ["attachments"])
|
await session.refresh(note, ["attachments"])
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(note, ["attachments"])
|
await session.refresh(note, ["attachments"])
|
||||||
|
await enqueue("export_note_markdown", str(note.id))
|
||||||
return note
|
return note
|
||||||
|
|
||||||
|
|
||||||
@@ -97,6 +99,7 @@ async def update_note(
|
|||||||
setattr(note, field, value)
|
setattr(note, field, value)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(note, ["attachments"])
|
await session.refresh(note, ["attachments"])
|
||||||
|
await enqueue("export_note_markdown", str(note.id))
|
||||||
return note
|
return note
|
||||||
|
|
||||||
|
|
||||||
@@ -107,6 +110,7 @@ async def delete_note(note_id: uuid.UUID, session: AsyncSession = Depends(get_se
|
|||||||
raise HTTPException(404, "Note introuvable")
|
raise HTTPException(404, "Note introuvable")
|
||||||
await session.delete(note)
|
await session.delete(note)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
await enqueue("remove_note_markdown", str(note_id))
|
||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
@@ -145,6 +149,7 @@ async def add_attachment(
|
|||||||
session.add(att)
|
session.add(att)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(note, ["attachments"])
|
await session.refresh(note, ["attachments"])
|
||||||
|
await enqueue("export_note_markdown", str(note_id))
|
||||||
return note
|
return note
|
||||||
|
|
||||||
|
|
||||||
@@ -170,4 +175,5 @@ async def delete_attachment(
|
|||||||
)
|
)
|
||||||
await session.delete(att)
|
await session.delete(att)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
await enqueue("export_note_markdown", str(note_id))
|
||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ class Settings(BaseSettings):
|
|||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||||
|
|
||||||
database_url: str = "postgresql+asyncpg://homehub:homehub@localhost:5432/homehub"
|
database_url: str = "postgresql+asyncpg://homehub:homehub@localhost:5432/homehub"
|
||||||
upload_dir: str = "/uploads"
|
upload_dir: str = "/data/uploads"
|
||||||
|
data_dir: str = "/data"
|
||||||
|
redis_url: str = "redis://redis:6379"
|
||||||
cors_origins: str = "http://localhost:3000"
|
cors_origins: str = "http://localhost:3000"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -17,5 +19,13 @@ class Settings(BaseSettings):
|
|||||||
def upload_path(self) -> Path:
|
def upload_path(self) -> Path:
|
||||||
return Path(self.upload_dir)
|
return Path(self.upload_dir)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def notes_md_path(self) -> Path:
|
||||||
|
return Path(self.data_dir) / "notes"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def backup_path(self) -> Path:
|
||||||
|
return Path(self.data_dir) / "backup"
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import logging
|
||||||
|
from arq import create_pool
|
||||||
|
from arq.connections import ArqRedis, RedisSettings
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
_pool: ArqRedis | None = None
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def init_redis() -> None:
|
||||||
|
global _pool
|
||||||
|
try:
|
||||||
|
_pool = await create_pool(RedisSettings.from_dsn(settings.redis_url))
|
||||||
|
logger.info("Redis pool initialisé")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Redis indisponible, export Markdown désactivé : {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def close_redis() -> None:
|
||||||
|
global _pool
|
||||||
|
if _pool:
|
||||||
|
await _pool.close()
|
||||||
|
_pool = None
|
||||||
|
|
||||||
|
|
||||||
|
async def enqueue(job_name: str, *args) -> None:
|
||||||
|
if _pool is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await _pool.enqueue_job(job_name, *args)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Redis enqueue échoué ({job_name}) : {e}")
|
||||||
@@ -1,20 +1,28 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from app.api.admin import router as admin_router
|
||||||
from app.api.health import router as health_router
|
from app.api.health import router as health_router
|
||||||
from app.api.media import router as media_router
|
from app.api.media import router as media_router
|
||||||
from app.api.notes import router as notes_router
|
from app.api.notes import router as notes_router
|
||||||
from app.api.todos import router as todos_router
|
from app.api.todos import router as todos_router
|
||||||
from app.api.shopping import router as shopping_router
|
from app.api.shopping import router as shopping_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.redis import init_redis, close_redis
|
||||||
from app.data.seed import run_seed
|
from app.data.seed import run_seed
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
# Crée les dossiers data/ au démarrage
|
||||||
|
for subdir in ("uploads", "notes", "backup"):
|
||||||
|
Path(settings.data_dir, subdir).mkdir(parents=True, exist_ok=True)
|
||||||
await run_seed()
|
await run_seed()
|
||||||
|
await init_redis()
|
||||||
yield
|
yield
|
||||||
|
await close_redis()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="HomeHub API", version="0.1.0", lifespan=lifespan)
|
app = FastAPI(title="HomeHub API", version="0.1.0", lifespan=lifespan)
|
||||||
@@ -28,6 +36,7 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(health_router, prefix="/api")
|
app.include_router(health_router, prefix="/api")
|
||||||
|
app.include_router(admin_router, prefix="/api/admin")
|
||||||
app.include_router(media_router, prefix="/api/media")
|
app.include_router(media_router, prefix="/api/media")
|
||||||
app.include_router(notes_router, prefix="/api/notes")
|
app.include_router(notes_router, prefix="/api/notes")
|
||||||
app.include_router(todos_router, prefix="/api/todos")
|
app.include_router(todos_router, prefix="/api/todos")
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.notes import NoteItem
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify(text: str, max_len: int = 50) -> str:
|
||||||
|
text = unicodedata.normalize("NFD", text)
|
||||||
|
text = "".join(c for c in text if unicodedata.category(c) != "Mn")
|
||||||
|
text = text.lower()
|
||||||
|
text = re.sub(r"[^\w\s-]", "", text)
|
||||||
|
text = re.sub(r"[\s_-]+", "-", text)
|
||||||
|
return text.strip("-")[:max_len]
|
||||||
|
|
||||||
|
|
||||||
|
def _note_filename(note: NoteItem) -> str:
|
||||||
|
date = note.created_at.strftime("%Y-%m-%d")
|
||||||
|
raw = note.title or note.content[:40]
|
||||||
|
slug = _slugify(raw) or "sans-titre"
|
||||||
|
short_id = str(note.id)[:8]
|
||||||
|
return f"{date}_{slug}_{short_id}.md"
|
||||||
|
|
||||||
|
|
||||||
|
def _render(note: NoteItem) -> str:
|
||||||
|
lines = ["---"]
|
||||||
|
lines.append(f"id: {note.id}")
|
||||||
|
if note.title:
|
||||||
|
lines.append(f'title: "{note.title}"')
|
||||||
|
if note.category:
|
||||||
|
lines.append(f"category: {note.category}")
|
||||||
|
if note.tags:
|
||||||
|
tags_str = ", ".join(f'"{t}"' for t in note.tags)
|
||||||
|
lines.append(f"tags: [{tags_str}]")
|
||||||
|
lines.append(f"created_at: {note.created_at.isoformat()}")
|
||||||
|
if note.gps_lat is not None and note.gps_lon is not None:
|
||||||
|
lines.append(f'gps: "{note.gps_lat}, {note.gps_lon}"')
|
||||||
|
lines.append("---")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(note.content)
|
||||||
|
|
||||||
|
images = [a for a in note.attachments if a.file_type == "image" and a.file_path]
|
||||||
|
audios = [a for a in note.attachments if a.file_type == "audio" and a.file_path]
|
||||||
|
|
||||||
|
if images or audios:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## Pièces jointes")
|
||||||
|
lines.append("")
|
||||||
|
for att in images:
|
||||||
|
# file_path est relatif à UPLOAD_DIR, ex: "images/originals/uuid.webp"
|
||||||
|
rel = f"../uploads/{att.file_path}"
|
||||||
|
name = att.original_name or "image"
|
||||||
|
lines.append(f"")
|
||||||
|
for att in audios:
|
||||||
|
rel = f"../uploads/{att.file_path}"
|
||||||
|
name = att.original_name or "audio"
|
||||||
|
lines.append(f"[{name}]({rel})")
|
||||||
|
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def save_note_markdown(note: NoteItem) -> None:
|
||||||
|
notes_dir = settings.notes_md_path
|
||||||
|
notes_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
short_id = str(note.id)[:8]
|
||||||
|
# Supprime l'ancienne version (titre peut avoir changé)
|
||||||
|
for old in notes_dir.glob(f"*_{short_id}.md"):
|
||||||
|
old.unlink()
|
||||||
|
(notes_dir / _note_filename(note)).write_text(_render(note), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def delete_note_markdown(note_id: str) -> None:
|
||||||
|
notes_dir = settings.notes_md_path
|
||||||
|
if not notes_dir.exists():
|
||||||
|
return
|
||||||
|
short_id = note_id[:8]
|
||||||
|
for f in notes_dir.glob(f"*_{short_id}.md"):
|
||||||
|
f.unlink()
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from arq.connections import RedisSettings
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import AsyncSessionLocal
|
||||||
|
from app.models.notes import NoteItem
|
||||||
|
from app.services.notes_markdown import delete_note_markdown, save_note_markdown
|
||||||
|
|
||||||
|
|
||||||
|
async def export_note_markdown(ctx: dict, note_id: str) -> None:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
stmt = (
|
||||||
|
select(NoteItem)
|
||||||
|
.where(NoteItem.id == note_id)
|
||||||
|
.options(selectinload(NoteItem.attachments))
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
note = result.scalar_one_or_none()
|
||||||
|
if note:
|
||||||
|
save_note_markdown(note)
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_note_markdown(ctx: dict, note_id: str) -> None:
|
||||||
|
delete_note_markdown(note_id)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerSettings:
|
||||||
|
functions = [export_note_markdown, remove_note_markdown]
|
||||||
|
redis_settings = RedisSettings.from_dsn(settings.redis_url)
|
||||||
@@ -7,5 +7,6 @@ pydantic-settings==2.6.1
|
|||||||
pillow==11.1.0
|
pillow==11.1.0
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
httpx==0.28.0
|
httpx==0.28.0
|
||||||
|
arq==0.26.1
|
||||||
pytest==8.3.4
|
pytest==8.3.4
|
||||||
pytest-asyncio==0.24.0
|
pytest-asyncio==0.24.0
|
||||||
|
|||||||
+34
-3
@@ -13,20 +13,51 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://homehub:homehub@db:5432/homehub
|
DATABASE_URL: postgresql+asyncpg://homehub:homehub@db:5432/homehub
|
||||||
UPLOAD_DIR: /uploads
|
UPLOAD_DIR: /data/uploads
|
||||||
|
DATA_DIR: /data
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
CORS_ORIGINS: http://localhost:3001,http://localhost:3000
|
CORS_ORIGINS: http://localhost:3001,http://localhost:3000
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/app:/app/app
|
- ./backend/app:/app/app
|
||||||
- uploads:/uploads
|
- ./data:/data
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
backend-worker:
|
||||||
|
build: ./backend
|
||||||
|
command: arq app.workers.notes_worker.WorkerSettings
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://homehub:homehub@db:5432/homehub
|
||||||
|
UPLOAD_DIR: /data/uploads
|
||||||
|
DATA_DIR: /data
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
volumes:
|
||||||
|
- ./backend/app:/app/app
|
||||||
|
- ./data:/data
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
@@ -37,4 +68,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
db_data:
|
||||||
uploads:
|
redis_data:
|
||||||
|
|||||||
+34
-5
@@ -177,7 +177,36 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 5 — Scan produits + enrichissement catalogue
|
## Phase 5 — Export Markdown notes + Backup BDD ✅
|
||||||
|
|
||||||
|
**Objectif** : persistance double des notes (BDD source de vérité + fichiers Markdown partagés), infrastructure Redis, backup/restore depuis l'interface.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- Volume Docker `./data/` (bind mount) remplace le volume nommé `uploads`
|
||||||
|
- `data/notes/` — fichiers `.md` auto-générés
|
||||||
|
- `data/uploads/` — médias (photos, audio)
|
||||||
|
- `data/backup/` — dumps PostgreSQL
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- [x] Service Redis : `redis:7-alpine` dans docker-compose
|
||||||
|
- [x] Worker ARQ (`backend-worker`) : consomme la queue `notes:markdown`, même image que backend
|
||||||
|
- [x] `app/core/redis.py` — pool ARQ, `enqueue()` best-effort (silence si Redis down)
|
||||||
|
- [x] `app/workers/notes_worker.py` — tâches `export_note_markdown` + `remove_note_markdown`
|
||||||
|
- [x] `app/services/notes_markdown.py` — génère le frontmatter YAML + contenu + pièces jointes (liens relatifs `../uploads/…`)
|
||||||
|
- Nom de fichier : `YYYY-MM-DD_slug-du-titre_shortid.md`
|
||||||
|
- Rotation automatique si titre modifié (recherche par `*_{shortid}.md`)
|
||||||
|
- [x] `app/api/notes.py` — publie sur Redis après create / update / delete / add_attachment / delete_attachment
|
||||||
|
- [x] `app/api/admin.py` — `POST /api/admin/backup`, `GET /api/admin/backups`, `POST /api/admin/restore/{filename}`
|
||||||
|
- [x] `backend/Dockerfile` — ajout `postgresql-client` pour pg_dump / pg_restore
|
||||||
|
- [x] `requirements.txt` — ajout `arq==0.26.1`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- [x] `frontend/src/api/admin.ts` — client TypeScript backup/restore
|
||||||
|
- [x] Page Config — section "Base de données" : bouton sauvegarde + liste des dumps + bouton Restaurer par fichier
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 — Scan produits + enrichissement catalogue
|
||||||
|
|
||||||
**Objectif** : scan code-barres depuis mobile, auto-remplissage depuis OpenFoodFacts.
|
**Objectif** : scan code-barres depuis mobile, auto-remplissage depuis OpenFoodFacts.
|
||||||
|
|
||||||
@@ -203,7 +232,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 6 — Service OCR (conteneur dédié)
|
## Phase 7 — Service OCR (conteneur dédié)
|
||||||
|
|
||||||
**Objectif** : OCR partagé, prérequis pour shopping avancé et notes avancées.
|
**Objectif** : OCR partagé, prérequis pour shopping avancé et notes avancées.
|
||||||
|
|
||||||
@@ -214,7 +243,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 7 — Shopping avancé (OCR + suivi prix)
|
## Phase 8 — Shopping avancé (OCR + suivi prix)
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
- [ ] `POST /api/shopping/ocr/price-tag` — photo étiquette → extraction prix
|
- [ ] `POST /api/shopping/ocr/price-tag` — photo étiquette → extraction prix
|
||||||
@@ -228,7 +257,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 8 — MCP Server
|
## Phase 9 — MCP Server
|
||||||
|
|
||||||
**Objectif** : exposer les outils HomeHub aux agents IA (Hermes, Claude, etc.).
|
**Objectif** : exposer les outils HomeHub aux agents IA (Hermes, Claude, etc.).
|
||||||
|
|
||||||
@@ -262,5 +291,5 @@
|
|||||||
## Ordre de développement
|
## Ordre de développement
|
||||||
|
|
||||||
```
|
```
|
||||||
Phase 1 ✅ → Phase 2 ✅ → Phase 3 ✅ → Phase 4 ✅ → Phase 4b ✅ → Phase 5 (Scan) → Phase 6 (OCR) → Phase 7 (Shopping avancé) → Phase 8 (MCP)
|
Phase 1 ✅ → Phase 2 ✅ → Phase 3 ✅ → Phase 4 ✅ → Phase 4b ✅ → Phase 5 ✅ → Phase 6 (Scan) → Phase 7 (OCR) → Phase 8 (Shopping avancé) → Phase 9 (MCP)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "homehub-frontend",
|
"name": "homehub-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export interface BackupFile {
|
||||||
|
filename: string
|
||||||
|
size: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBackups(): Promise<BackupFile[]> {
|
||||||
|
const res = await fetch('/api/admin/backups')
|
||||||
|
if (!res.ok) throw new Error('Erreur chargement sauvegardes')
|
||||||
|
return res.json() as Promise<BackupFile[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBackup(): Promise<BackupFile> {
|
||||||
|
const res = await fetch('/api/admin/backup', { method: 'POST' })
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({})) as { detail?: string }
|
||||||
|
throw new Error(err.detail ?? 'Erreur lors du backup')
|
||||||
|
}
|
||||||
|
return res.json() as Promise<BackupFile>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreBackup(filename: string): Promise<void> {
|
||||||
|
const res = await fetch(`/api/admin/restore/${encodeURIComponent(filename)}`, { method: 'POST' })
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({})) as { detail?: string }
|
||||||
|
throw new Error(err.detail ?? 'Erreur lors de la restauration')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useTheme, type ThemeMode } from '../contexts/ThemeContext'
|
import { useTheme, type ThemeMode } from '../contexts/ThemeContext'
|
||||||
|
import { fetchBackups, createBackup, restoreBackup, type BackupFile } from '../api/admin'
|
||||||
|
|
||||||
const sectionStyle: React.CSSProperties = {
|
const sectionStyle: React.CSSProperties = {
|
||||||
background: 'var(--bg-3)',
|
background: 'var(--bg-3)',
|
||||||
@@ -31,9 +33,53 @@ const FONT_LABELS: Record<string, string> = {
|
|||||||
'1.25': 'XL', '1.3': 'XL+', '1.35': 'XXL', '1.4': 'XXL+',
|
'1.25': 'XL', '1.3': 'XL+', '1.35': 'XXL', '1.4': 'XXL+',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} o`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} Mo`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' })
|
||||||
|
}
|
||||||
|
|
||||||
export default function ConfigPage() {
|
export default function ConfigPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { theme, setTheme, fontScale, setFontScale } = useTheme()
|
const { theme, setTheme, fontScale, setFontScale } = useTheme()
|
||||||
|
const [backups, setBackups] = useState<BackupFile[]>([])
|
||||||
|
const [backupLoading, setBackupLoading] = useState(false)
|
||||||
|
const [restoring, setRestoring] = useState<string | null>(null)
|
||||||
|
const [backupError, setBackupError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBackups().then(setBackups).catch(() => setBackups([]))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function handleCreateBackup() {
|
||||||
|
setBackupLoading(true)
|
||||||
|
setBackupError(null)
|
||||||
|
try {
|
||||||
|
const b = await createBackup()
|
||||||
|
setBackups(prev => [b, ...prev])
|
||||||
|
} catch (e) {
|
||||||
|
setBackupError((e as Error).message)
|
||||||
|
} finally {
|
||||||
|
setBackupLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRestore(filename: string) {
|
||||||
|
if (!confirm(`Restaurer "${filename}" ? L'état actuel de la base sera remplacé.`)) return
|
||||||
|
setRestoring(filename)
|
||||||
|
setBackupError(null)
|
||||||
|
try {
|
||||||
|
await restoreBackup(filename)
|
||||||
|
} catch (e) {
|
||||||
|
setBackupError((e as Error).message)
|
||||||
|
} finally {
|
||||||
|
setRestoring(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const scaleLabel = FONT_LABELS[String(fontScale)] ?? `${Math.round(fontScale * 100)}%`
|
const scaleLabel = FONT_LABELS[String(fontScale)] ?? `${Math.round(fontScale * 100)}%`
|
||||||
|
|
||||||
@@ -86,6 +132,76 @@ export default function ConfigPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sauvegarde & Restauration */}
|
||||||
|
<div style={sectionStyle}>
|
||||||
|
<div style={labelStyle}>Base de données</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateBackup}
|
||||||
|
disabled={backupLoading}
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px', borderRadius: 8, border: 'none',
|
||||||
|
background: 'var(--ok)', color: '#1d2021',
|
||||||
|
fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600,
|
||||||
|
cursor: backupLoading ? 'default' : 'pointer', minHeight: 44,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||||
|
opacity: backupLoading ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-database" />
|
||||||
|
{backupLoading ? 'Sauvegarde en cours…' : 'Créer une sauvegarde'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{backupError && (
|
||||||
|
<div style={{ color: 'var(--err)', fontFamily: 'var(--font-ui)', fontSize: 12, padding: '4px 0' }}>
|
||||||
|
{backupError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{backups.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4 }}>
|
||||||
|
<div style={{ ...labelStyle, marginBottom: 0 }}>Sauvegardes disponibles</div>
|
||||||
|
{backups.map(b => (
|
||||||
|
<div
|
||||||
|
key={b.filename}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
background: 'var(--bg-4)', borderRadius: 8, padding: '8px 10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-file-zipper" style={{ color: 'var(--ink-3)', fontSize: 14, flexShrink: 0 }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{b.filename}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-ui)', fontSize: 11, color: 'var(--ink-4)' }}>
|
||||||
|
{formatDate(b.created_at)} — {formatSize(b.size)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => void handleRestore(b.filename)}
|
||||||
|
disabled={restoring === b.filename}
|
||||||
|
style={{
|
||||||
|
padding: '5px 10px', borderRadius: 6,
|
||||||
|
border: '1px solid var(--warn)', background: 'transparent',
|
||||||
|
color: 'var(--warn)', fontFamily: 'var(--font-ui)', fontSize: 11,
|
||||||
|
cursor: restoring === b.filename ? 'default' : 'pointer',
|
||||||
|
flexShrink: 0, opacity: restoring === b.filename ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{restoring === b.filename ? '…' : 'Restaurer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{backups.length === 0 && !backupLoading && (
|
||||||
|
<div style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-ui)', fontSize: 12 }}>
|
||||||
|
Aucune sauvegarde disponible
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Taille du texte */}
|
{/* Taille du texte */}
|
||||||
<div style={sectionStyle}>
|
<div style={sectionStyle}>
|
||||||
<div style={labelStyle}>Taille du texte</div>
|
<div style={labelStyle}>Taille du texte</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user