be0c8bceb6
- 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>
180 lines
5.6 KiB
Python
180 lines
5.6 KiB
Python
import uuid
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
|
|
from fastapi.responses import Response
|
|
from sqlalchemy import select, text, and_
|
|
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
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.get("/", response_model=list[NoteResponse])
|
|
async def list_notes(
|
|
q: str | None = Query(default=None),
|
|
category: str | None = Query(default=None),
|
|
tag: str | None = Query(default=None),
|
|
has_photo: bool | None = Query(default=None),
|
|
has_audio: bool | None = Query(default=None),
|
|
has_gps: bool | None = Query(default=None),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
stmt = (
|
|
select(NoteItem)
|
|
.options(selectinload(NoteItem.attachments))
|
|
.order_by(NoteItem.created_at.desc())
|
|
)
|
|
|
|
conditions = []
|
|
if category:
|
|
conditions.append(NoteItem.category == category)
|
|
if tag:
|
|
conditions.append(NoteItem.tags.contains([tag]))
|
|
if has_gps is True:
|
|
conditions.append(NoteItem.gps_lat.isnot(None))
|
|
if has_gps is False:
|
|
conditions.append(NoteItem.gps_lat.is_(None))
|
|
|
|
if q:
|
|
conditions.append(
|
|
text(
|
|
"to_tsvector('french', coalesce(notes.items.title,'') || ' ' || notes.items.content) "
|
|
"@@ plainto_tsquery('french', :q)"
|
|
).bindparams(q=q)
|
|
)
|
|
|
|
if conditions:
|
|
stmt = stmt.where(and_(*conditions))
|
|
|
|
result = await session.execute(stmt)
|
|
notes = result.scalars().all()
|
|
|
|
if has_photo is not None:
|
|
notes = [
|
|
n for n in notes
|
|
if has_photo == any(a.file_type == "image" for a in n.attachments)
|
|
]
|
|
if has_audio is not None:
|
|
notes = [
|
|
n for n in notes
|
|
if has_audio == any(a.file_type == "audio" for a in n.attachments)
|
|
]
|
|
|
|
return notes
|
|
|
|
|
|
@router.post("/", response_model=NoteResponse, status_code=201)
|
|
async def create_note(payload: NoteCreate, session: AsyncSession = Depends(get_session)):
|
|
note = NoteItem(**payload.model_dump())
|
|
session.add(note)
|
|
await session.flush()
|
|
await session.refresh(note, ["attachments"])
|
|
await session.commit()
|
|
await session.refresh(note, ["attachments"])
|
|
await enqueue("export_note_markdown", str(note.id))
|
|
return note
|
|
|
|
|
|
@router.patch("/{note_id}", response_model=NoteResponse)
|
|
async def update_note(
|
|
note_id: uuid.UUID,
|
|
payload: NoteUpdate,
|
|
session: AsyncSession = Depends(get_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 not note:
|
|
raise HTTPException(404, "Note introuvable")
|
|
for field, value in payload.model_dump(exclude_none=True).items():
|
|
setattr(note, field, value)
|
|
await session.commit()
|
|
await session.refresh(note, ["attachments"])
|
|
await enqueue("export_note_markdown", str(note.id))
|
|
return note
|
|
|
|
|
|
@router.delete("/{note_id}", status_code=204)
|
|
async def delete_note(note_id: uuid.UUID, session: AsyncSession = Depends(get_session)):
|
|
note = await session.get(NoteItem, note_id)
|
|
if not note:
|
|
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)
|
|
|
|
|
|
@router.post("/{note_id}/attachments", response_model=NoteResponse, status_code=201)
|
|
async def add_attachment(
|
|
note_id: uuid.UUID,
|
|
file: UploadFile = File(...),
|
|
session: AsyncSession = Depends(get_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 not note:
|
|
raise HTTPException(404, "Note introuvable")
|
|
|
|
if file.content_type in ALLOWED_IMAGE_TYPES:
|
|
media = await save_image(file, context="note")
|
|
file_type = "image"
|
|
elif file.content_type in ALLOWED_AUDIO_TYPES:
|
|
media = await save_audio(file)
|
|
file_type = "audio"
|
|
else:
|
|
raise HTTPException(400, f"Type non supporté : {file.content_type}")
|
|
|
|
att = NoteAttachment(
|
|
note_id=note_id,
|
|
file_path=media["file_path"],
|
|
thumbnail_path=media.get("thumbnail_path"),
|
|
file_type=file_type,
|
|
original_name=file.filename,
|
|
)
|
|
session.add(att)
|
|
await session.commit()
|
|
await session.refresh(note, ["attachments"])
|
|
await enqueue("export_note_markdown", str(note_id))
|
|
return note
|
|
|
|
|
|
@router.delete("/{note_id}/attachments/{att_id}", status_code=204)
|
|
async def delete_attachment(
|
|
note_id: uuid.UUID,
|
|
att_id: uuid.UUID,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
stmt = select(NoteAttachment).where(
|
|
NoteAttachment.id == att_id,
|
|
NoteAttachment.note_id == note_id,
|
|
)
|
|
result = await session.execute(stmt)
|
|
att = result.scalar_one_or_none()
|
|
if not att:
|
|
raise HTTPException(404, "Pièce jointe introuvable")
|
|
|
|
delete_media(
|
|
file_id=str(att_id),
|
|
file_path=att.file_path or "",
|
|
thumbnail_path=att.thumbnail_path,
|
|
)
|
|
await session.delete(att)
|
|
await session.commit()
|
|
await enqueue("export_note_markdown", str(note_id))
|
|
return Response(status_code=204)
|