feat: Phase 4 — module Notes complet

Backend :
- schemas/notes.py : NoteCreate/Update/Response + AttachmentResponse
- api/notes.py : CRUD + FTS français (plainto_tsquery) + filtres rapides
  (has_photo/audio/gps/tag/category) + pièces jointes (image/audio)
- main.py : enregistrement /api/notes

Frontend :
- api/notes.ts : fetchNotes/create/update/delete + add/deleteAttachment
- NoteForm.tsx : titre, contenu, catégorie, tags CSV, GPS
- NotesPage.tsx : liste mobile (chronologique) + grille laptop, FAB +,
  enregistrement audio inline (MediaRecorder), upload photo, filtres rapides

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 06:49:46 +02:00
parent c4634b5a27
commit fdeb747f38
6 changed files with 835 additions and 4 deletions
+173
View File
@@ -0,0 +1,173 @@
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.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"])
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"])
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()
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"])
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()
return Response(status_code=204)
+2
View File
@@ -4,6 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
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
@@ -28,6 +29,7 @@ app.add_middleware(
app.include_router(health_router, prefix="/api")
app.include_router(media_router, prefix="/api/media")
app.include_router(notes_router, prefix="/api/notes")
app.include_router(todos_router, prefix="/api/todos")
app.include_router(shopping_router, prefix="/api/shopping")
+45
View File
@@ -0,0 +1,45 @@
import uuid
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict
class AttachmentResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
file_path: str | None
thumbnail_path: str | None
file_type: str | None
original_name: str | None
created_at: datetime
class NoteCreate(BaseModel):
title: str | None = None
content: str
category: str | None = None
tags: list[str] = []
gps_lat: Decimal | None = None
gps_lon: Decimal | None = None
class NoteUpdate(BaseModel):
title: str | None = None
content: str | None = None
category: str | None = None
tags: list[str] | None = None
gps_lat: Decimal | None = None
gps_lon: Decimal | None = None
class NoteResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
title: str | None
content: str
category: str | None
tags: list[str]
gps_lat: Decimal | None
gps_lon: Decimal | None
created_at: datetime
attachments: list[AttachmentResponse]