Files
home_stock/backend/app/routers/documents.py
2026-02-01 01:45:51 +01:00

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()