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:
+1
-1
@@ -3,7 +3,7 @@ FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
|
||||
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/*
|
||||
|
||||
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 app.core.database import get_session
|
||||
from app.core.redis import enqueue
|
||||
from app.models.notes import NoteItem, NoteAttachment
|
||||
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
|
||||
@@ -75,6 +76,7 @@ async def create_note(payload: NoteCreate, session: AsyncSession = Depends(get_s
|
||||
await session.refresh(note, ["attachments"])
|
||||
await session.commit()
|
||||
await session.refresh(note, ["attachments"])
|
||||
await enqueue("export_note_markdown", str(note.id))
|
||||
return note
|
||||
|
||||
|
||||
@@ -97,6 +99,7 @@ async def update_note(
|
||||
setattr(note, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(note, ["attachments"])
|
||||
await enqueue("export_note_markdown", str(note.id))
|
||||
return note
|
||||
|
||||
|
||||
@@ -107,6 +110,7 @@ async def delete_note(note_id: uuid.UUID, session: AsyncSession = Depends(get_se
|
||||
raise HTTPException(404, "Note introuvable")
|
||||
await session.delete(note)
|
||||
await session.commit()
|
||||
await enqueue("remove_note_markdown", str(note_id))
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@@ -145,6 +149,7 @@ async def add_attachment(
|
||||
session.add(att)
|
||||
await session.commit()
|
||||
await session.refresh(note, ["attachments"])
|
||||
await enqueue("export_note_markdown", str(note_id))
|
||||
return note
|
||||
|
||||
|
||||
@@ -170,4 +175,5 @@ async def delete_attachment(
|
||||
)
|
||||
await session.delete(att)
|
||||
await session.commit()
|
||||
await enqueue("export_note_markdown", str(note_id))
|
||||
return Response(status_code=204)
|
||||
|
||||
@@ -6,7 +6,9 @@ class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
|
||||
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"
|
||||
|
||||
@property
|
||||
@@ -17,5 +19,13 @@ class Settings(BaseSettings):
|
||||
def upload_path(self) -> Path:
|
||||
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()
|
||||
|
||||
@@ -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 pathlib import Path
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
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.media import router as media_router
|
||||
from app.api.notes import router as notes_router
|
||||
from app.api.todos import router as todos_router
|
||||
from app.api.shopping import router as shopping_router
|
||||
from app.core.config import settings
|
||||
from app.core.redis import init_redis, close_redis
|
||||
from app.data.seed import run_seed
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
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 init_redis()
|
||||
yield
|
||||
await close_redis()
|
||||
|
||||
|
||||
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(admin_router, prefix="/api/admin")
|
||||
app.include_router(media_router, prefix="/api/media")
|
||||
app.include_router(notes_router, prefix="/api/notes")
|
||||
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
|
||||
python-multipart==0.0.20
|
||||
httpx==0.28.0
|
||||
arq==0.26.1
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.24.0
|
||||
|
||||
Reference in New Issue
Block a user