generated from gilles/template-webapp
250 lines
7.9 KiB
Python
250 lines
7.9 KiB
Python
"""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()
|