generated from gilles/template-webapp
import ali
This commit is contained in:
249
backend/app/routers/documents.py
Normal file
249
backend/app/routers/documents.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""Router pour les documents (upload, téléchargement, suppression)."""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.models.document import Document, DocumentType
|
||||
from app.models.item import Item
|
||||
from app.schemas.document import DocumentResponse, DocumentUpdate, DocumentUploadResponse
|
||||
|
||||
router = APIRouter(prefix="/documents", tags=["documents"])
|
||||
|
||||
# Types MIME autorisés
|
||||
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||
ALLOWED_PDF_TYPES = {"application/pdf"}
|
||||
ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | ALLOWED_PDF_TYPES
|
||||
|
||||
# Taille max : 10 Mo
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def get_upload_path(doc_type: DocumentType, filename: str) -> Path:
|
||||
"""Retourne le chemin complet pour un fichier uploadé."""
|
||||
subdir = "photos" if doc_type == DocumentType.PHOTO else "documents"
|
||||
return settings.upload_dir_path / subdir / filename
|
||||
|
||||
|
||||
def generate_unique_filename(original_name: str) -> str:
|
||||
"""Génère un nom de fichier unique avec UUID."""
|
||||
ext = Path(original_name).suffix.lower()
|
||||
return f"{uuid.uuid4()}{ext}"
|
||||
|
||||
|
||||
async def validate_item_exists(session: AsyncSession, item_id: int) -> Item:
|
||||
"""Vérifie que l'item existe."""
|
||||
item = await session.get(Item, item_id)
|
||||
if not item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Item {item_id} non trouvé"
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
@router.post("/upload", response_model=DocumentUploadResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_document(
|
||||
file: Annotated[UploadFile, File(description="Fichier à uploader")],
|
||||
item_id: Annotated[int, Form(description="ID de l'objet associé")],
|
||||
doc_type: Annotated[DocumentType, Form(description="Type de document")],
|
||||
description: Annotated[str | None, Form(description="Description optionnelle")] = None,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Upload un document et l'associe à un item.
|
||||
|
||||
- Accepte images (JPEG, PNG, GIF, WebP) et PDF
|
||||
- Taille max : 10 Mo
|
||||
- Génère un nom unique pour éviter les conflits
|
||||
"""
|
||||
# Vérifier que l'item existe
|
||||
await validate_item_exists(session, item_id)
|
||||
|
||||
# Vérifier le type MIME
|
||||
if file.content_type not in ALLOWED_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Type de fichier non autorisé : {file.content_type}. "
|
||||
f"Types acceptés : images (JPEG, PNG, GIF, WebP) et PDF"
|
||||
)
|
||||
|
||||
# Vérifier si le type correspond au fichier
|
||||
if doc_type == DocumentType.PHOTO and file.content_type not in ALLOWED_IMAGE_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Le type 'photo' nécessite un fichier image"
|
||||
)
|
||||
|
||||
# Lire le contenu du fichier
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
# Vérifier la taille
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Fichier trop volumineux ({file_size / 1024 / 1024:.1f} Mo). "
|
||||
f"Taille max : {MAX_FILE_SIZE / 1024 / 1024:.0f} Mo"
|
||||
)
|
||||
|
||||
# Générer un nom unique
|
||||
unique_filename = generate_unique_filename(file.filename or "document")
|
||||
|
||||
# Déterminer le chemin de stockage
|
||||
file_path = get_upload_path(doc_type, unique_filename)
|
||||
|
||||
# Créer le répertoire si nécessaire
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Sauvegarder le fichier
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# Créer l'entrée en base
|
||||
relative_path = str(file_path.relative_to(settings.upload_dir_path.parent))
|
||||
|
||||
document = Document(
|
||||
filename=unique_filename,
|
||||
original_name=file.filename or "document",
|
||||
type=doc_type,
|
||||
mime_type=file.content_type or "application/octet-stream",
|
||||
size_bytes=file_size,
|
||||
file_path=relative_path,
|
||||
description=description,
|
||||
item_id=item_id,
|
||||
)
|
||||
|
||||
session.add(document)
|
||||
await session.commit()
|
||||
await session.refresh(document)
|
||||
|
||||
return DocumentUploadResponse(
|
||||
id=document.id,
|
||||
filename=document.filename,
|
||||
original_name=document.original_name,
|
||||
type=document.type,
|
||||
mime_type=document.mime_type,
|
||||
size_bytes=document.size_bytes,
|
||||
message="Document uploadé avec succès"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/item/{item_id}", response_model=list[DocumentResponse])
|
||||
async def get_item_documents(
|
||||
item_id: int,
|
||||
doc_type: DocumentType | None = None,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Récupère tous les documents d'un item."""
|
||||
from sqlalchemy import select
|
||||
|
||||
await validate_item_exists(session, item_id)
|
||||
|
||||
query = select(Document).where(Document.item_id == item_id)
|
||||
|
||||
if doc_type:
|
||||
query = query.where(Document.type == doc_type)
|
||||
|
||||
query = query.order_by(Document.created_at.desc())
|
||||
|
||||
result = await session.execute(query)
|
||||
documents = result.scalars().all()
|
||||
|
||||
return [DocumentResponse.model_validate(doc) for doc in documents]
|
||||
|
||||
|
||||
@router.get("/{document_id}", response_model=DocumentResponse)
|
||||
async def get_document(
|
||||
document_id: int,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Récupère les métadonnées d'un document."""
|
||||
document = await session.get(Document, document_id)
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Document non trouvé"
|
||||
)
|
||||
return DocumentResponse.model_validate(document)
|
||||
|
||||
|
||||
@router.get("/{document_id}/download")
|
||||
async def download_document(
|
||||
document_id: int,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Télécharge un document."""
|
||||
document = await session.get(Document, document_id)
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Document non trouvé"
|
||||
)
|
||||
|
||||
file_path = settings.upload_dir_path.parent / document.file_path
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Fichier non trouvé sur le disque"
|
||||
)
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=document.original_name,
|
||||
media_type=document.mime_type,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{document_id}", response_model=DocumentResponse)
|
||||
async def update_document(
|
||||
document_id: int,
|
||||
data: DocumentUpdate,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Met à jour les métadonnées d'un document."""
|
||||
document = await session.get(Document, document_id)
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Document non trouvé"
|
||||
)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(document, field, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(document)
|
||||
|
||||
return DocumentResponse.model_validate(document)
|
||||
|
||||
|
||||
@router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_document(
|
||||
document_id: int,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Supprime un document (fichier + entrée en base)."""
|
||||
document = await session.get(Document, document_id)
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Document non trouvé"
|
||||
)
|
||||
|
||||
# Supprimer le fichier
|
||||
file_path = settings.upload_dir_path.parent / document.file_path
|
||||
if file_path.exists():
|
||||
os.remove(file_path)
|
||||
|
||||
# Supprimer l'entrée en base
|
||||
await session.delete(document)
|
||||
await session.commit()
|
||||
Reference in New Issue
Block a user