Add comprehensive test suite for image processing and related services
- Implement tests for database generator to ensure proper session handling. - Create tests for EXIF extraction and conversion functions. - Add tests for image-related endpoints, ensuring proper data retrieval and isolation between clients. - Develop tests for OCR functionality, including language detection and text extraction. - Introduce tests for the image processing pipeline, covering success and failure scenarios. - Validate rate limiting functionality and ensure independent counters for different clients. - Implement scraper tests to verify HTML content fetching and error handling. - Add unit tests for various services, including storage and filename generation. - Establish worker entry point for ARQ to handle background image processing tasks.
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Schémas Pydantic — validation et sérialisation des réponses API
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Optional
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from app.models.image import ProcessingStatus
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Sous-schémas imbriqués
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
class ExifGPS(BaseModel):
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
altitude: Optional[float] = None
|
||||
has_gps: bool = False
|
||||
|
||||
|
||||
class ExifCamera(BaseModel):
|
||||
make: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
lens: Optional[str] = None
|
||||
iso: Optional[int] = None
|
||||
aperture: Optional[str] = None
|
||||
shutter_speed: Optional[str] = None
|
||||
focal_length: Optional[str] = None
|
||||
flash: Optional[bool] = None
|
||||
orientation: Optional[int] = None
|
||||
software: Optional[str] = None
|
||||
taken_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class ExifData(BaseModel):
|
||||
camera: ExifCamera
|
||||
gps: ExifGPS
|
||||
raw: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
class OcrData(BaseModel):
|
||||
text: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
confidence: Optional[float] = None
|
||||
has_text: bool = False
|
||||
|
||||
|
||||
class AiData(BaseModel):
|
||||
description: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
confidence: Optional[float] = None
|
||||
model_used: Optional[str] = None
|
||||
processed_at: Optional[datetime] = None
|
||||
prompt_tokens: Optional[int] = None
|
||||
output_tokens: Optional[int] = None
|
||||
|
||||
|
||||
class ProcessingInfo(BaseModel):
|
||||
status: ProcessingStatus
|
||||
error: Optional[str] = None
|
||||
started_at: Optional[datetime] = None
|
||||
done_at: Optional[datetime] = None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Réponses principales
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
class ImageBase(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
uuid: str
|
||||
original_name: str
|
||||
mime_type: Optional[str] = None
|
||||
file_size: Optional[int] = None
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
uploaded_at: Optional[datetime] = None
|
||||
processing_status: ProcessingStatus
|
||||
|
||||
|
||||
class ImageSummary(ImageBase):
|
||||
"""Version allégée pour les listes."""
|
||||
ai_tags: Optional[List[str]] = None
|
||||
ai_description: Optional[str] = None
|
||||
thumbnail_path: Optional[str] = None
|
||||
|
||||
|
||||
class ImageDetail(ImageBase):
|
||||
"""Version complète avec toutes les données collectées."""
|
||||
exif: ExifData
|
||||
ocr: OcrData
|
||||
ai: AiData
|
||||
processing: ProcessingInfo
|
||||
|
||||
@classmethod
|
||||
def from_orm_full(cls, img) -> "ImageDetail":
|
||||
return cls(
|
||||
id=img.id,
|
||||
uuid=img.uuid,
|
||||
original_name=img.original_name,
|
||||
mime_type=img.mime_type,
|
||||
file_size=img.file_size,
|
||||
width=img.width,
|
||||
height=img.height,
|
||||
uploaded_at=img.uploaded_at,
|
||||
processing_status=img.processing_status,
|
||||
thumbnail_path=img.thumbnail_path,
|
||||
exif=ExifData(
|
||||
camera=ExifCamera(
|
||||
make=img.exif_make,
|
||||
model=img.exif_model,
|
||||
lens=img.exif_lens,
|
||||
iso=img.exif_iso,
|
||||
aperture=img.exif_aperture,
|
||||
shutter_speed=img.exif_shutter,
|
||||
focal_length=img.exif_focal,
|
||||
flash=img.exif_flash,
|
||||
orientation=img.exif_orientation,
|
||||
software=img.exif_software,
|
||||
taken_at=img.exif_taken_at,
|
||||
),
|
||||
gps=ExifGPS(
|
||||
latitude=img.exif_gps_lat,
|
||||
longitude=img.exif_gps_lon,
|
||||
altitude=img.exif_altitude,
|
||||
has_gps=img.has_gps,
|
||||
),
|
||||
raw=img.exif_raw,
|
||||
),
|
||||
ocr=OcrData(
|
||||
text=img.ocr_text,
|
||||
language=img.ocr_language,
|
||||
confidence=img.ocr_confidence,
|
||||
has_text=img.ocr_has_text or False,
|
||||
),
|
||||
ai=AiData(
|
||||
description=img.ai_description,
|
||||
tags=img.ai_tags,
|
||||
confidence=img.ai_confidence,
|
||||
model_used=img.ai_model_used,
|
||||
processed_at=img.ai_processed_at,
|
||||
prompt_tokens=img.ai_prompt_tokens,
|
||||
output_tokens=img.ai_output_tokens,
|
||||
),
|
||||
processing=ProcessingInfo(
|
||||
status=img.processing_status,
|
||||
error=img.processing_error,
|
||||
started_at=img.processing_started_at,
|
||||
done_at=img.processing_done_at,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class UploadResponse(BaseModel):
|
||||
id: int
|
||||
uuid: str
|
||||
original_name: str
|
||||
status: ProcessingStatus
|
||||
message: str = "Image uploadée — traitement AI en cours"
|
||||
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
id: int
|
||||
uuid: str
|
||||
status: ProcessingStatus
|
||||
error: Optional[str] = None
|
||||
started_at: Optional[datetime] = None
|
||||
done_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class PaginatedImages(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
pages: int
|
||||
items: List[ImageSummary]
|
||||
# Quota tracking
|
||||
storage_used_mb: Optional[float] = None
|
||||
storage_quota_mb: Optional[int] = None
|
||||
quota_pct: Optional[float] = None
|
||||
|
||||
|
||||
class DeleteResponse(BaseModel):
|
||||
deleted_id: int
|
||||
message: str = "Image supprimée avec succès"
|
||||
|
||||
|
||||
class TagsResponse(BaseModel):
|
||||
tags: List[str]
|
||||
total: int
|
||||
|
||||
|
||||
class ReprocessResponse(BaseModel):
|
||||
id: int
|
||||
message: str = "Traitement AI relancé"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# AI — Endpoints externes (résumé URL, rédaction)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
class SummarizeRequest(BaseModel):
|
||||
url: str
|
||||
language: str = "français"
|
||||
|
||||
|
||||
class SummarizeResponse(BaseModel):
|
||||
url: str
|
||||
title: Optional[str] = None
|
||||
summary: str
|
||||
tags: List[str]
|
||||
model: str
|
||||
|
||||
|
||||
class DraftTaskRequest(BaseModel):
|
||||
description: str
|
||||
context: Optional[str] = None
|
||||
language: str = "français"
|
||||
|
||||
|
||||
class DraftTaskResponse(BaseModel):
|
||||
title: str
|
||||
description: str
|
||||
steps: List[str]
|
||||
estimated_time: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Schémas Pydantic — authentification et gestion des clients API
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from app.models.client import ClientPlan
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Requêtes
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
class ClientCreate(BaseModel):
|
||||
"""Créer un nouveau client API."""
|
||||
name: str = Field(..., min_length=1, max_length=256, description="Nom de l'application cliente")
|
||||
scopes: List[str] = Field(
|
||||
default=["images:read", "images:write"],
|
||||
description="Permissions accordées",
|
||||
)
|
||||
plan: ClientPlan = Field(default=ClientPlan.FREE, description="Plan tarifaire")
|
||||
|
||||
|
||||
class ClientUpdate(BaseModel):
|
||||
"""Modifier un client API existant."""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=256)
|
||||
scopes: Optional[List[str]] = None
|
||||
plan: Optional[ClientPlan] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Réponses
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
class ClientResponse(BaseModel):
|
||||
"""Réponse de base pour un client API."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
name: str
|
||||
scopes: List[str]
|
||||
plan: ClientPlan
|
||||
is_active: bool
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class ClientCreateResponse(ClientResponse):
|
||||
"""
|
||||
Réponse après création d'un client.
|
||||
La clé API est retournée EN CLAIR une seule fois.
|
||||
"""
|
||||
api_key: str = Field(
|
||||
...,
|
||||
description="Clé API en clair — stockez-la, elle ne sera plus jamais affichée",
|
||||
)
|
||||
|
||||
|
||||
class KeyRotateResponse(BaseModel):
|
||||
"""Réponse après rotation de la clé API."""
|
||||
id: str
|
||||
api_key: str = Field(
|
||||
...,
|
||||
description="Nouvelle clé API en clair — stockez-la, elle ne sera plus jamais affichée",
|
||||
)
|
||||
message: str = "Clé API régénérée avec succès — l'ancienne clé est désormais invalide"
|
||||
Reference in New Issue
Block a user