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_PREFIXES 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") ct = (file.content_type or "").lower().split(";")[0].strip() if ct in ALLOWED_IMAGE_TYPES: media = await save_image(file, context="note") file_type = "image" elif ct in ALLOWED_AUDIO_PREFIXES: 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)