Add comprehensive test suite for image processing and related services
Some checks failed
CI / Lint & Format (push) Has been cancelled
CI / Tests (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Docker Build (push) Has been cancelled

- 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:
Bruno Charest 2026-02-24 11:22:10 -05:00
commit cc99fea20a
80 changed files with 9582 additions and 0 deletions

72
.env.example Normal file
View File

@ -0,0 +1,72 @@
# ============================================================
# Imago — Configuration
# Copier ce fichier en .env et remplir les valeurs
# ============================================================
# Application
APP_NAME="Imago"
APP_VERSION="1.0.0"
DEBUG=true
SECRET_KEY="changez-moi-en-production-avec-une-cle-aleatoire-longue"
# AI — Configuration
AI_ENABLED=true
# AI — Provider (gemini/openrouter)
AI_PROVIDER="openrouter"
# Serveur
HOST=0.0.0.0
PORT=8000
# Base de données
DATABASE_URL="sqlite+aiosqlite:///./data/imago.db"
# Pour PostgreSQL:
# DATABASE_URL="postgresql+asyncpg://user:password@localhost/shaarli"
# Stockage des fichiers
UPLOAD_DIR="./data/uploads"
THUMBNAILS_DIR="./data/thumbnails"
MAX_UPLOAD_SIZE_MB=50
# AI — Google Gemini
GEMINI_API_KEY="AIza..."
GEMINI_MODEL="gemini-3.1-pro-preview"
GEMINI_MAX_TOKENS=1024
# AI - Openrouter
# model name : mistralai/mistral-small-3.1-24b-instruct:free
# model name : google/gemini-2.0-flash-001
OPENROUTER_API_KEY="..."
OPENROUTER_MODEL="qwen/qwen2.5-vl-72b-instruct"
# AI — Comportement
AI_TAGS_MIN=5
AI_TAGS_MAX=10
AI_DESCRIPTION_LANGUAGE="français"
AI_CACHE_DAYS=30
# OCR
OCR_ENABLED=true
TESSERACT_CMD="/usr/bin/tesseract"
OCR_LANGUAGES="fra+eng"
# CORS
CORS_ORIGINS=["http://localhost:3000","http://localhost:8080","http://localhost:5173"]
# Authentification
ADMIN_API_KEY=""
JWT_SECRET_KEY="changez-moi-jwt-secret-en-production"
JWT_ALGORITHM="HS256"
# Rate Limiting (requêtes par minute — legacy)
RATE_LIMIT_UPLOAD=10
RATE_LIMIT_AI=20
# Rate Limiting par plan (requêtes par heure)
RATE_LIMIT_FREE_UPLOAD=20
RATE_LIMIT_FREE_AI=50
RATE_LIMIT_STANDARD_UPLOAD=100
RATE_LIMIT_STANDARD_AI=200
RATE_LIMIT_PREMIUM_UPLOAD=500
RATE_LIMIT_PREMIUM_AI=1000

114
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,114 @@
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
PYTHON_VERSION: "3.12"
AI_ENABLED: "false"
OCR_ENABLED: "false"
DATABASE_URL: "sqlite+aiosqlite:///./test.db"
ADMIN_API_KEY: "ci-test-key"
JWT_SECRET_KEY: "ci-secret"
jobs:
lint:
name: Lint & Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install dependencies
run: |
pip install --upgrade pip
pip install ruff mypy types-aiofiles pydantic
- name: Ruff check
run: ruff check app/ tests/
- name: Ruff format check
run: ruff format --check app/ tests/
- name: Mypy
run: mypy app/ --ignore-missing-imports
continue-on-error: true
test:
name: Tests
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install system deps
run: |
sudo apt-get update
sudo apt-get install -y tesseract-ocr tesseract-ocr-fra
- name: Install Python dependencies
run: |
pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-asyncio pytest-cov
- name: Run tests
run: |
python -m pytest tests/ -v --tb=short --cov=app --cov-report=term-missing
- name: Upload coverage
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: htmlcov/
security:
name: Security Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install ruff
run: pip install ruff
- name: Bandit security scan via ruff
run: ruff check app/ --select S --statistics
docker:
name: Docker Build
runs-on: ubuntu-latest
needs: test
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t shaarli-backend:ci .
- name: Verify container starts
run: |
docker run -d --name ci-test \
-e AI_ENABLED=false \
-e OCR_ENABLED=false \
-e DATABASE_URL=sqlite+aiosqlite:///./test.db \
-e ADMIN_API_KEY=test \
-e JWT_SECRET_KEY=test \
shaarli-backend:ci
sleep 3
docker logs ci-test
docker stop ci-test

39
.gitignore vendored Normal file
View File

@ -0,0 +1,39 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/
.eggs/
# Environnement virtuel
venv/
env/
.venv/
# Variables d'environnement
.env
# Données (images, BDD)
data/
# IDE
.vscode/
.idea/
*.swp
*.swo
# Tests
.pytest_cache/
.coverage
htmlcov/
# Alembic
alembic/versions/*.py
# OS
.DS_Store
Thumbs.db

28
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,28 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.6
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-toml
- id: check-added-large-files
args: [--maxkb=500]
- id: debug-statements
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.13.0
hooks:
- id: mypy
additional_dependencies:
- pydantic
- types-aiofiles
args: [--ignore-missing-imports]
pass_filenames: false

242
API_GUIDE.md Normal file
View File

@ -0,0 +1,242 @@
# Guide d'utilisation de l'API Imago
## 📍 Sommaire
- [🔐 Authentification](#-authentification)
- [🛡️ Scopes (Permissions)](#-scopes-permissions)
- [📊 Plans et Rate Limiting](#-plans-et-rate-limiting)
- [👥 Gestion des Clients (Admin uniquement)](#-gestion-des-clients-admin-uniquement)
- [📁 Multi-tenancy et Isolation](#-multi-tenancy-et-isolation)
- [📖 Référence des Endpoints](#-référence-des-endpoints)
- [📸 Gestion des Images](#-gestion-des-images)
- [🤖 Intelligence Artificielle](#-intelligence-artificielle)
- [🔑 Administration (Admin uniquement)](#-administration-admin-uniquement)
- [🏥 Santé et Status](#-santé-et-status)
- [🚀 Exemples Rapides](#-exemples-rapides)
---
## 🔐 Authentification
Tous les endpoints (sauf `/health` et `/`) nécessitent une authentification via une **Clé API**.
### Header Authorization
Vous devez inclure votre clé dans le header `Authorization` de chaque requête :
```http
Authorization: Bearer VOTRE_CLE_API_SECRET
```
> [!WARNING]
> Traitez votre clé API comme un mot de passe. Ne la partagez jamais et ne l'incluez pas dans du code client (frontend) accessible publiquement.
---
## 🛡️ Scopes (Permissions)
L'accès aux fonctionnalités est contrôlé par des **scopes**. Chaque clé API est limitée à un ensemble de permissions :
| Scope | Description | Fonctions incluses |
|-------|-------------|-------------------|
| `images:read` | Lecture seule | Lister les images, voir les détails, EXIF, OCR, AI. |
| `images:write` | Écriture | Uploader des images, relancer le pipeline de traitement. |
| `images:delete`| Suppression | Supprimer définitivement des images. |
| `ai:use` | Utilisation IA | Résumé d'URL, rédaction de tâches. |
| `admin` | Administration | Gérer les clients API, voir les clés, modifier les plans. |
---
## 📊 Plans et Rate Limiting
Le système applique des limites de requêtes (Rate Limits) basées sur le **Plan** de votre client. Les compteurs sont réinitialisés toutes les heures.
| Plan | Uploads / heure | Requêtes AI / heure |
|------|-----------------|---------------------|
| `free` | 20 | 50 |
| `standard` | 100 | 200 |
| `premium` | 500 | 1000 |
*Les requêtes de lecture (`GET`) ne sont pas limitées par défaut.*
---
## 👥 Gestion des Clients (Admin uniquement)
Si vous avez le scope `admin`, vous pouvez gérer les accès.
### Créer un nouveau client
```bash
curl -X POST http://localhost:8000/auth/clients \
-H "Authorization: Bearer CLE_ADMIN" \
-H "Content-Type: application/json" \
-d '{
"name": "Application Mobile",
"scopes": ["images:read", "images:write", "ai:use"],
"plan": "standard"
}'
```
**Réponse (Importante) :**
```json
{
"id": "uuid-...",
"api_key": "cle_generee_en_clair_une_seule_fois",
"name": "Application Mobile",
...
}
```
> [!CAUTION]
> La clé API n'est affichée **qu'une seule fois** à la création. Stockez-la immédiatement de manière sécurisée.
### Régénérer une clé (Rotation)
En cas de compromission, invalidez l'ancienne clé et générez-en une nouvelle :
```bash
curl -X POST http://localhost:8000/auth/clients/{id}/rotate-key \
-H "Authorization: Bearer CLE_ADMIN"
```
---
## 📁 Multi-tenancy et Isolation
L'API est **multi-tenant**. Cela signifie que :
- Vous ne voyez **que** les images uploadées avec votre clé.
- Les IDs d'images sont globaux, mais si vous tentez d'accéder à l'ID d'un autre client, vous recevrez une erreur `404 Not Found`.
- Vos fichiers physiques sont stockés dans un sous-répertoire dédié sur le serveur (`/data/uploads/{votre_client_id}/`).
---
## 📖 Référence des Endpoints
### 📸 Gestion des Images
#### Lister les images
`GET /images`
- **Scope required** : `images:read`
- **Query Params** :
- `page` : Numéro de page (défaut: 1)
- `page_size` : Taille de page (défaut: 20, max: 100)
- `tag` : Filtrer par tag AI
- `status` : Filtrer par statut (`pending`, `processing`, `done`, `error`)
- `search` : Recherche textuelle dans le nom, la description AI ou l'OCR.
#### Uploader une image
`POST /images/upload`
- **Scope required** : `images:write`
- **Body** : `multipart/form-data`
- `file` : Le fichier image (JPEG, PNG, WebP, etc.)
- **Note** : Lance automatiquement le pipeline AI en arrière-plan.
#### Détail complet d'une image
`GET /images/{id}`
- **Scope required** : `images:read`
- **Description** : Retourne toutes les données (Source, EXIF, OCR, AI).
#### Statut du traitement
`GET /images/{id}/status`
- **Scope required** : `images:read`
- **Description** : Pour savoir si l'analyse par l'IA est terminée.
#### Métadonnées spécifiques
- `GET /images/{id}/exif` : Données techniques de l'appareil et GPS.
- `GET /images/{id}/ocr` : Texte extrait de l'image.
- `GET /images/{id}/ai` : Description textuelle et tags générés.
#### Retraitement
`POST /images/{id}/reprocess`
- **Scope required** : `images:write`
- **Description** : Réinitialise et relance le pipeline d'analyse AI.
#### Suppression
`DELETE /images/{id}`
- **Scope required** : `images:delete`
- **Description** : Supprime l'entrée en base et les fichiers sur le disque.
#### Tags globaux
`GET /images/tags/all`
- **Scope required** : `images:read`
- **Description** : Liste tous les tags uniques utilisés par le client.
---
### 🤖 Intelligence Artificielle
#### Résumé d'URL
`POST /ai/summarize`
- **Scope required** : `ai:use`
- **Body** (JSON) :
```json
{
"url": "https://...",
"language": "français"
}
```
#### Rédaction de tâche
`POST /ai/draft-task`
- **Scope required** : `ai:use`
- **Body** (JSON) :
```json
{
"description": "Texte libre décrivant la tâche",
"context": "Contexte optionnel",
"language": "fr"
}
```
---
### 🔑 Administration (Admin uniquement)
*Nécessite le scope `admin`.*
- `POST /auth/clients` : Créer un client (retourne la clé).
- `GET /auth/clients` : Lister tous les clients.
- `GET /auth/clients/{id}` : Voir les détails d'un client.
- `PATCH /auth/clients/{id}` : Modifier un client (nom, scopes, plan).
- `POST /auth/clients/{id}/rotate-key` : Changer la clé API.
- `DELETE /auth/clients/{id}` : Désactiver un client (suspension d'accès).
---
### 🏥 Santé et Status
Ces endpoints sont **publics** et ne nécessitent aucune clé API.
- `GET /` : Informations de base sur l'application (Version, Status).
- `GET /health` : Vérification complète de l'état (AI configurée, OCR actif, Modèle utilisé).
---
## 🚀 Exemples Rapides
### Lister mes images (Python / httpx)
```python
import httpx
headers = {"Authorization": "Bearer ma_super_cle"}
r = httpx.get("http://localhost:8000/images", headers=headers)
for img in r.json()["items"]:
print(f"ID: {img['id']} | Name: {img['original_name']}")
```
### Uploader une image (Node.js / Axios)
```javascript
const axios = require('axios');
const fs = require('fs');
const FormData = require('form-data');
const form = new FormData();
form.append('file', fs.createReadStream('vacances.jpg'));
axios.post('http://localhost:8000/images/upload', form, {
headers: {
...form.getHeaders(),
'Authorization': 'Bearer ma_super_cle'
}
}).then(console.log);
```

27
Dockerfile Normal file
View File

@ -0,0 +1,27 @@
FROM python:3.12-slim
# Dépendances système (Tesseract OCR + données de langues)
RUN apt-get update && apt-get install -y \
tesseract-ocr \
tesseract-ocr-fra \
tesseract-ocr-eng \
libgl1-mesa-glx \
libglib2.0-0 \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Dépendances Python
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Code source
COPY . .
# Répertoires de données
RUN mkdir -p data/uploads data/thumbnails
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

57
Makefile Normal file
View File

@ -0,0 +1,57 @@
.PHONY: help install install-dev lint format test test-cov serve worker docker-up docker-down clean
help: ## Affiche l'aide
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
# ── Installation ──────────────────────────────────────────────
install: ## Installer les dépendances de production
pip install -r requirements.txt
install-dev: ## Installer les dépendances de dev + pre-commit
pip install -r requirements-dev.txt
pre-commit install
# ── Qualité du code ───────────────────────────────────────────
lint: ## Linter le code (ruff check + mypy)
ruff check app/ tests/
mypy app/ --ignore-missing-imports
format: ## Formater le code (ruff format)
ruff format app/ tests/
ruff check --fix app/ tests/
# ── Tests ─────────────────────────────────────────────────────
test: ## Lancer les tests
python -m pytest tests/ -v --tb=short
test-cov: ## Lancer les tests avec couverture
python -m pytest tests/ -v --tb=short --cov=app --cov-report=term-missing --cov-report=html
# ── Serveur ───────────────────────────────────────────────────
serve: ## Démarrer le serveur de développement
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
worker: ## Démarrer le worker ARQ
python worker.py
# ── Docker ────────────────────────────────────────────────────
docker-up: ## Démarrer tous les services Docker
docker-compose up -d --build
docker-down: ## Arrêter tous les services Docker
docker-compose down -v
# ── Nettoyage ─────────────────────────────────────────────────
clean: ## Nettoyer les fichiers temporaires
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type d -name .pytest_cache -exec rm -rf {} + 2>/dev/null || true
find . -type d -name .mypy_cache -exec rm -rf {} + 2>/dev/null || true
find . -type d -name htmlcov -exec rm -rf {} + 2>/dev/null || true
find . -name "*.pyc" -delete 2>/dev/null || true

403
README.md Normal file
View File

@ -0,0 +1,403 @@
# Imago
Backend FastAPI pour la gestion d'images et fonctionnalités AI, conçu comme complément à l'interface Web-thème **Shaarli-Pro** et toute autre applications qui voudrait l'utiliser.
---
## Fonctionnalités
| Feature | Description |
|---|---|
| 📸 Upload d'images | Stockage + thumbnails, isolés par client (multi-tenant) |
| 🛡️ Sécurité | Authentification par API Key + scopes granulaires + Rate Limiting |
| 🔍 Extraction EXIF | Appareil photo, GPS, ISO, ouverture, date de prise de vue |
| 📝 OCR | Extraction du texte visible dans l'image (Tesseract + fallback AI) |
| 🤖 Vision AI | Description naturelle + classification par tags (Gemini / OpenRouter) |
| 🔗 Résumé URL | Scraping d'une page web + résumé AI |
| ✅ Rédaction de tâches | Génération structurée d'une tâche à partir d'une description |
| 📋 File de tâches ARQ | Pipeline persistant avec retry automatique (Redis) |
| 📊 Métriques Prometheus | Endpoint `/metrics` + custom counters/histograms |
| 💾 Storage abstrait | Local (HMAC signed URLs) ou S3/MinIO (presigned URLs) |
| 📈 Quota tracking | Suivi du stockage par client avec enforcement |
| 🔧 Logging structuré | JSON (production) / Console colorée (dev) via structlog |
---
## Stack technique
```
FastAPI + Uvicorn — Framework web async
SQLAlchemy (async) — ORM + SQLite/Postgres
ARQ + Redis — File de tâches persistante
Pillow + piexif — Traitement d'images + EXIF
pytesseract — OCR (Tesseract)
Google GenAI SDK — Vision AI (Gemini)
aioboto3 — Stockage S3/MinIO
structlog — Logging structuré JSON
Prometheus — Métriques et monitoring
slowapi — Rate limiting par client
ruff — Linting + formatting
```
---
## Installation
### Prérequis
- Python 3.12+ (3.14 supporté)
- Tesseract OCR installé sur le système
- Redis (optionnel, pour ARQ worker)
```bash
# Ubuntu/Debian
sudo apt install tesseract-ocr tesseract-ocr-fra tesseract-ocr-eng
# macOS
brew install tesseract tesseract-lang
# Windows
scoop install main/tesseract
```
### Setup
> [!NOTE]
> Les fichiers `requirements.txt` appliquent automatiquement des versions compatibles Python 3.14 (notamment pour Pillow et Pydantic).
```bash
# 1. Cloner et installer
git clone <repo>
cd imago
# Production
pip install -r requirements.txt
# Développement (inclut linting, tests, pre-commit)
pip install -r requirements-dev.txt
pre-commit install
# 2. Configuration
cp .env.example .env
# Éditer .env — voir la section Configuration ci-dessous
# 3. Migrations
alembic upgrade head
# La migration crée un client "default" et affiche sa clé API une seule fois.
# 4. Démarrage
python run.py # API sur http://localhost:8000
python worker.py # Worker ARQ (requiert Redis)
```
### Avec Docker
```bash
docker-compose up -d # API + Redis + Worker
```
### Commandes utiles (Makefile)
```bash
make help # Affiche toutes les commandes
make install-dev # Dépendances dev + pre-commit
make lint # Ruff + Mypy
make format # Auto-format du code
make test # Tests rapides
make test-cov # Tests + couverture HTML
make serve # Serveur dev (reload)
make worker # Worker ARQ
make docker-up # Docker compose up
make clean # Nettoyage __pycache__, .pytest_cache, etc.
```
---
## Pipeline de traitement AI
Chaque image uploadée déclenche un pipeline via ARQ (Redis) :
```
Upload → Sauvegarde fichier + thumbnail → BDD (PENDING)
▼ (ARQ Worker — persistant, avec retry)
┌────────────────────────────────────────┐
│ Étape 1 — Extraction EXIF │
│ Étape 2 — OCR (Tesseract + AI) │
│ Étape 3 — Vision AI (Gemini) │
└────────────────────────────────────────┘
BDD (DONE) + événements Redis pub/sub
```
> [!IMPORTANT]
> **Isolation multi-tenant** : fichiers et données sont compartimentés par `client_id`. Un client ne peut ni voir, ni modifier, ni supprimer les images d'un autre.
Chaque étape est **indépendante** : un échec partiel n'arrête pas le pipeline.
**Priority queues** : les clients `premium` ont une file dédiée.
**Retry automatique** avec backoff exponentiel. Dead-letter après max retries.
---
## Endpoints API
### Images (Auth requise)
| Méthode | URL | Description | Scope |
|---|---|---|---|
| `POST` | `/images/upload` | Uploader une image | `images:write` |
| `GET` | `/images` | Lister (paginé, filtrable, quota info) | `images:read` |
| `GET` | `/images/{id}` | Détail complet | `images:read` |
| `GET` | `/images/{id}/status` | Statut du pipeline | `images:read` |
| `GET` | `/images/{id}/exif` | Métadonnées EXIF | `images:read` |
| `GET` | `/images/{id}/ocr` | Texte OCR extrait | `images:read` |
| `GET` | `/images/{id}/ai` | Description + tags AI | `images:read` |
| `GET` | `/images/{id}/download-url` | URL signée de téléchargement | `images:read` |
| `GET` | `/images/{id}/thumbnail-url` | URL signée du thumbnail | `images:read` |
| `GET` | `/images/tags/all` | Tous les tags du client | `images:read` |
| `POST` | `/images/{id}/reprocess` | Relancer le pipeline | `images:write` |
| `DELETE` | `/images/{id}` | Supprimer image | `images:delete` |
### Intelligence Artificielle (Auth requise)
| Méthode | URL | Description | Scope |
|---|---|---|---|
| `POST` | `/ai/summarize` | Résumé AI d'une URL | `ai:use` |
| `POST` | `/ai/draft-task` | Rédaction de tâche | `ai:use` |
### Administration & Clients (Admin only)
| Méthode | URL | Description | Scope |
|---|---|---|---|
| `POST` | `/auth/clients` | Créer un client API | `admin` |
| `GET` | `/auth/clients` | Lister les clients | `admin` |
| `GET` | `/auth/clients/{id}` | Détail d'un client | `admin` |
| `PATCH` | `/auth/clients/{id}` | Modifier client (plan, scopes) | `admin` |
| `POST` | `/auth/clients/{id}/rotate-key` | Régénérer clé API | `admin` |
| `DELETE` | `/auth/clients/{id}` | Soft delete (désactivation) | `admin` |
### Fichiers signés
| Méthode | URL | Description | Auth |
|---|---|---|---|
| `GET` | `/files/signed/{token}` | Télécharger via URL signée | Token HMAC |
### Observabilité & Santé
| Méthode | URL | Description | Auth |
|---|---|---|---|
| `GET` | `/` | Info application | Non |
| `GET` | `/health` | Santé du service | Non |
| `GET` | `/health/detailed` | Santé détaillée (DB, Redis, ARQ, OCR) | Non |
| `GET` | `/metrics` | Métriques Prometheus | Non |
| `GET` | `/docs` | Documentation Swagger | Non |
---
## Exemples d'utilisation
> [!TIP]
> Tous les appels (sauf `/health` et `/metrics`) nécessitent une clé API valide passée dans le header `X-API-Key`.
### Upload d'une image
```bash
curl -X POST http://localhost:8000/images/upload \
-H "X-API-Key: your_api_key" \
-F "file=@photo.jpg"
```
Réponse :
```json
{
"id": 1,
"uuid": "a1b2c3d4-...",
"original_name": "photo.jpg",
"status": "pending",
"message": "Image uploadée — traitement AI en cours"
}
```
### Polling du statut
```bash
curl http://localhost:8000/images/1/status -H "X-API-Key: your_api_key"
```
```json
{
"id": 1,
"status": "done",
"started_at": "2024-01-15T10:30:00",
"done_at": "2024-01-15T10:30:08"
}
```
### Détail complet
```bash
curl http://localhost:8000/images/1 -H "X-API-Key: your_api_key"
```
```json
{
"id": 1,
"original_name": "photo.jpg",
"processing_status": "done",
"exif": {
"camera": {
"make": "Canon",
"model": "EOS R5",
"iso": 400,
"aperture": "f/2.8",
"shutter_speed": "1/250",
"focal_length": "50mm",
"taken_at": "2024-06-15T14:30:00"
},
"gps": {
"latitude": 48.8566,
"longitude": 2.3522,
"has_gps": true,
"maps_url": "https://maps.google.com/?q=48.8566,2.3522"
}
},
"ocr": {
"has_text": true,
"text": "Café de Flore",
"language": "fr",
"confidence": 0.94
},
"ai": {
"description": "Une terrasse de café parisien animée en fin d'après-midi. Les tables en osier sont disposées sur le trottoir boulevard Saint-Germain, avec une clientèle détendue profitant du soleil. L'enseigne emblématique du Café de Flore est visible en arrière-plan.",
"tags": ["café", "paris", "terrasse", "france", "bistrot", "extérieur", "urbain"],
"confidence": 0.97,
"model_used": "gemini-1.5-pro"
}
}
```
### Résumé d'URL
```bash
curl -X POST http://localhost:8000/ai/summarize \
-H "X-API-Key: your_api_key" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/article", "language": "français"}'
```
### Rédaction de tâche
```bash
curl -X POST http://localhost:8000/ai/draft-task \
-H "X-API-Key: your_api_key" \
-H "Content-Type: application/json" \
-d '{
"description": "Mettre à jour la documentation technique du projet backend",
"context": "Projet Imago, backend FastAPI"
}'
```
---
## Configuration
| Variable | Défaut | Description |
|---|---|---|
| `ADMIN_API_KEY` | — | Clé maîtresse pour gérer les clients API |
| `JWT_SECRET_KEY` | — | Secret pour la signature des tokens |
| `AI_PROVIDER` | `gemini` | `gemini` ou `openrouter` |
| `GEMINI_API_KEY` | — | Clé API Gemini |
| `DATABASE_URL` | SQLite local | URL de connexion (SQLite ou Postgres) |
| `REDIS_URL` | `redis://localhost:6379/0` | URL Redis pour ARQ |
| `STORAGE_BACKEND` | `local` | `local` ou `s3` |
| `S3_BUCKET` | — | Bucket S3/MinIO |
| `S3_ENDPOINT_URL` | — | Endpoint S3 custom (MinIO/R2) |
| `S3_ACCESS_KEY` | — | Clé d'accès S3 |
| `S3_SECRET_KEY` | — | Secret S3 |
| `SIGNED_URL_SECRET` | — | Secret HMAC pour URLs signées locales |
| `MAX_UPLOAD_SIZE_MB` | `50` | Taille max par upload |
| `OCR_ENABLED` | `true` | Activer/désactiver l'OCR |
| `WORKER_MAX_JOBS` | `10` | Concurrence du worker ARQ |
| `WORKER_MAX_TRIES` | `3` | Retries max avant dead-letter |
| `DEBUG` | `false` | Mode debug (console logging) |
---
## Tests
```bash
# Tests rapides
make test
# Tests avec couverture
make test-cov
# Directement
python -m pytest tests/ -v --cov=app
```
76 tests couvrant : auth, isolation multi-tenant, rate limiting, pipeline, EXIF, OCR, AI, stockage.
---
## CI/CD
Le pipeline GitHub Actions (`.github/workflows/ci.yml`) exécute :
1. **Lint** — ruff check + format + mypy
2. **Tests** — pytest + couverture
3. **Sécurité** — bandit via ruff (règles S)
4. **Docker** — build + smoke test (branche main uniquement)
---
## Structure du projet
```
imago/
├── app/
│ ├── main.py # FastAPI + lifespan + Prometheus
│ ├── config.py # Settings Pydantic
│ ├── database.py # Engine SQLAlchemy async
│ ├── logging_config.py # structlog JSON/Console
│ ├── metrics.py # Prometheus custom metrics
│ ├── models/
│ │ ├── image.py # Modèle Image
│ │ └── client.py # APIClient + quotas
│ ├── schemas/
│ │ ├── __init__.py # Schémas images + quota
│ │ └── auth.py # Schémas Auth/Clients
│ ├── routers/
│ │ ├── images.py # CRUD images + signed URLs
│ │ ├── ai.py # Endpoints AI
│ │ ├── auth.py # Gestion clients
│ │ └── files.py # Serving signed local files
│ ├── dependencies/
│ │ └── auth.py # verify_api_key, require_scope
│ ├── middleware/
│ │ ├── rate_limit.py # slowapi configuration
│ │ └── logging_middleware.py # HTTP request logger
│ ├── services/
│ │ ├── storage.py # Sauvegarde isolée par client
│ │ ├── storage_backend.py # ABC + Local/S3 backends
│ │ ├── pipeline.py # Pipeline EXIF → OCR → AI
│ │ ├── ai_vision.py # Gemini/OpenRouter
│ │ ├── exif_service.py # Extraction EXIF
│ │ ├── ocr_service.py # Tesseract OCR
│ │ └── scraper.py # Scraping web
│ └── workers/
│ ├── redis_client.py # Redis pool partagé
│ └── image_worker.py # ARQ worker + retries
├── tests/ # 76 tests
├── .github/workflows/ci.yml # CI/CD pipeline
├── pyproject.toml # ruff, mypy, coverage config
├── Makefile # Commandes utiles
├── docker-compose.yml # API + Redis + Worker
├── Dockerfile
├── requirements.txt # Production deps
├── requirements-dev.txt # Dev deps (lint, test)
├── .pre-commit-config.yaml # Pre-commit hooks
├── worker.py # ARQ worker entrypoint
└── .env.example
```

38
alembic.ini Normal file
View File

@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = sqlite+aiosqlite:///./data/imago.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

53
alembic/env.py Normal file
View File

@ -0,0 +1,53 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.config import settings
from app.database import Base
from app.models import image # noqa: F401 — importer les modèles
from app.models import client # noqa: F401 — importer les modèles
config = context.config
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

6
api_key.md Normal file
View File

@ -0,0 +1,6 @@
============================================================
🚀 BOOTSTRAP : CLIENT ADMIN CRÉÉ
ID : 99aef90e-0185-419f-ba6b-dd0a9cea70a3
KEY : jFoSMb0uMMZ2wiHkttQmi72qMUmyrNKTI_fBqFr7034
⚠️ NOTEZ CETTE CLÉ ! Elle ne sera plus affichée.
============================================================

1
app/__init__.py Normal file
View File

@ -0,0 +1 @@
# Imago

121
app/config.py Normal file
View File

@ -0,0 +1,121 @@
"""
Configuration centralisée chargée depuis .env
"""
from pathlib import Path
from typing import List
from pydantic_settings import BaseSettings
from pydantic import field_validator
import json
class Settings(BaseSettings):
# Application
APP_NAME: str = "Imago"
APP_VERSION: str = "1.0.0"
DEBUG: bool = False
SECRET_KEY: str = "changez-moi"
# Serveur
HOST: str = "0.0.0.0"
PORT: int = 8000
# Base de données
DATABASE_URL: str = "sqlite+aiosqlite:///./data/imago.db"
# Stockage
UPLOAD_DIR: str = "./data/uploads"
THUMBNAILS_DIR: str = "./data/thumbnails"
MAX_UPLOAD_SIZE_MB: int = 50
# AI — Configuration
AI_ENABLED: bool = True
AI_PROVIDER: str = "openrouter"
# AI — Google Gemini
GEMINI_API_KEY: str = ""
GEMINI_MODEL: str = "gemini-3.1-pro-preview"
GEMINI_MAX_TOKENS: int = 1024
# AI — OpenRouter
OPENROUTER_API_KEY: str = ""
OPENROUTER_MODEL: str = "qwen/qwen2.5-vl-72b-instruct"
# AI — Comportement
AI_TAGS_MIN: int = 5
AI_TAGS_MAX: int = 10
AI_DESCRIPTION_LANGUAGE: str = "français"
AI_CACHE_DAYS: int = 30
# OCR
OCR_ENABLED: bool = True
TESSERACT_CMD: str = "/usr/bin/tesseract"
OCR_LANGUAGES: str = "fra+eng"
# CORS
CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:5173"]
# Authentification
ADMIN_API_KEY: str = ""
JWT_SECRET_KEY: str = "changez-moi-jwt-secret"
JWT_ALGORITHM: str = "HS256"
# Rate limiting — global (legacy)
RATE_LIMIT_UPLOAD: int = 10
RATE_LIMIT_AI: int = 20
# Rate limiting — par plan (requêtes/heure)
RATE_LIMIT_FREE_UPLOAD: int = 20
RATE_LIMIT_FREE_AI: int = 50
RATE_LIMIT_STANDARD_UPLOAD: int = 100
RATE_LIMIT_STANDARD_AI: int = 200
RATE_LIMIT_PREMIUM_UPLOAD: int = 500
RATE_LIMIT_PREMIUM_AI: int = 1000
# Redis + ARQ Worker
REDIS_URL: str = "redis://localhost:6379"
WORKER_MAX_JOBS: int = 10
WORKER_JOB_TIMEOUT: int = 180
WORKER_MAX_TRIES: int = 3
AI_STEP_TIMEOUT: int = 120
OCR_STEP_TIMEOUT: int = 30
# Storage Backend
STORAGE_BACKEND: str = "local" # "local" | "s3"
S3_BUCKET: str = ""
S3_REGION: str = "us-east-1"
S3_ENDPOINT_URL: str = "" # vide = AWS, sinon MinIO/R2
S3_ACCESS_KEY: str = ""
S3_SECRET_KEY: str = ""
S3_PREFIX: str = "imago"
SIGNED_URL_SECRET: str = "changez-moi-signed-url"
@field_validator("CORS_ORIGINS", mode="before")
@classmethod
def parse_cors(cls, v):
if isinstance(v, str):
try:
return json.loads(v)
except Exception:
return [v]
return v
@property
def upload_path(self) -> Path:
p = Path(self.UPLOAD_DIR)
p.mkdir(parents=True, exist_ok=True)
return p
@property
def thumbnails_path(self) -> Path:
p = Path(self.THUMBNAILS_DIR)
p.mkdir(parents=True, exist_ok=True)
return p
@property
def max_upload_bytes(self) -> int:
return self.MAX_UPLOAD_SIZE_MB * 1024 * 1024
model_config = {"env_file": ".env", "case_sensitive": True, "extra": "ignore"}
settings = Settings()

77
app/database.py Normal file
View File

@ -0,0 +1,77 @@
"""
Configuration SQLAlchemy session async
"""
import logging
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
logger = logging.getLogger(__name__)
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
future=True,
)
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
autocommit=False,
)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
"""Dependency FastAPI — injecte une session DB dans chaque requête."""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db():
"""Crée toutes les tables et initialise un client par défaut si nécessaire."""
import secrets
import hashlib
from sqlalchemy import select
from app.models.client import APIClient, ClientPlan
async with engine.begin() as conn:
from app.models import image # noqa: F401
from app.models import client # noqa: F401
await conn.run_sync(Base.metadata.create_all)
# Vérifier s'il y a déjà des clients
async with AsyncSessionLocal() as session:
result = await session.execute(select(APIClient).limit(1))
if result.scalar_one_or_none() is None:
# Table vide -> Création du client bootstrap
raw_key = secrets.token_urlsafe(32)
key_hash = hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
bootstrap_client = APIClient(
name="Default Admin",
api_key_hash=key_hash,
scopes=["images:read", "images:write", "images:delete", "ai:use", "admin"],
plan=ClientPlan.PREMIUM,
)
session.add(bootstrap_client)
await session.commit()
logger.info("bootstrap.client_created", extra={
"client_id": bootstrap_client.id,
"api_key": raw_key,
"warning": "Notez cette clé ! Elle ne sera plus affichée.",
})

View File

@ -0,0 +1,3 @@
from app.dependencies.auth import get_current_client, require_scope, verify_api_key
__all__ = ["get_current_client", "require_scope", "verify_api_key"]

115
app/dependencies/auth.py Normal file
View File

@ -0,0 +1,115 @@
"""
Dépendances FastAPI authentification par API Key + vérification de scopes.
Usage dans les routers :
client = Depends(get_current_client)
_ = Depends(require_scope("images:read"))
"""
import hashlib
import logging
from typing import Callable
from fastapi import Depends, Header, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.client import APIClient
logger = logging.getLogger(__name__)
def hash_api_key(api_key: str) -> str:
"""Hash SHA-256 d'une clé API — fonction utilitaire réutilisable."""
return hashlib.sha256(api_key.encode("utf-8")).hexdigest()
async def verify_api_key(
request: Request,
authorization: str = Header(
...,
alias="Authorization",
description="Clé API au format 'Bearer <key>'",
),
db: AsyncSession = Depends(get_db),
) -> APIClient:
"""
Vérifie la clé API fournie dans le header Authorization.
Injecte client_id et client_plan dans request.state pour le rate limiter.
Raises:
HTTPException 401: clé absente, invalide ou client inactif.
"""
# ── Extraction du token ───────────────────────────────────
if not authorization.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentification requise",
headers={"WWW-Authenticate": "Bearer"},
)
raw_key = authorization[7:] # strip "Bearer "
if not raw_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentification requise",
headers={"WWW-Authenticate": "Bearer"},
)
# ── Lookup par hash ───────────────────────────────────────
key_hash = hash_api_key(raw_key)
result = await db.execute(
select(APIClient).where(APIClient.api_key_hash == key_hash)
)
client = result.scalar_one_or_none()
if client is None:
logger.warning("Tentative d'authentification avec une clé invalide")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentification requise",
headers={"WWW-Authenticate": "Bearer"},
)
if not client.is_active:
logger.warning("Tentative d'authentification avec un client inactif: %s", client.id)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentification requise",
headers={"WWW-Authenticate": "Bearer"},
)
# Injecter dans request.state pour le rate limiter
request.state.client_id = client.id
request.state.client_plan = client.plan.value if client.plan else "free"
return client
# Alias pratique pour injection dans les routers
get_current_client = verify_api_key
def require_scope(scope: str) -> Callable:
"""
Factory qui retourne une dépendance FastAPI vérifiant qu'un scope est accordé.
Usage:
@router.get("/...", dependencies=[Depends(require_scope("images:read"))])
"""
async def _check_scope(
client: APIClient = Depends(get_current_client),
) -> APIClient:
if not client.has_scope(scope):
logger.warning(
"Client %s (%s) a tenté d'accéder au scope '%s' sans autorisation",
client.id, client.name, scope,
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Permission insuffisante",
)
return client
return _check_scope

58
app/logging_config.py Normal file
View File

@ -0,0 +1,58 @@
"""
Configuration structlog logging structuré JSON/Console.
En production (DEBUG=False) : JSON pour les agrégateurs (ELK, Datadog, etc.)
En développement (DEBUG=True) : Console colorée lisible.
"""
import logging
import sys
import structlog
def configure_logging(debug: bool = False) -> None:
"""Configure structlog + stdlib logging."""
shared_processors = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.UnicodeDecoder(),
]
if debug:
# Console lisible en dev
renderer = structlog.dev.ConsoleRenderer(colors=True)
else:
# JSON en production
renderer = structlog.processors.JSONRenderer()
structlog.configure(
processors=[
*shared_processors,
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
formatter = structlog.stdlib.ProcessorFormatter(
processor=renderer,
foreign_pre_chain=shared_processors,
)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
root_logger = logging.getLogger()
root_logger.handlers.clear()
root_logger.addHandler(handler)
root_logger.setLevel(logging.DEBUG if debug else logging.INFO)
# Réduire le bruit des librairies tierces
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)

310
app/main.py Normal file
View File

@ -0,0 +1,310 @@
"""
Imago Application principale FastAPI
"""
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from app.config import settings
from app.database import init_db
from app.logging_config import configure_logging
from app.routers import images_router, ai_router, auth_router, files_router
from app.middleware import limiter
from app.middleware.logging_middleware import LoggingMiddleware
from app.workers.redis_client import get_redis_pool, close_redis_pool
# Configure le logging structuré dès l'import
configure_logging(debug=settings.DEBUG)
logger = logging.getLogger(__name__)
try:
from arq import create_pool
from arq.connections import RedisSettings
_arq_available = True
except ImportError:
_arq_available = False
try:
from prometheus_fastapi_instrumentator import Instrumentator
_prometheus_available = True
except ImportError:
_prometheus_available = False
def _arq_redis_settings() -> "RedisSettings":
"""Parse REDIS_URL en RedisSettings ARQ."""
url = settings.REDIS_URL
if url.startswith("redis://"):
url = url[8:]
elif url.startswith("rediss://"):
url = url[9:]
password = None
host = "localhost"
port = 6379
database = 0
if "@" in url:
auth_part, url = url.rsplit("@", 1)
if ":" in auth_part:
password = auth_part.split(":", 1)[1]
else:
password = auth_part
if "/" in url:
host_port, db_str = url.split("/", 1)
if db_str:
database = int(db_str)
else:
host_port = url
if ":" in host_port:
host, port_str = host_port.rsplit(":", 1)
if port_str:
port = int(port_str)
else:
host = host_port
return RedisSettings(
host=host or "localhost",
port=port,
password=password,
database=database,
)
# ─────────────────────────────────────────────────────────────
# Lifespan — initialisation au démarrage
# ─────────────────────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
# Création des répertoires de données
settings.upload_path
settings.thumbnails_path
# Initialisation de la base de données (création des tables)
await init_db()
active_model = settings.OPENROUTER_MODEL if settings.AI_PROVIDER == "openrouter" else settings.GEMINI_MODEL
logger.info("startup.db_initialized", extra={"upload_dir": settings.UPLOAD_DIR})
logger.info("startup.ai_config", extra={
"provider": settings.AI_PROVIDER,
"model": active_model,
"ocr_enabled": settings.OCR_ENABLED,
})
# Initialisation Redis + ARQ pool
try:
app.state.redis = await get_redis_pool()
logger.info("startup.redis_connected", extra={"url": settings.REDIS_URL})
except Exception as e:
app.state.redis = None
logger.warning("startup.redis_unavailable", extra={"error": str(e)})
if _arq_available:
try:
app.state.arq_pool = await create_pool(_arq_redis_settings())
logger.info("startup.arq_pool_created")
except Exception as e:
app.state.arq_pool = _FallbackArqPool()
logger.warning("startup.arq_fallback", extra={"error": str(e)})
else:
app.state.arq_pool = _FallbackArqPool()
logger.warning("startup.arq_not_installed")
yield
# Fermeture propre
if hasattr(app.state, "arq_pool") and hasattr(app.state.arq_pool, "close"):
await app.state.arq_pool.close()
await close_redis_pool()
logger.info("shutdown.complete")
class _FallbackArqPool:
"""Fallback quand Redis/ARQ n'est pas disponible."""
async def enqueue_job(self, *args, **kwargs):
logger.warning("arq.fallback_enqueue", extra={"args": str(args)})
return None
async def close(self):
pass
# ─────────────────────────────────────────────────────────────
# Application
# ─────────────────────────────────────────────────────────────
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
description="""
## Imago
Backend de gestion d'images et fonctionnalités AI pour l'interface Shaarli.
### Fonctionnalités
- 📸 **Upload et stockage d'images** avec génération de thumbnails
- 🔍 **Extraction EXIF** automatique (appareil, GPS, paramètres de prise de vue)
- 📝 **OCR** extraction de texte depuis les images (Tesseract)
- 🤖 **Vision AI** description et classification par tags (Gemini)
- 🔗 **Résumé d'URL** — scraping + résumé AI de pages web
- **Rédaction de tâches** génération structurée via AI
- 📋 **File de tâches ARQ** pipeline persistant avec retry automatique
- 📊 **Métriques Prometheus** /metrics endpoint
### Pipeline de traitement
Chaque image uploadée est automatiquement traitée via ARQ (Redis) :
`EXIF OCR Vision AI stockage BDD`
""",
lifespan=lifespan,
docs_url="/docs",
redoc_url="/redoc",
)
# ─────────────────────────────────────────────────────────────
# Middleware CORS
# ─────────────────────────────────────────────────────────────
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ─────────────────────────────────────────────────────────────
# Middleware Logging HTTP
# ─────────────────────────────────────────────────────────────
app.add_middleware(LoggingMiddleware)
# ─────────────────────────────────────────────────────────────
# Rate Limiting (slowapi)
# ─────────────────────────────────────────────────────────────
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# ─────────────────────────────────────────────────────────────
# Prometheus Metrics
# ─────────────────────────────────────────────────────────────
if _prometheus_available:
Instrumentator(
should_group_status_codes=True,
should_ignore_untemplated=True,
excluded_handlers=["/health", "/health/detailed", "/metrics"],
).instrument(app).expose(app, endpoint="/metrics", tags=["Observabilité"])
# ─────────────────────────────────────────────────────────────
# Fichiers statiques / URLs signées
# ─────────────────────────────────────────────────────────────
if settings.STORAGE_BACKEND == "local":
app.include_router(files_router)
app.mount("/static/uploads", StaticFiles(directory=str(settings.upload_path)), name="uploads")
app.mount("/static/thumbnails", StaticFiles(directory=str(settings.thumbnails_path)), name="thumbnails")
# ─────────────────────────────────────────────────────────────
# Routers
# ─────────────────────────────────────────────────────────────
app.include_router(images_router)
app.include_router(ai_router)
app.include_router(auth_router)
# ─────────────────────────────────────────────────────────────
# Routes utilitaires
# ─────────────────────────────────────────────────────────────
@app.get("/", tags=["Santé"])
async def root():
return {
"app": settings.APP_NAME,
"version": settings.APP_VERSION,
"status": "running",
"docs": "/docs",
}
@app.get("/health", tags=["Santé"])
async def health():
ai_configured = (
(settings.AI_PROVIDER == "gemini" and bool(settings.GEMINI_API_KEY)) or
(settings.AI_PROVIDER == "openrouter" and bool(settings.OPENROUTER_API_KEY))
)
active_model = settings.OPENROUTER_MODEL if settings.AI_PROVIDER == "openrouter" else settings.GEMINI_MODEL
return {
"status": "healthy",
"ai_enabled": settings.AI_ENABLED,
"ai_provider": settings.AI_PROVIDER,
"ai_configured": ai_configured,
"ocr_enabled": settings.OCR_ENABLED,
"model": active_model,
}
@app.get("/health/detailed", tags=["Santé"])
async def health_detailed(request: Request):
"""Endpoint de santé détaillé pour monitoring avancé."""
checks = {}
# DB check
try:
from app.database import AsyncSessionLocal
from sqlalchemy import text
async with AsyncSessionLocal() as session:
await session.execute(text("SELECT 1"))
checks["database"] = {"status": "ok"}
except Exception as e:
checks["database"] = {"status": "error", "error": str(e)}
# Redis check
redis = getattr(request.app.state, "redis", None)
if redis:
try:
await redis.ping()
checks["redis"] = {"status": "ok"}
except Exception as e:
checks["redis"] = {"status": "error", "error": str(e)}
else:
checks["redis"] = {"status": "not_configured"}
# ARQ check
arq_pool = getattr(request.app.state, "arq_pool", None)
if arq_pool and not isinstance(arq_pool, _FallbackArqPool):
checks["arq"] = {"status": "ok"}
else:
checks["arq"] = {"status": "fallback"}
# OCR check
checks["ocr"] = {"status": "enabled" if settings.OCR_ENABLED else "disabled"}
# Storage check
checks["storage"] = {
"backend": settings.STORAGE_BACKEND,
"status": "ok",
}
overall = "healthy" if all(
c.get("status") in ("ok", "enabled", "disabled", "not_configured", "fallback")
for c in checks.values()
) else "degraded"
return {
"status": overall,
"checks": checks,
"version": settings.APP_VERSION,
}

62
app/metrics.py Normal file
View File

@ -0,0 +1,62 @@
"""
Métriques Prometheus custom pour le hub d'images.
Exposed via /metrics par prometheus-fastapi-instrumentator.
"""
from prometheus_client import Counter, Histogram, Gauge
# ── Images ────────────────────────────────────────────────────
hub_images_uploaded = Counter(
"hub_images_uploaded_total",
"Nombre total d'images uploadées",
["client_plan"],
)
hub_images_deleted = Counter(
"hub_images_deleted_total",
"Nombre total d'images supprimées",
)
# ── Pipeline ──────────────────────────────────────────────────
hub_pipeline_duration = Histogram(
"hub_pipeline_duration_seconds",
"Durée du pipeline de traitement complet",
buckets=[1, 5, 10, 30, 60, 120, 300],
)
hub_pipeline_step_duration = Histogram(
"hub_pipeline_step_duration_seconds",
"Durée de chaque étape du pipeline",
["step"],
buckets=[0.1, 0.5, 1, 5, 10, 30, 60],
)
hub_pipeline_errors = Counter(
"hub_pipeline_errors_total",
"Nombre d'erreurs pipeline",
["step"],
)
# ── Storage ───────────────────────────────────────────────────
hub_storage_used_bytes = Gauge(
"hub_storage_used_bytes",
"Espace de stockage utilisé par client",
["client_id"],
)
# ── ARQ ───────────────────────────────────────────────────────
hub_arq_jobs_enqueued = Counter(
"hub_arq_jobs_enqueued_total",
"Nombre de jobs ARQ enfilés",
["queue"],
)
hub_arq_jobs_completed = Counter(
"hub_arq_jobs_completed_total",
"Nombre de jobs ARQ terminés",
)
hub_arq_jobs_failed = Counter(
"hub_arq_jobs_failed_total",
"Nombre de jobs ARQ échoués",
)

View File

@ -0,0 +1,65 @@
"""
Middleware de rate limiting par client et par plan.
Utilise slowapi (basé sur limits) avec identification par client_id
plutôt que par IP, pour compter les requêtes par client API.
Limites par plan (par heure) :
- free : 20 uploads, 50 AI
- standard : 100 uploads, 200 AI
- premium : 500 uploads, 1000 AI
"""
import logging
from slowapi import Limiter
from slowapi.util import get_remote_address
from starlette.requests import Request
from app.config import settings
logger = logging.getLogger(__name__)
def _get_client_id_from_request(request: Request) -> str:
"""
Extrait le client_id depuis la state de la requête.
Fallback vers l'IP si le client n'est pas encore authentifié.
"""
# Le client_id est injecté par le middleware ou la dépendance auth
client_id = getattr(request.state, "client_id", None)
if client_id:
return str(client_id)
return get_remote_address(request)
# Instance globale du limiter
limiter = Limiter(key_func=_get_client_id_from_request)
def get_upload_rate_limit(plan: str) -> str:
"""Retourne la limite de rate pour les uploads selon le plan."""
limits = {
"free": f"{settings.RATE_LIMIT_FREE_UPLOAD}/hour",
"standard": f"{settings.RATE_LIMIT_STANDARD_UPLOAD}/hour",
"premium": f"{settings.RATE_LIMIT_PREMIUM_UPLOAD}/hour",
}
return limits.get(plan, limits["free"])
def get_ai_rate_limit(plan: str) -> str:
"""Retourne la limite de rate pour les endpoints AI selon le plan."""
limits = {
"free": f"{settings.RATE_LIMIT_FREE_AI}/hour",
"standard": f"{settings.RATE_LIMIT_STANDARD_AI}/hour",
"premium": f"{settings.RATE_LIMIT_PREMIUM_AI}/hour",
}
return limits.get(plan, limits["free"])
def upload_rate_limit_key(request: Request) -> str:
"""Clé dynamique pour le rate limiting des uploads."""
return _get_client_id_from_request(request)
def ai_rate_limit_key(request: Request) -> str:
"""Clé dynamique pour le rate limiting des endpoints AI."""
return _get_client_id_from_request(request)

View File

@ -0,0 +1,41 @@
"""
Middleware de logging HTTP enregistre chaque requête avec structlog.
Exclut les endpoints de santé (/health, /metrics) pour réduire le bruit.
"""
import time
import logging
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
logger = logging.getLogger("http")
# Chemins exclus du logging
_EXCLUDED_PATHS = {"/health", "/health/detailed", "/metrics", "/favicon.ico"}
class LoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
if request.url.path in _EXCLUDED_PATHS:
return await call_next(request)
start = time.monotonic()
client_id = getattr(request.state, "client_id", "anonymous")
response = await call_next(request)
duration_ms = int((time.monotonic() - start) * 1000)
logger.info(
"http.request",
extra={
"method": request.method,
"path": request.url.path,
"status": response.status_code,
"duration_ms": duration_ms,
"client_id": str(client_id),
},
)
return response

View File

@ -0,0 +1,3 @@
from app.middleware import limiter, get_upload_rate_limit, get_ai_rate_limit
__all__ = ["limiter", "get_upload_rate_limit", "get_ai_rate_limit"]

4
app/models/__init__.py Normal file
View File

@ -0,0 +1,4 @@
from app.models.image import Image, ProcessingStatus
from app.models.client import APIClient, ClientPlan
__all__ = ["Image", "ProcessingStatus", "APIClient", "ClientPlan"]

70
app/models/client.py Normal file
View File

@ -0,0 +1,70 @@
"""
Modèle SQLAlchemy APIClient : clients authentifiés du hub
"""
import enum
import uuid as uuid_lib
from datetime import datetime, timezone
from sqlalchemy import (
Column, String, JSON, Boolean, DateTime, Enum as SAEnum,
BigInteger, Integer,
)
from sqlalchemy.orm import relationship
from app.database import Base
class ClientPlan(str, enum.Enum):
FREE = "free"
STANDARD = "standard"
PREMIUM = "premium"
class APIClient(Base):
__tablename__ = "api_clients"
# ── Identité ──────────────────────────────────────────────
id = Column(
String(36),
primary_key=True,
default=lambda: str(uuid_lib.uuid4()),
index=True,
)
name = Column(String(256), nullable=False)
# ── Authentification ──────────────────────────────────────
api_key_hash = Column(String(64), nullable=False, unique=True, index=True)
# ── Permissions ───────────────────────────────────────────
scopes = Column(JSON, nullable=False, default=list)
plan = Column(
SAEnum(ClientPlan),
default=ClientPlan.FREE,
nullable=False,
)
is_active = Column(Boolean, default=True, nullable=False)
# ── Quota tracking ─────────────────────────────────────────
storage_used_bytes = Column(BigInteger, default=0, nullable=False)
quota_storage_mb = Column(Integer, default=500, nullable=False)
quota_images = Column(Integer, default=1000, nullable=False)
# ── Timestamps ────────────────────────────────────────────
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(
DateTime,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
# ── Relations (ajoutée par Livrable 1.2) ──────────────────
images = relationship(
"Image",
back_populates="client",
cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<APIClient id={self.id} name={self.name} plan={self.plan}>"
def has_scope(self, scope: str) -> bool:
"""Vérifie si le client possède le scope demandé."""
return scope in (self.scopes or [])

97
app/models/image.py Normal file
View File

@ -0,0 +1,97 @@
"""
Modèle SQLAlchemy Image et métadonnées associées
"""
import enum
from datetime import datetime, timezone
from sqlalchemy import (
Column, Integer, String, Text, DateTime,
JSON, Float, Enum as SAEnum, BigInteger, Boolean, ForeignKey
)
from sqlalchemy.orm import relationship
from app.database import Base
class ProcessingStatus(str, enum.Enum):
PENDING = "pending"
PROCESSING = "processing"
DONE = "done"
ERROR = "error"
class Image(Base):
__tablename__ = "images"
# ── Identité ──────────────────────────────────────────────
id = Column(Integer, primary_key=True, index=True)
uuid = Column(String(36), unique=True, index=True, nullable=False)
# ── Client (multi-tenant) ─────────────────────────────────
client_id = Column(String(36), ForeignKey("api_clients.id"), nullable=False, index=True)
client = relationship("APIClient", back_populates="images")
# ── Fichier ───────────────────────────────────────────────
original_name = Column(String(512), nullable=False)
filename = Column(String(512), nullable=False) # nom sur disque (uuid-based)
file_path = Column(String(1024), nullable=False)
thumbnail_path = Column(String(1024))
mime_type = Column(String(128))
file_size = Column(BigInteger) # bytes
width = Column(Integer)
height = Column(Integer)
uploaded_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
# ── Statut du pipeline AI ─────────────────────────────────
processing_status = Column(
SAEnum(ProcessingStatus),
default=ProcessingStatus.PENDING,
nullable=False,
index=True
)
processing_error = Column(Text)
processing_started_at = Column(DateTime)
processing_done_at = Column(DateTime)
# ── Métadonnées EXIF ──────────────────────────────────────
exif_raw = Column(JSON) # dict complet brut
exif_make = Column(String(256)) # Appareil — fabricant
exif_model = Column(String(256)) # Appareil — modèle
exif_lens = Column(String(256))
exif_taken_at = Column(DateTime) # DateTimeOriginal EXIF
exif_gps_lat = Column(Float)
exif_gps_lon = Column(Float)
exif_altitude = Column(Float)
exif_iso = Column(Integer)
exif_aperture = Column(String(32)) # ex: "f/2.8"
exif_shutter = Column(String(32)) # ex: "1/250"
exif_focal = Column(String(32)) # ex: "50mm"
exif_flash = Column(Boolean)
exif_orientation = Column(Integer)
exif_software = Column(String(256))
# ── OCR ───────────────────────────────────────────────────
ocr_text = Column(Text)
ocr_language = Column(String(64))
ocr_confidence = Column(Float) # 0.0 1.0
ocr_has_text = Column(Boolean, default=False)
# ── AI Vision ─────────────────────────────────────────────
ai_description = Column(Text)
ai_tags = Column(JSON) # ["nature", "paysage", ...]
ai_confidence = Column(Float) # score de confiance global
ai_model_used = Column(String(128))
ai_processed_at = Column(DateTime)
ai_prompt_tokens = Column(Integer)
ai_output_tokens = Column(Integer)
def __repr__(self):
return f"<Image id={self.id} name={self.original_name} status={self.processing_status}>"
@property
def has_gps(self) -> bool:
return self.exif_gps_lat is not None and self.exif_gps_lon is not None
@property
def dimensions(self) -> str | None:
if self.width and self.height:
return f"{self.width}x{self.height}"
return None

6
app/routers/__init__.py Normal file
View File

@ -0,0 +1,6 @@
from app.routers.images import router as images_router
from app.routers.ai import router as ai_router
from app.routers.auth import router as auth_router
from app.routers.files import router as files_router
__all__ = ["images_router", "ai_router", "auth_router", "files_router"]

102
app/routers/ai.py Normal file
View File

@ -0,0 +1,102 @@
"""
Router AI : résumé d'URL, rédaction de tâches
Sécurisé : authentification par API Key + scope ai:use + rate limiting.
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from app.dependencies.auth import get_current_client, require_scope
from app.models.client import APIClient
from app.schemas import (
SummarizeRequest, SummarizeResponse,
DraftTaskRequest, DraftTaskResponse,
)
from app.services.scraper import fetch_page_content
from app.services.ai_vision import summarize_url, draft_task
from app.config import settings
from app.middleware import limiter
router = APIRouter(prefix="/ai", tags=["Intelligence Artificielle"])
@router.post(
"/summarize",
response_model=SummarizeResponse,
summary="Résumé AI d'une URL",
description=(
"Scrappe le contenu d'une URL et génère un résumé structuré + tags via AI. "
"Utile pour enrichir les bookmarks Shaarli."
),
dependencies=[Depends(require_scope("ai:use"))],
)
@limiter.limit("1000/hour")
async def summarize_link(
request: Request,
body: SummarizeRequest,
client: APIClient = Depends(get_current_client),
):
if not settings.AI_ENABLED:
raise HTTPException(status_code=503, detail="AI désactivée")
# Scraping
page = await fetch_page_content(body.url)
if page.get("error"):
raise HTTPException(
status_code=422,
detail=f"Impossible de récupérer la page : {page['error']}",
)
content = " ".join(filter(None, [
page.get("title"),
page.get("description"),
page.get("text"),
]))
if not content.strip():
raise HTTPException(status_code=422, detail="Aucun contenu texte trouvé sur cette page")
# Résumé AI
result = await summarize_url(
url=body.url,
content=content,
language=body.language,
)
return SummarizeResponse(
url=body.url,
title=page.get("title"),
summary=result["summary"],
tags=result["tags"],
model=result["model"],
)
@router.post(
"/draft-task",
response_model=DraftTaskResponse,
summary="Rédaction AI d'une tâche",
description=(
"Génère une tâche structurée (titre, description, étapes, priorité) "
"à partir d'une description libre."
),
dependencies=[Depends(require_scope("ai:use"))],
)
@limiter.limit("1000/hour")
async def generate_task(
request: Request,
body: DraftTaskRequest,
client: APIClient = Depends(get_current_client),
):
if not settings.AI_ENABLED:
raise HTTPException(status_code=503, detail="AI désactivée")
result = await draft_task(
description=body.description,
context=body.context,
language=body.language,
)
if not result.get("title"):
raise HTTPException(status_code=500, detail="Échec de la génération de la tâche")
return DraftTaskResponse(**result)

198
app/routers/auth.py Normal file
View File

@ -0,0 +1,198 @@
"""
Router Auth : gestion des clients API (CRUD + rotation de clé)
"""
import logging
import secrets
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies.auth import get_current_client, hash_api_key, require_scope
from app.models.client import APIClient
from app.schemas.auth import (
ClientCreate,
ClientCreateResponse,
ClientResponse,
ClientUpdate,
KeyRotateResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["Authentification"])
# ─────────────────────────────────────────────────────────────
# CRÉER UN CLIENT
# ─────────────────────────────────────────────────────────────
@router.post(
"/clients",
response_model=ClientCreateResponse,
status_code=status.HTTP_201_CREATED,
summary="Créer un nouveau client API",
description=(
"Crée un client et retourne la clé API **en clair une seule fois**. "
"Stockez-la immédiatement — elle ne sera plus jamais affichée."
),
dependencies=[Depends(require_scope("admin"))],
)
async def create_client(
body: ClientCreate,
db: AsyncSession = Depends(get_db),
) -> ClientCreateResponse:
# Génération de la clé API
raw_key = secrets.token_urlsafe(32)
key_hash = hash_api_key(raw_key)
client = APIClient(
name=body.name,
api_key_hash=key_hash,
scopes=body.scopes,
plan=body.plan,
)
db.add(client)
await db.flush()
await db.refresh(client)
logger.info("Client créé : %s (%s)", client.name, client.id)
return ClientCreateResponse(
id=client.id,
name=client.name,
scopes=client.scopes,
plan=client.plan,
is_active=client.is_active,
created_at=client.created_at,
updated_at=client.updated_at,
api_key=raw_key,
)
# ─────────────────────────────────────────────────────────────
# LISTER LES CLIENTS
# ─────────────────────────────────────────────────────────────
@router.get(
"/clients",
response_model=list[ClientResponse],
summary="Lister tous les clients API",
dependencies=[Depends(require_scope("admin"))],
)
async def list_clients(
db: AsyncSession = Depends(get_db),
) -> list[ClientResponse]:
result = await db.execute(select(APIClient).order_by(APIClient.created_at.desc()))
clients = result.scalars().all()
return [ClientResponse.model_validate(c) for c in clients]
# ─────────────────────────────────────────────────────────────
# DÉTAIL D'UN CLIENT
# ─────────────────────────────────────────────────────────────
@router.get(
"/clients/{client_id}",
response_model=ClientResponse,
summary="Détail d'un client API",
dependencies=[Depends(require_scope("admin"))],
)
async def get_client(
client_id: str,
db: AsyncSession = Depends(get_db),
) -> ClientResponse:
result = await db.execute(select(APIClient).where(APIClient.id == client_id))
client = result.scalar_one_or_none()
if not client:
raise HTTPException(status_code=404, detail="Client introuvable")
return ClientResponse.model_validate(client)
# ─────────────────────────────────────────────────────────────
# MODIFIER UN CLIENT
# ─────────────────────────────────────────────────────────────
@router.patch(
"/clients/{client_id}",
response_model=ClientResponse,
summary="Modifier un client API",
dependencies=[Depends(require_scope("admin"))],
)
async def update_client(
client_id: str,
body: ClientUpdate,
db: AsyncSession = Depends(get_db),
) -> ClientResponse:
result = await db.execute(select(APIClient).where(APIClient.id == client_id))
client = result.scalar_one_or_none()
if not client:
raise HTTPException(status_code=404, detail="Client introuvable")
update_data = body.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(client, field, value)
await db.flush()
await db.refresh(client)
logger.info("Client mis à jour : %s (%s)", client.name, client.id)
return ClientResponse.model_validate(client)
# ─────────────────────────────────────────────────────────────
# ROTATION DE CLÉ
# ─────────────────────────────────────────────────────────────
@router.post(
"/clients/{client_id}/rotate-key",
response_model=KeyRotateResponse,
summary="Régénérer la clé API d'un client",
description="Invalide l'ancienne clé et en génère une nouvelle.",
dependencies=[Depends(require_scope("admin"))],
)
async def rotate_key(
client_id: str,
db: AsyncSession = Depends(get_db),
) -> KeyRotateResponse:
result = await db.execute(select(APIClient).where(APIClient.id == client_id))
client = result.scalar_one_or_none()
if not client:
raise HTTPException(status_code=404, detail="Client introuvable")
raw_key = secrets.token_urlsafe(32)
client.api_key_hash = hash_api_key(raw_key)
await db.flush()
logger.info("Clé API rotée pour client : %s (%s)", client.name, client.id)
return KeyRotateResponse(id=client.id, api_key=raw_key)
# ─────────────────────────────────────────────────────────────
# DÉSACTIVER UN CLIENT (soft delete)
# ─────────────────────────────────────────────────────────────
@router.delete(
"/clients/{client_id}",
response_model=ClientResponse,
summary="Désactiver un client API",
description="Soft delete — marque le client comme inactif sans supprimer les données.",
dependencies=[Depends(require_scope("admin"))],
)
async def delete_client(
client_id: str,
db: AsyncSession = Depends(get_db),
) -> ClientResponse:
result = await db.execute(select(APIClient).where(APIClient.id == client_id))
client = result.scalar_one_or_none()
if not client:
raise HTTPException(status_code=404, detail="Client introuvable")
client.is_active = False
await db.flush()
await db.refresh(client)
logger.info("Client désactivé : %s (%s)", client.name, client.id)
return ClientResponse.model_validate(client)

68
app/routers/files.py Normal file
View File

@ -0,0 +1,68 @@
"""
Router Files : sert les fichiers locaux via URLs signées HMAC.
Monté uniquement quand STORAGE_BACKEND == "local".
"""
import logging
from pathlib import Path
from fastapi import APIRouter, HTTPException, status
from fastapi.responses import FileResponse
from app.config import settings
from app.services.storage_backend import get_storage_backend, LocalStorage
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/files", tags=["Fichiers"])
@router.get(
"/signed/{token}",
summary="Télécharger un fichier via URL signée",
description="Valide le token HMAC et retourne le fichier correspondant.",
)
async def serve_signed_file(token: str):
"""Sert un fichier local via un token HMAC signé."""
backend = get_storage_backend()
if not isinstance(backend, LocalStorage):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Endpoint non disponible avec le backend de stockage actuel",
)
# Valider le token
path = backend.validate_token(token)
if path is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Token invalide ou expiré",
)
# Vérifier que le fichier existe
abs_path = backend.get_absolute_path(path)
if not abs_path.exists():
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail="Le fichier n'existe plus",
)
# Détecter le content type
suffix = abs_path.suffix.lower()
mime_map = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".tiff": "image/tiff",
}
media_type = mime_map.get(suffix, "application/octet-stream")
return FileResponse(
path=str(abs_path),
media_type=media_type,
filename=abs_path.name,
)

500
app/routers/images.py Normal file
View File

@ -0,0 +1,500 @@
"""
Router Images : upload, lecture, suppression, retraitement
Sécurisé : authentification par API Key + isolation par client_id.
"""
import logging
import math
from typing import Optional
from fastapi import (
APIRouter, Depends, HTTPException, UploadFile, File,
Request, status, Query,
)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_
from app.database import get_db
from app.dependencies.auth import get_current_client, require_scope
from app.models.client import APIClient
from app.models.image import Image, ProcessingStatus
from app.schemas import (
UploadResponse, ImageDetail, ImageSummary,
StatusResponse, PaginatedImages, DeleteResponse,
TagsResponse, ReprocessResponse,
)
from app.services import storage
from app.middleware import limiter, get_upload_rate_limit
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/images", tags=["Images"])
# ─────────────────────────────────────────────────────────────
# UTILITAIRE : récupérer une image avec isolation client
# ─────────────────────────────────────────────────────────────
async def get_image_or_404(
image_id: int, client_id: str, db: AsyncSession
) -> Image:
"""
Récupère une image par ID en vérifiant qu'elle appartient au client.
Lève HTTP 404 si introuvable ou si elle n'appartient pas au client.
"""
result = await db.execute(
select(Image).where(
Image.id == image_id,
Image.client_id == client_id,
)
)
image = result.scalar_one_or_none()
if not image:
raise HTTPException(status_code=404, detail="Image introuvable")
return image
def _dynamic_upload_limit(key: str) -> str:
"""Retourne la limite dynamique basée sur le plan du client."""
# On parse le plan depuis la clé ou le state — fallback free
return get_upload_rate_limit("free")
# ─────────────────────────────────────────────────────────────
# UPLOAD
# ─────────────────────────────────────────────────────────────
@router.post(
"/upload",
response_model=UploadResponse,
status_code=status.HTTP_201_CREATED,
summary="Uploader une image",
description="Upload une image, lance automatiquement le pipeline AI (EXIF + OCR + Vision).",
dependencies=[Depends(require_scope("images:write"))],
)
@limiter.limit("500/hour")
async def upload_image(
request: Request,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
# Vérification quota avant upload
quota_mb = client.quota_storage_mb or 500
used_bytes = client.storage_used_bytes or 0
if used_bytes >= quota_mb * 1024 * 1024:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Quota de stockage dépassé ({quota_mb} MB)",
)
# Sauvegarde fichier + thumbnail (isolé par client_id)
file_data = await storage.save_upload(file, client_id=client.id)
# Création de l'enregistrement BDD
image = Image(**file_data)
db.add(image)
# Mise à jour du quota
file_size = file_data.get("file_size", 0)
client.storage_used_bytes = (client.storage_used_bytes or 0) + file_size
await db.commit()
await db.refresh(image)
# Enqueue dans ARQ (persistant, avec retry)
arq_pool = request.app.state.arq_pool
queue_name = "premium" if client.plan and client.plan.value == "premium" else "standard"
await arq_pool.enqueue_job(
"process_image_task",
image.id,
str(client.id),
_queue_name=queue_name,
)
return UploadResponse(
id=image.id,
uuid=image.uuid,
original_name=image.original_name,
status=image.processing_status,
)
# ─────────────────────────────────────────────────────────────
# LISTE
# ─────────────────────────────────────────────────────────────
@router.get(
"",
response_model=PaginatedImages,
summary="Lister les images",
dependencies=[Depends(require_scope("images:read"))],
)
async def list_images(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
tag: Optional[str] = Query(None, description="Filtrer par tag AI"),
status_filter: Optional[ProcessingStatus] = Query(None, alias="status"),
search: Optional[str] = Query(None, description="Recherche dans description et OCR"),
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
# Filtre d'isolation par client
query = select(Image).where(Image.client_id == client.id)
if status_filter:
query = query.where(Image.processing_status == status_filter)
if tag:
query = query.where(Image.ai_tags.contains([tag]))
if search:
query = query.where(
or_(
Image.ai_description.ilike(f"%{search}%"),
Image.ocr_text.ilike(f"%{search}%"),
Image.original_name.ilike(f"%{search}%"),
)
)
# Count total
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar_one()
# Pagination
offset = (page - 1) * page_size
query = query.order_by(Image.uploaded_at.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
images = result.scalars().all()
# Quota info
used_mb = round((client.storage_used_bytes or 0) / (1024 * 1024), 2)
quota_mb = client.quota_storage_mb or 500
pct = round(used_mb / quota_mb * 100, 1) if quota_mb > 0 else 0.0
return PaginatedImages(
total=total,
page=page,
page_size=page_size,
pages=math.ceil(total / page_size) if total else 0,
storage_used_mb=used_mb,
storage_quota_mb=quota_mb,
quota_pct=pct,
items=[
ImageSummary(
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,
ai_tags=img.ai_tags,
ai_description=img.ai_description,
thumbnail_path=img.thumbnail_path,
)
for img in images
],
)
# ─────────────────────────────────────────────────────────────
# DÉTAIL COMPLET
# ─────────────────────────────────────────────────────────────
@router.get(
"/{image_id}",
response_model=ImageDetail,
summary="Détail complet d'une image",
description="Retourne toutes les données : fichier, EXIF, OCR et résultats AI.",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_image(
image_id: int,
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
image = await get_image_or_404(image_id, client.id, db)
return ImageDetail.from_orm_full(image)
# ─────────────────────────────────────────────────────────────
# STATUT DU PIPELINE
# ─────────────────────────────────────────────────────────────
@router.get(
"/{image_id}/status",
response_model=StatusResponse,
summary="Statut du traitement AI",
description="Permet de poller l'avancement du pipeline (pending → processing → done/error).",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_status(
image_id: int,
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
image = await get_image_or_404(image_id, client.id, db)
return StatusResponse(
id=image.id,
uuid=image.uuid,
status=image.processing_status,
error=image.processing_error,
started_at=image.processing_started_at,
done_at=image.processing_done_at,
)
# ─────────────────────────────────────────────────────────────
# DONNÉES EXIF
# ─────────────────────────────────────────────────────────────
@router.get(
"/{image_id}/exif",
summary="Métadonnées EXIF de l'image",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_exif(
image_id: int,
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
image = await get_image_or_404(image_id, client.id, db)
return {
"id": image.id,
"camera": {
"make": image.exif_make,
"model": image.exif_model,
"lens": image.exif_lens,
"iso": image.exif_iso,
"aperture": image.exif_aperture,
"shutter_speed": image.exif_shutter,
"focal_length": image.exif_focal,
"flash": image.exif_flash,
"orientation": image.exif_orientation,
"software": image.exif_software,
"taken_at": image.exif_taken_at,
},
"gps": {
"latitude": image.exif_gps_lat,
"longitude": image.exif_gps_lon,
"altitude": image.exif_altitude,
"has_gps": image.has_gps,
"maps_url": (
f"https://maps.google.com/?q={image.exif_gps_lat},{image.exif_gps_lon}"
if image.has_gps else None
),
},
"raw": image.exif_raw,
}
# ─────────────────────────────────────────────────────────────
# DONNÉES OCR
# ─────────────────────────────────────────────────────────────
@router.get(
"/{image_id}/ocr",
summary="Texte extrait de l'image (OCR)",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_ocr(
image_id: int,
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
image = await get_image_or_404(image_id, client.id, db)
return {
"id": image.id,
"has_text": image.ocr_has_text,
"text": image.ocr_text,
"language": image.ocr_language,
"confidence": image.ocr_confidence,
}
# ─────────────────────────────────────────────────────────────
# DONNÉES AI
# ─────────────────────────────────────────────────────────────
@router.get(
"/{image_id}/ai",
summary="Résultats AI (description + tags)",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_ai(
image_id: int,
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
image = await get_image_or_404(image_id, client.id, db)
return {
"id": image.id,
"description": image.ai_description,
"tags": image.ai_tags,
"confidence": image.ai_confidence,
"model_used": image.ai_model_used,
"processed_at": image.ai_processed_at,
"tokens": {
"prompt": image.ai_prompt_tokens,
"output": image.ai_output_tokens,
"total": (image.ai_prompt_tokens or 0) + (image.ai_output_tokens or 0),
},
}
# ─────────────────────────────────────────────────────────────
# TAGS — Vue globale (filtrée par client)
# ─────────────────────────────────────────────────────────────
@router.get(
"/tags/all",
response_model=TagsResponse,
summary="Tous les tags utilisés",
description="Liste dédupliquée de tous les tags AI générés sur les images du client.",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_all_tags(
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
result = await db.execute(
select(Image.ai_tags).where(
Image.client_id == client.id,
Image.ai_tags.isnot(None),
)
)
all_tag_lists = result.scalars().all()
unique_tags = sorted(set(
tag
for tag_list in all_tag_lists
for tag in (tag_list or [])
))
return TagsResponse(tags=unique_tags, total=len(unique_tags))
# ─────────────────────────────────────────────────────────────
# RETRAITEMENT AI
# ─────────────────────────────────────────────────────────────
@router.post(
"/{image_id}/reprocess",
response_model=ReprocessResponse,
summary="Relancer le pipeline AI",
description="Reprocess une image existante (utile après changement de modèle AI).",
dependencies=[Depends(require_scope("images:write"))],
)
@limiter.limit("500/hour")
async def reprocess_image(
request: Request,
image_id: int,
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
image = await get_image_or_404(image_id, client.id, db)
# Reset du statut
image.processing_status = ProcessingStatus.PENDING
image.processing_error = None
image.processing_started_at = None
image.processing_done_at = None
await db.commit()
# Enqueue dans ARQ
arq_pool = request.app.state.arq_pool
queue_name = "premium" if client.plan and client.plan.value == "premium" else "standard"
await arq_pool.enqueue_job(
"process_image_task",
image_id,
str(client.id),
_queue_name=queue_name,
)
return ReprocessResponse(id=image_id)
# ─────────────────────────────────────────────────────────────
# SUPPRESSION
# ─────────────────────────────────────────────────────────────
@router.delete(
"/{image_id}",
response_model=DeleteResponse,
summary="Supprimer une image",
dependencies=[Depends(require_scope("images:delete"))],
)
async def delete_image(
image_id: int,
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
image = await get_image_or_404(image_id, client.id, db)
# Décrémentation du quota
file_size = image.file_size or 0
client.storage_used_bytes = max(0, (client.storage_used_bytes or 0) - file_size)
# Suppression des fichiers sur disque
storage.delete_files(image.file_path, image.thumbnail_path)
await db.delete(image)
await db.commit()
return DeleteResponse(deleted_id=image_id)
# ─────────────────────────────────────────────────────────────
# URLs SIGNÉES
# ─────────────────────────────────────────────────────────────
@router.get(
"/{image_id}/download-url",
summary="URL signée de téléchargement",
description="Retourne une URL signée temporaire pour télécharger l'image originale.",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_download_url(
image_id: int,
expires_in: int = Query(900, ge=60, le=86400, description="Durée de validité en secondes"),
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
from app.services.storage_backend import get_storage_backend
image = await get_image_or_404(image_id, client.id, db)
backend = get_storage_backend()
url = await backend.get_signed_url(image.file_path, expires_in=expires_in)
return {"url": url, "expires_in": expires_in}
@router.get(
"/{image_id}/thumbnail-url",
summary="URL signée du thumbnail",
description="Retourne une URL signée temporaire pour le thumbnail de l'image.",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_thumbnail_url(
image_id: int,
expires_in: int = Query(900, ge=60, le=86400, description="Durée de validité en secondes"),
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
from app.services.storage_backend import get_storage_backend
image = await get_image_or_404(image_id, client.id, db)
if not image.thumbnail_path:
raise HTTPException(status_code=404, detail="Thumbnail non disponible")
backend = get_storage_backend()
url = await backend.get_signed_url(image.thumbnail_path, expires_in=expires_in)
return {"url": url, "expires_in": expires_in}

228
app/schemas/__init__.py Normal file
View File

@ -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

67
app/schemas/auth.py Normal file
View File

@ -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"

3
app/services/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from app.services import storage, exif_service, ocr_service, ai_vision, scraper, pipeline
__all__ = ["storage", "exif_service", "ocr_service", "ai_vision", "scraper", "pipeline"]

417
app/services/ai_vision.py Normal file
View File

@ -0,0 +1,417 @@
"""
Service AI Vision description, classification et tags via Google Gemini ou OpenRouter
"""
import asyncio
import json
import logging
import re
import base64
import httpx
from pathlib import Path
from typing import Optional, Tuple
from google import genai
from google.genai import types
from app.config import settings
logger = logging.getLogger(__name__)
_client: Optional[genai.Client] = None
def _get_client() -> genai.Client:
global _client
if _client is None:
_client = genai.Client(api_key=settings.GEMINI_API_KEY)
return _client
def _read_image(file_path: str) -> tuple[bytes, str]:
"""Lit l'image en bytes et détecte le media_type."""
path = Path(file_path)
suffix = path.suffix.lower()
mime_map = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
}
media_type = mime_map.get(suffix, "image/jpeg")
with open(path, "rb") as f:
data = f.read()
return data, media_type
def _extract_json(text: str) -> Optional[dict]:
cleaned = re.sub(r"```json\s*|```\s*", "", (text or "")).strip()
json_match = re.search(r"\{.*\}", cleaned, re.DOTALL)
if not json_match:
return None
try:
return json.loads(json_match.group())
except json.JSONDecodeError:
return None
def _usage_tokens_gemini(response) -> tuple[Optional[int], Optional[int]]:
usage = getattr(response, "usage_metadata", None)
if not usage:
return None, None
prompt_tokens = getattr(usage, "prompt_token_count", None)
output_tokens = getattr(usage, "candidates_token_count", None)
return prompt_tokens, output_tokens
async def _generate_gemini(
prompt: str,
image_bytes: Optional[bytes] = None,
media_type: Optional[str] = None,
max_tokens: int = 1024
) -> dict:
"""Appel à Google Gemini via SDK."""
if not settings.GEMINI_API_KEY:
logger.warning("ai.gemini.no_key")
return {"text": None, "usage": (None, None)}
client = _get_client()
contents = []
if image_bytes and media_type:
contents.append(types.Part.from_bytes(data=image_bytes, mime_type=media_type))
contents.append(prompt)
try:
# Le SDK est sync, on le run dans un thread
response = await asyncio.to_thread(
client.models.generate_content,
model=settings.GEMINI_MODEL,
contents=contents,
config=types.GenerateContentConfig(
max_output_tokens=max_tokens,
response_mime_type="application/json",
),
)
usage = _usage_tokens_gemini(response)
return {"text": getattr(response, "text", ""), "usage": usage}
except Exception as e:
logger.error("ai.gemini.error", extra={"error": str(e)})
return {"text": None, "usage": (None, None), "error": str(e)}
async def _generate_openrouter(
prompt: str,
image_bytes: Optional[bytes] = None,
media_type: Optional[str] = None,
max_tokens: int = 1024
) -> dict:
"""Appel à OpenRouter via HTTP."""
if not settings.OPENROUTER_API_KEY:
logger.warning("ai.openrouter.no_key")
return {"text": None, "usage": (None, None)}
headers = {
"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}",
"Content-Type": "application/json",
"HTTP-Referer": settings.HOST,
"X-Title": settings.APP_NAME,
}
messages = []
content_payload = []
content_payload.append({"type": "text", "text": prompt})
if image_bytes and media_type:
b64_img = base64.b64encode(image_bytes).decode("utf-8")
content_payload.append({
"type": "image_url",
"image_url": {
"url": f"data:{media_type};base64,{b64_img}"
}
})
messages.append({"role": "user", "content": content_payload})
payload = {
"model": settings.OPENROUTER_MODEL,
"messages": messages,
"max_tokens": max_tokens,
# OpenRouter/OpenAI support response_format={"type": "json_object"} pour certains modèles
# On tente le coup si le modèle est compatible, sinon le prompt engineering fait le travail
"response_format": {"type": "json_object"}
}
async with httpx.AsyncClient() as client:
try:
response = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
json=payload,
headers=headers,
timeout=60.0
)
response.raise_for_status()
data = response.json()
text = ""
if "choices" in data and len(data["choices"]) > 0:
text = data["choices"][0]["message"]["content"]
usage_data = data.get("usage", {})
prompt_tokens = usage_data.get("prompt_tokens")
output_tokens = usage_data.get("completion_tokens")
return {"text": text, "usage": (prompt_tokens, output_tokens)}
except Exception as e:
logger.error("ai.openrouter.error", extra={"error": str(e)})
return {"text": None, "usage": (None, None), "error": str(e)}
async def _generate(
prompt: str,
image_bytes: Optional[bytes] = None,
media_type: Optional[str] = None,
max_tokens: int = 1024
) -> dict:
"""Dispatcher vers le bon provider."""
provider = settings.AI_PROVIDER.lower()
logger.info("ai.generate", extra={"provider": provider})
if provider == "openrouter":
return await _generate_openrouter(prompt, image_bytes, media_type, max_tokens)
else:
# Default to Gemini
return await _generate_gemini(prompt, image_bytes, media_type, max_tokens)
def _build_prompt(ocr_hint: Optional[str], language: str) -> str:
ocr_section = ""
if ocr_hint and len(ocr_hint.strip()) > 5:
ocr_section = f"""
Texte détecté dans l'image par OCR (utilise-le pour enrichir ta réponse) :
\"\"\"
{ocr_hint[:500]}
\"\"\"
"""
return f"""Analyse cette image avec précision et retourne UNIQUEMENT un objet JSON valide avec ces champs :
{{
"description": "Description complète et détaillée en {language}, 2-4 phrases. Décris le sujet principal, le contexte, les couleurs, l'ambiance.",
"tags": ["tag1", "tag2", "tag3"],
"confidence": 0.95
}}
Règles pour les tags :
- Entre {settings.AI_TAGS_MIN} et {settings.AI_TAGS_MAX} tags
- En minuscules, sans espaces (utiliser des tirets si nécessaire)
- Couvrir : sujet principal, type d'image, couleurs dominantes, style, contexte
- Exemples : portrait, paysage, architecture, nature, nourriture, texte, document, animal, sport, technologie, intérieur, extérieur
{ocr_section}
Réponds UNIQUEMENT avec le JSON, sans texte avant ou après, sans balises markdown."""
async def analyze_image(
file_path: str,
ocr_hint: Optional[str] = None,
language: str = "français",
) -> dict:
"""
Envoie l'image à l'AI pour analyse (Description + Tags).
"""
if not settings.AI_ENABLED:
return {}
result = {
"description": None,
"tags": [],
"confidence": None,
"model": settings.OPENROUTER_MODEL if settings.AI_PROVIDER == "openrouter" else settings.GEMINI_MODEL,
"prompt_tokens": None,
"output_tokens": None,
}
try:
image_bytes, media_type = _read_image(file_path)
prompt = _build_prompt(ocr_hint, language)
response = await _generate(
prompt=prompt,
image_bytes=image_bytes,
media_type=media_type,
max_tokens=settings.GEMINI_MAX_TOKENS # Ou une config unifiée
)
text = response.get("text")
result["prompt_tokens"], result["output_tokens"] = response.get("usage")
if text:
parsed = _extract_json(text)
if parsed:
result["description"] = parsed.get("description")
result["tags"] = parsed.get("tags", [])
result["confidence"] = parsed.get("confidence")
else:
logger.warning("ai.vision.json_parse_failed", extra={"raw": text[:100]})
if response.get("error"):
logger.error("ai.vision.provider_error", extra={"error": response['error']})
except Exception as e:
logger.error("ai.vision.unexpected_error", extra={"error": str(e)})
return result
async def extract_text_with_ai(file_path: str) -> dict:
"""
Utilise l'AI comme fallback OCR.
"""
result = {
"text": None,
"has_text": False,
"language": "unknown",
"confidence": 0.0,
"method": f"ai-{settings.AI_PROVIDER}"
}
if not settings.AI_ENABLED:
return result
logger.info("ai.ocr.fallback_start", extra={"file": Path(file_path).name})
try:
image_bytes, media_type = _read_image(file_path)
prompt = """Agis comme un moteur OCR avancé.
Extrais TOUT le texte visible dans cette image.
Retourne UNIQUEMENT un objet JSON :
{
"text": "Le texte complet extrait ici...",
"language": "fr" (code langue ISO 2 lettres, ex: fr, en, es),
"confidence": 0.9 (estimation confiance 0.0 à 1.0)
}
Si aucun texte n'est visible, retourne : {"text": "", "has_text": false}
"""
response = await _generate(
prompt=prompt,
image_bytes=image_bytes,
media_type=media_type,
max_tokens=1024
)
text = response.get("text")
if text:
parsed = _extract_json(text)
if parsed:
extracted = parsed.get("text", "").strip()
result["text"] = extracted
result["has_text"] = bool(extracted) or parsed.get("has_text", False)
result["language"] = parsed.get("language", "unknown")
result["confidence"] = parsed.get("confidence", 0.0)
logger.info("ai.ocr.success", extra={"chars": len(extracted)})
else:
logger.warning("ai.ocr.json_parse_failed")
else:
logger.info("ai.ocr.empty_response")
except Exception as e:
logger.error("ai.ocr.error", extra={"error": str(e)})
return result
async def summarize_url(url: str, content: str, language: str = "français") -> dict:
"""Génère un résumé et des tags pour un contenu web."""
result = {
"summary": "",
"tags": [],
"model": settings.AI_PROVIDER,
}
if not settings.AI_ENABLED:
return result
prompt = f"""Tu reçois le contenu d'une page web. Génère un résumé et des tags en {language}.
URL : {url}
Contenu :
\"\"\"
{content[:3000]}
\"\"\"
Retourne UNIQUEMENT ce JSON :
{{
"summary": "Résumé clair en 3-5 phrases en {language}",
"tags": ["tag1", "tag2", "tag3"]
}}"""
try:
response = await _generate(
prompt=prompt,
max_tokens=settings.GEMINI_MAX_TOKENS
)
text = response.get("text")
if text:
parsed = _extract_json(text)
if parsed:
result["summary"] = parsed.get("summary", "")
result["tags"] = parsed.get("tags", [])
except Exception as e:
logger.error("ai.summarize_url.error", extra={"error": str(e)})
return result
async def draft_task(description: str, context: Optional[str], language: str = "français") -> dict:
"""Génère une tâche structurée à partir d'une description."""
result = {
"title": "",
"description": "",
"steps": [],
"estimated_time": None,
"priority": None,
}
if not settings.AI_ENABLED:
return result
ctx_section = f"\nContexte : {context}" if context else ""
prompt = f"""Tu es un assistant de gestion de tâches. Génère une tâche structurée en {language}.
Description : {description}{ctx_section}
Retourne UNIQUEMENT ce JSON :
{{
"title": "Titre court et actionnable",
"description": "Description complète de la tâche",
"steps": ["Étape 1", "Étape 2", "Étape 3"],
"estimated_time": "30 minutes",
"priority": "haute|moyenne|basse"
}}"""
try:
response = await _generate(
prompt=prompt,
max_tokens=settings.GEMINI_MAX_TOKENS
)
text = response.get("text")
if text:
parsed = _extract_json(text)
if parsed:
result.update(parsed)
except Exception as e:
logger.error("ai.draft_task.error", extra={"error": str(e)})
return result

View File

@ -0,0 +1,173 @@
"""
Service d'extraction EXIF — Pillow + piexif
"""
import logging
from datetime import datetime
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
import piexif
from PIL import Image as PILImage
from PIL.ExifTags import TAGS, GPSTAGS
def _dms_to_decimal(dms: tuple, ref: str) -> float | None:
"""Convertit les coordonnées GPS DMS (degrés/minutes/secondes) en décimal."""
try:
degrees = dms[0][0] / dms[0][1]
minutes = dms[1][0] / dms[1][1]
seconds = dms[2][0] / dms[2][1]
decimal = degrees + minutes / 60 + seconds / 3600
if ref in ("S", "W"):
decimal = -decimal
return round(decimal, 7)
except Exception:
return None
def _parse_rational(value) -> str | None:
"""Convertit un rationnel EXIF en chaîne lisible."""
try:
if isinstance(value, tuple) and len(value) == 2:
num, den = value
if den == 0:
return None
return f"{num}/{den}"
return str(value)
except Exception:
return None
def _safe_str(value: Any) -> str | None:
"""Décode les bytes en string si nécessaire."""
if value is None:
return None
if isinstance(value, bytes):
return value.decode("utf-8", errors="ignore").strip("\x00")
return str(value)
def extract_exif(file_path: str) -> dict:
"""
Extrait toutes les métadonnées EXIF d'une image.
Retourne un dict structuré avec les données parsées.
"""
result = {
"raw": {},
"make": None,
"model": None,
"lens": None,
"taken_at": None,
"gps_lat": None,
"gps_lon": None,
"altitude": None,
"iso": None,
"aperture": None,
"shutter": None,
"focal": None,
"flash": None,
"orientation": None,
"software": None,
}
try:
path = Path(file_path)
if not path.exists():
return result
# ── Lecture EXIF brute via piexif ─────────────────────
try:
exif_data = piexif.load(str(path))
except Exception:
# JPEG sans EXIF, PNG, etc.
return result
raw_dict = {}
# ── IFD 0 (Image principale) ──────────────────────────
ifd0 = exif_data.get("0th", {})
result["make"] = _safe_str(ifd0.get(piexif.ImageIFD.Make))
result["model"] = _safe_str(ifd0.get(piexif.ImageIFD.Model))
result["software"] = _safe_str(ifd0.get(piexif.ImageIFD.Software))
result["orientation"] = ifd0.get(piexif.ImageIFD.Orientation)
# ── EXIF IFD ──────────────────────────────────────────
exif = exif_data.get("Exif", {})
# Date de prise de vue
taken_raw = _safe_str(exif.get(piexif.ExifIFD.DateTimeOriginal))
if taken_raw:
try:
result["taken_at"] = datetime.strptime(taken_raw, "%Y:%m:%d %H:%M:%S")
except ValueError:
pass
# Paramètres de prise de vue
iso_val = exif.get(piexif.ExifIFD.ISOSpeedRatings)
result["iso"] = int(iso_val) if iso_val else None
aperture_val = exif.get(piexif.ExifIFD.FNumber)
if aperture_val:
try:
f = aperture_val[0] / aperture_val[1]
result["aperture"] = f"f/{f:.1f}"
except Exception:
pass
shutter_val = exif.get(piexif.ExifIFD.ExposureTime)
if shutter_val:
result["shutter"] = _parse_rational(shutter_val)
focal_val = exif.get(piexif.ExifIFD.FocalLength)
if focal_val:
try:
f = focal_val[0] / focal_val[1]
result["focal"] = f"{f:.0f}mm"
except Exception:
pass
flash_val = exif.get(piexif.ExifIFD.Flash)
result["flash"] = bool(flash_val & 1) if flash_val is not None else None
lens_val = _safe_str(exif.get(piexif.ExifIFD.LensModel))
result["lens"] = lens_val
# ── GPS IFD ───────────────────────────────────────────
gps = exif_data.get("GPS", {})
if gps:
lat_val = gps.get(piexif.GPSIFD.GPSLatitude)
lat_ref = _safe_str(gps.get(piexif.GPSIFD.GPSLatitudeRef))
lon_val = gps.get(piexif.GPSIFD.GPSLongitude)
lon_ref = _safe_str(gps.get(piexif.GPSIFD.GPSLongitudeRef))
if lat_val and lat_ref:
result["gps_lat"] = _dms_to_decimal(lat_val, lat_ref)
if lon_val and lon_ref:
result["gps_lon"] = _dms_to_decimal(lon_val, lon_ref)
alt_val = gps.get(piexif.GPSIFD.GPSAltitude)
if alt_val:
try:
result["altitude"] = round(alt_val[0] / alt_val[1], 2)
except Exception:
pass
# ── Dict brut lisible (TAGS humains) ──────────────────
with PILImage.open(path) as img:
raw_exif = img._getexif()
if raw_exif:
for tag_id, val in raw_exif.items():
tag = TAGS.get(tag_id, str(tag_id))
if isinstance(val, bytes):
val = val.decode("utf-8", errors="ignore")
elif isinstance(val, tuple):
val = list(val)
raw_dict[tag] = val
result["raw"] = raw_dict
except Exception as e:
logger.error("exif.extraction_error", extra={"file": file_path, "error": str(e)})
return result

107
app/services/ocr_service.py Normal file
View File

@ -0,0 +1,107 @@
"""
Service OCR extraction de texte via Tesseract
"""
import logging
from pathlib import Path
from PIL import Image as PILImage
from app.config import settings
logger = logging.getLogger(__name__)
try:
import pytesseract
_ocr_import_error: Exception | None = None
except Exception as e:
pytesseract = None
_ocr_import_error = e
def _detect_language(text: str) -> str:
"""Détection grossière de la langue à partir du texte extrait."""
if not text:
return "unknown"
# Mots communs français
fr_words = {"le", "la", "les", "de", "du", "des", "un", "une", "et", "en", "est", "que"}
# Mots communs anglais
en_words = {"the", "is", "are", "and", "or", "of", "to", "in", "a", "an", "for", "with"}
words = set(text.lower().split())
fr_score = len(words & fr_words)
en_score = len(words & en_words)
if fr_score == 0 and en_score == 0:
return "unknown"
return "fr" if fr_score >= en_score else "en"
def extract_text(file_path: str) -> dict:
"""
Extrait le texte d'une image via Tesseract OCR.
Retourne un dict avec le texte, la langue et le score de confiance.
"""
result = {
"text": None,
"language": None,
"confidence": None,
"has_text": False,
}
if not settings.OCR_ENABLED:
return result
if pytesseract is None:
logger.warning("ocr.unavailable", extra={"error": str(_ocr_import_error)})
return result
path = Path(file_path)
if not path.exists():
return result
try:
# Configuration Tesseract
if settings.TESSERACT_CMD:
pytesseract.pytesseract.tesseract_cmd = settings.TESSERACT_CMD
with PILImage.open(path) as img:
# Convertit en RGB si nécessaire
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
# Extraction avec données de confiance
data = pytesseract.image_to_data(
img,
lang=settings.OCR_LANGUAGES,
output_type=pytesseract.Output.DICT,
)
# Calcul de la confiance moyenne (on ignore les -1)
confidences = [
int(c) for c in data["conf"]
if str(c).strip() not in ("-1", "")
]
avg_confidence = (
round(sum(confidences) / len(confidences) / 100, 3)
if confidences else 0.0
)
# Texte nettoyé
raw_text = pytesseract.image_to_string(
img,
lang=settings.OCR_LANGUAGES,
).strip()
if raw_text and len(raw_text) > 3:
result["text"] = raw_text
result["has_text"] = True
result["confidence"] = avg_confidence
result["language"] = _detect_language(raw_text)
else:
result["has_text"] = False
except pytesseract.TesseractNotFoundError:
logger.warning("ocr.tesseract_not_found")
except Exception as e:
logger.error("ocr.extraction_error", extra={"file": file_path, "error": str(e)})
return result

207
app/services/pipeline.py Normal file
View File

@ -0,0 +1,207 @@
"""
Pipeline de traitement AI orchestration des 3 étapes
Chaque étape est indépendante : un échec partiel n'arrête pas le pipeline.
Publie des événements Redis (si disponible) pour le suivi en temps réel.
"""
import json
import logging
import time
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.image import Image, ProcessingStatus
from app.services.exif_service import extract_exif
from app.services.ocr_service import extract_text
from app.services.ai_vision import analyze_image, extract_text_with_ai
import asyncio
logger = logging.getLogger(__name__)
async def _run_sync_in_thread(func: Any, *args: Any) -> Any:
"""Exécute une fonction synchrone dans un thread pour ne pas bloquer l'event loop."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, func, *args)
async def _publish_event(
redis: Any, image_id: int, event: str, data: dict | None = None
) -> None:
"""Publie un événement sur le channel Redis pipeline:{image_id}."""
if redis is None:
return
try:
payload = {"event": event, "image_id": image_id, "timestamp": time.time()}
if data:
payload["data"] = data
await redis.publish(f"pipeline:{image_id}", json.dumps(payload))
except Exception:
pass # Pub/Sub non critique — ne doit pas bloquer le pipeline
async def process_image_pipeline(
image_id: int, db: AsyncSession, redis: Any = None
) -> None:
"""
Pipeline complet de traitement d'une image :
1. Extraction EXIF (sync thread)
2. OCR extraction texte (sync thread)
3. Vision AI description + tags (async)
4. Sauvegarde finale en BDD
Le statut est mis à jour à chaque étape pour permettre le polling.
Publie des événements Redis sur le channel pipeline:{image_id}.
"""
# ── Chargement de l'image ─────────────────────────────────
result = await db.execute(select(Image).where(Image.id == image_id))
image = result.scalar_one_or_none()
if not image:
logger.warning("pipeline.image_not_found", extra={"image_id": image_id})
return
# ── Démarrage ─────────────────────────────────────────────
image.processing_status = ProcessingStatus.PROCESSING
image.processing_started_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(image)
await _publish_event(redis, image_id, "pipeline.started")
errors: list[str] = []
file_path = image.file_path
# ════════════════════════════════════════════════════════════
# ÉTAPE 1 — Extraction EXIF
# ════════════════════════════════════════════════════════════
try:
logger.info("pipeline.step.start", extra={"image_id": image_id, "step": "exif", "step_num": "1/3"})
t0 = time.time()
exif = await _run_sync_in_thread(extract_exif, file_path)
image.exif_raw = exif.get("raw")
image.exif_make = exif.get("make")
image.exif_model = exif.get("model")
image.exif_lens = exif.get("lens")
image.exif_taken_at = exif.get("taken_at")
image.exif_gps_lat = exif.get("gps_lat")
image.exif_gps_lon = exif.get("gps_lon")
image.exif_altitude = exif.get("altitude")
image.exif_iso = exif.get("iso")
image.exif_aperture = exif.get("aperture")
image.exif_shutter = exif.get("shutter")
image.exif_focal = exif.get("focal")
image.exif_flash = exif.get("flash")
image.exif_orientation = exif.get("orientation")
image.exif_software = exif.get("software")
await db.commit()
elapsed = int((time.time() - t0) * 1000)
logger.info("pipeline.step.done", extra={"image_id": image_id, "step": "exif", "duration_ms": elapsed, "camera": image.exif_make})
await _publish_event(redis, image_id, "step.completed", {
"step": "exif", "duration_ms": elapsed, "camera": image.exif_make,
})
except Exception as e:
msg = f"EXIF : {str(e)}"
errors.append(msg)
logger.error("pipeline.step.error", extra={"image_id": image_id, "step": "exif", "error": str(e)})
# ════════════════════════════════════════════════════════════
# ÉTAPE 2 — OCR
# ════════════════════════════════════════════════════════════
try:
logger.info("pipeline.step.start", extra={"image_id": image_id, "step": "ocr", "step_num": "2/3"})
t0 = time.time()
ocr = await _run_sync_in_thread(extract_text, file_path)
# Fallback AI si OCR classique échoue ou ne trouve rien
if not ocr.get("has_text", False):
logger.info("pipeline.ocr.fallback", extra={"image_id": image_id, "reason": "tesseract_empty"})
ai_ocr = await extract_text_with_ai(file_path)
if ai_ocr.get("has_text"):
ocr = ai_ocr
logger.info("pipeline.ocr.fallback_success", extra={"image_id": image_id, "chars": len(ocr.get("text", ""))})
else:
logger.info("pipeline.ocr.fallback_empty", extra={"image_id": image_id})
image.ocr_text = ocr.get("text")
image.ocr_language = ocr.get("language")
image.ocr_confidence = ocr.get("confidence")
image.ocr_has_text = ocr.get("has_text", False)
await db.commit()
elapsed = int((time.time() - t0) * 1000)
logger.info("pipeline.step.done", extra={"image_id": image_id, "step": "ocr", "duration_ms": elapsed, "has_text": image.ocr_has_text})
await _publish_event(redis, image_id, "step.completed", {
"step": "ocr", "duration_ms": elapsed, "has_text": image.ocr_has_text,
})
except Exception as e:
msg = f"OCR : {str(e)}"
errors.append(msg)
logger.error("pipeline.step.error", extra={"image_id": image_id, "step": "ocr", "error": str(e)})
# ════════════════════════════════════════════════════════════
# ÉTAPE 3 — Vision AI (description + tags)
# ════════════════════════════════════════════════════════════
try:
logger.info("pipeline.step.start", extra={"image_id": image_id, "step": "ai", "step_num": "3/3"})
t0 = time.time()
ai = await analyze_image(
file_path=file_path,
ocr_hint=image.ocr_text,
)
image.ai_description = ai.get("description")
image.ai_tags = ai.get("tags", [])
image.ai_confidence = ai.get("confidence")
image.ai_model_used = ai.get("model")
image.ai_processed_at = datetime.now(timezone.utc)
image.ai_prompt_tokens = ai.get("prompt_tokens")
image.ai_output_tokens = ai.get("output_tokens")
await db.commit()
elapsed = int((time.time() - t0) * 1000)
logger.info("pipeline.step.done", extra={"image_id": image_id, "step": "ai", "duration_ms": elapsed, "tags_count": len(image.ai_tags or [])})
await _publish_event(redis, image_id, "step.completed", {
"step": "ai", "duration_ms": elapsed, "tags_count": len(image.ai_tags or []),
})
except Exception as e:
msg = f"AI Vision : {str(e)}"
errors.append(msg)
logger.error("pipeline.step.error", extra={"image_id": image_id, "step": "ai", "error": str(e)})
# ════════════════════════════════════════════════════════════
# FINALISATION
# ════════════════════════════════════════════════════════════
image.processing_done_at = datetime.now(timezone.utc)
if errors:
if image.ai_description:
image.processing_status = ProcessingStatus.DONE
image.processing_error = f"Avertissements : {'; '.join(errors)}"
else:
image.processing_status = ProcessingStatus.ERROR
image.processing_error = "; ".join(errors)
else:
image.processing_status = ProcessingStatus.DONE
image.processing_error = None
await db.commit()
logger.info("pipeline.completed", extra={
"image_id": image_id,
"status": image.processing_status.value,
"errors": len(errors),
})
if errors:
await _publish_event(redis, image_id, "pipeline.error", {"errors": errors})
else:
await _publish_event(redis, image_id, "pipeline.done")

70
app/services/scraper.py Normal file
View File

@ -0,0 +1,70 @@
"""
Service de scraping extraction de contenu web pour résumés AI
"""
from typing import Optional
import httpx
from bs4 import BeautifulSoup
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (compatible; ShaarliBot/1.0)"
)
}
async def fetch_page_content(url: str) -> dict:
"""
Récupère le contenu d'une URL et extrait :
- Titre de la page
- Méta description
- Texte principal
"""
result = {
"url": url,
"title": None,
"description": None,
"text": None,
"error": None,
}
try:
async with httpx.AsyncClient(
headers=HEADERS,
timeout=15.0,
follow_redirects=True,
) as client:
response = await client.get(url)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
# Titre
title_tag = soup.find("title")
result["title"] = title_tag.get_text(strip=True) if title_tag else None
# Meta description
meta_desc = soup.find("meta", attrs={"name": "description"})
if not meta_desc:
meta_desc = soup.find("meta", attrs={"property": "og:description"})
result["description"] = meta_desc.get("content", "") if meta_desc else None
# Texte principal — on retire scripts, styles, nav
for tag in soup(["script", "style", "nav", "footer", "header", "aside"]):
tag.decompose()
# Priorité aux balises sémantiques
main = soup.find("article") or soup.find("main") or soup.find("body")
if main:
paragraphs = main.find_all("p")
text = " ".join(p.get_text(strip=True) for p in paragraphs if len(p.get_text(strip=True)) > 30)
result["text"] = text[:5000] if text else None
except httpx.HTTPStatusError as e:
result["error"] = f"HTTP {e.response.status_code}"
except httpx.RequestError as e:
result["error"] = f"Connexion impossible : {str(e)}"
except Exception as e:
result["error"] = str(e)
return result

123
app/services/storage.py Normal file
View File

@ -0,0 +1,123 @@
"""
Service de stockage sauvegarde fichiers, génération thumbnails
Multi-tenant : les fichiers sont isolés par client_id.
"""
import uuid
import logging
import aiofiles
from pathlib import Path
from datetime import datetime, timezone
from PIL import Image as PILImage
from fastapi import UploadFile, HTTPException, status
from app.config import settings
logger = logging.getLogger(__name__)
ALLOWED_MIME_TYPES = {
"image/jpeg", "image/png", "image/gif",
"image/webp", "image/bmp", "image/tiff",
}
THUMBNAIL_SIZE = (320, 320)
def _generate_filename(original: str) -> tuple[str, str]:
"""Retourne (uuid_filename, extension)."""
suffix = Path(original).suffix.lower() or ".jpg"
uid = str(uuid.uuid4())
return f"{uid}{suffix}", uid
def _get_client_upload_path(client_id: str) -> Path:
"""Retourne le répertoire d'upload pour un client donné."""
p = settings.upload_path / client_id
p.mkdir(parents=True, exist_ok=True)
return p
def _get_client_thumbnails_path(client_id: str) -> Path:
"""Retourne le répertoire de thumbnails pour un client donné."""
p = settings.thumbnails_path / client_id
p.mkdir(parents=True, exist_ok=True)
return p
async def save_upload(file: UploadFile, client_id: str) -> dict:
"""
Valide, sauvegarde le fichier uploadé et génère un thumbnail.
Les fichiers sont stockés dans uploads/{client_id}/ pour l'isolation.
Retourne un dict avec toutes les métadonnées fichier.
"""
# ── Validation MIME ───────────────────────────────────────
if file.content_type not in ALLOWED_MIME_TYPES:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail=f"Type non supporté : {file.content_type}. "
f"Acceptés : {', '.join(ALLOWED_MIME_TYPES)}",
)
# ── Lecture du contenu ────────────────────────────────────
content = await file.read()
if len(content) > settings.max_upload_bytes:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Fichier trop volumineux. Max : {settings.MAX_UPLOAD_SIZE_MB} MB",
)
# ── Nommage et chemins ────────────────────────────────────
filename, file_uuid = _generate_filename(file.filename or "image")
upload_dir = _get_client_upload_path(client_id)
thumb_dir = _get_client_thumbnails_path(client_id)
file_path = upload_dir / filename
thumb_filename = f"thumb_{filename}"
thumb_path = thumb_dir / thumb_filename
# ── Sauvegarde fichier original ───────────────────────────
async with aiofiles.open(file_path, "wb") as f:
await f.write(content)
# ── Dimensions + thumbnail ────────────────────────────────
width, height = None, None
try:
with PILImage.open(file_path) as img:
width, height = img.size
img.thumbnail(THUMBNAIL_SIZE, PILImage.LANCZOS)
# Convertit en RGB si nécessaire (ex: PNG RGBA)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(thumb_path, "JPEG", quality=85)
except Exception as e:
# Thumbnail non bloquant
thumb_path = None
logger.warning("Erreur génération thumbnail : %s", e)
return {
"uuid": file_uuid,
"original_name": file.filename,
"filename": filename,
"file_path": str(file_path),
"thumbnail_path": str(thumb_path) if thumb_path else None,
"mime_type": file.content_type,
"file_size": len(content),
"width": width,
"height": height,
"uploaded_at": datetime.now(timezone.utc),
"client_id": client_id,
}
def delete_files(file_path: str, thumbnail_path: str | None = None) -> None:
"""Supprime le fichier original et son thumbnail du disque."""
for path_str in [file_path, thumbnail_path]:
if path_str:
p = Path(path_str)
if p.exists():
p.unlink()
def get_image_url(filename: str, client_id: str, thumb: bool = False) -> str:
"""Construit l'URL publique d'une image."""
prefix = "thumbnails" if thumb else "uploads"
return f"/static/{prefix}/{client_id}/{filename}"

View File

@ -0,0 +1,227 @@
"""
Abstraction StorageBackend interface commune pour le stockage de fichiers.
Deux implémentations :
- LocalStorage : fichiers sur disque local + URLs signées HMAC
- S3Storage : AWS S3 / MinIO / Cloudflare R2 via aioboto3
Le reste du code utilise exclusivement get_storage_backend() et l'interface
StorageBackend jamais les classes concrètes directement.
"""
import os
from abc import ABC, abstractmethod
from pathlib import Path
import aiofiles
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from app.config import settings
# Singleton backend
_backend: "StorageBackend | None" = None
class StorageBackend(ABC):
"""Interface abstraite pour le stockage de fichiers."""
@abstractmethod
async def save(self, content: bytes, path: str, content_type: str) -> str:
"""Sauvegarde un fichier. Retourne le chemin stocké."""
@abstractmethod
async def delete(self, path: str) -> None:
"""Supprime un fichier."""
@abstractmethod
async def get_signed_url(self, path: str, expires_in: int = 900) -> str:
"""Retourne une URL d'accès temporaire signée."""
@abstractmethod
async def exists(self, path: str) -> bool:
"""Vérifie qu'un fichier existe."""
@abstractmethod
async def get_size(self, path: str) -> int:
"""Retourne la taille en bytes."""
class LocalStorage(StorageBackend):
"""Stockage sur disque local avec URLs signées HMAC."""
def __init__(self, base_dir: str, secret: str) -> None:
self._base_dir = Path(base_dir)
self._serializer = URLSafeTimedSerializer(secret)
def _full_path(self, path: str) -> Path:
return self._base_dir / path
async def save(self, content: bytes, path: str, content_type: str) -> str:
"""Sauvegarde un fichier sur disque."""
full = self._full_path(path)
full.parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(full, "wb") as f:
await f.write(content)
return path
async def delete(self, path: str) -> None:
"""Supprime un fichier du disque."""
full = self._full_path(path)
if full.exists():
full.unlink()
async def get_signed_url(self, path: str, expires_in: int = 900) -> str:
"""Génère un token HMAC signé pour accéder au fichier."""
token = self._serializer.dumps({"path": path, "max_age": expires_in})
return f"/files/signed/{token}"
def validate_token(self, token: str) -> str | None:
"""Valide un token HMAC et retourne le path, None si invalide/expiré."""
try:
data = self._serializer.loads(token, max_age=3600)
return data.get("path")
except (BadSignature, SignatureExpired):
return None
def validate_token_with_max_age(self, token: str, max_age: int = 3600) -> str | None:
"""Valide un token HMAC avec un max_age spécifique."""
try:
data = self._serializer.loads(token, max_age=max_age)
return data.get("path")
except (BadSignature, SignatureExpired):
return None
async def exists(self, path: str) -> bool:
"""Vérifie l'existence du fichier sur disque."""
return self._full_path(path).exists()
async def get_size(self, path: str) -> int:
"""Retourne la taille du fichier."""
full = self._full_path(path)
if full.exists():
return full.stat().st_size
return 0
def get_absolute_path(self, path: str) -> Path:
"""Retourne le chemin absolu d'un fichier (pour FileResponse)."""
return self._full_path(path)
class S3Storage(StorageBackend):
"""Stockage S3/MinIO via aioboto3."""
def __init__(
self,
bucket: str,
prefix: str = "",
region: str = "us-east-1",
endpoint_url: str = "",
access_key: str = "",
secret_key: str = "",
) -> None:
self._bucket = bucket
self._prefix = prefix.rstrip("/")
self._region = region
self._endpoint_url = endpoint_url or None
self._access_key = access_key
self._secret_key = secret_key
def _s3_key(self, path: str) -> str:
if self._prefix:
return f"{self._prefix}/{path}"
return path
def _get_session(self):
import aioboto3
return aioboto3.Session(
aws_access_key_id=self._access_key,
aws_secret_access_key=self._secret_key,
region_name=self._region,
)
async def save(self, content: bytes, path: str, content_type: str) -> str:
"""Upload vers S3/MinIO."""
session = self._get_session()
async with session.client("s3", endpoint_url=self._endpoint_url) as client:
await client.put_object(
Bucket=self._bucket,
Key=self._s3_key(path),
Body=content,
ContentType=content_type,
)
return path
async def delete(self, path: str) -> None:
"""Supprime un objet S3."""
session = self._get_session()
async with session.client("s3", endpoint_url=self._endpoint_url) as client:
await client.delete_object(
Bucket=self._bucket,
Key=self._s3_key(path),
)
async def get_signed_url(self, path: str, expires_in: int = 900) -> str:
"""Génère une URL présignée S3."""
session = self._get_session()
async with session.client("s3", endpoint_url=self._endpoint_url) as client:
url = await client.generate_presigned_url(
"get_object",
Params={"Bucket": self._bucket, "Key": self._s3_key(path)},
ExpiresIn=expires_in,
)
return url
async def exists(self, path: str) -> bool:
"""Vérifie l'existence via head_object."""
session = self._get_session()
async with session.client("s3", endpoint_url=self._endpoint_url) as client:
try:
await client.head_object(
Bucket=self._bucket,
Key=self._s3_key(path),
)
return True
except Exception:
return False
async def get_size(self, path: str) -> int:
"""Retourne la taille via head_object."""
session = self._get_session()
async with session.client("s3", endpoint_url=self._endpoint_url) as client:
try:
resp = await client.head_object(
Bucket=self._bucket,
Key=self._s3_key(path),
)
return resp.get("ContentLength", 0)
except Exception:
return 0
def get_storage_backend() -> StorageBackend:
"""Factory : retourne le backend de stockage configuré (singleton)."""
global _backend
if _backend is not None:
return _backend
if settings.STORAGE_BACKEND == "s3":
_backend = S3Storage(
bucket=settings.S3_BUCKET,
prefix=settings.S3_PREFIX,
region=settings.S3_REGION,
endpoint_url=settings.S3_ENDPOINT_URL,
access_key=settings.S3_ACCESS_KEY,
secret_key=settings.S3_SECRET_KEY,
)
else:
_backend = LocalStorage(
base_dir=str(settings.upload_path.parent),
secret=settings.SIGNED_URL_SECRET,
)
return _backend
def reset_storage_backend() -> None:
"""Reset le singleton (utile pour les tests)."""
global _backend
_backend = None

1
app/workers/__init__.py Normal file
View File

@ -0,0 +1 @@
# Workers package — ARQ task queue

191
app/workers/image_worker.py Normal file
View File

@ -0,0 +1,191 @@
"""
Worker ARQ traitement asynchrone des images via Redis.
Lance avec : python worker.py
Fonctionnalités :
- File persistante Redis (survit aux redémarrages)
- Retry automatique avec backoff exponentiel
- Queues prioritaires (premium / standard)
- Dead-letter : marquage error après max_tries
"""
import logging
from datetime import datetime, timezone
from arq import cron, func
from arq.connections import RedisSettings
from app.config import settings
from app.database import AsyncSessionLocal
from app.models.image import Image, ProcessingStatus
from app.services.pipeline import process_image_pipeline
from sqlalchemy import select
logger = logging.getLogger(__name__)
# Backoff exponentiel : délais entre tentatives (en secondes)
RETRY_DELAYS = [1, 4, 16]
async def process_image_task(ctx: dict, image_id: int, client_id: str) -> str:
"""
Tâche ARQ : traite une image via le pipeline EXIF OCR AI.
Args:
ctx: Contexte ARQ (contient job_try, redis, etc.)
image_id: ID de l'image à traiter
client_id: ID du client propriétaire
"""
job_try = ctx.get("job_try", 1)
redis = ctx.get("redis")
logger.info(
"worker.job.started",
extra={"image_id": image_id, "client_id": client_id, "job_try": job_try},
)
async with AsyncSessionLocal() as db:
try:
await process_image_pipeline(image_id, db, redis=redis)
logger.info(
"worker.job.completed",
extra={"image_id": image_id, "client_id": client_id},
)
return f"OK image_id={image_id}"
except Exception as e:
max_tries = settings.WORKER_MAX_TRIES
logger.error(
"worker.job.failed",
extra={
"image_id": image_id,
"client_id": client_id,
"job_try": job_try,
"max_tries": max_tries,
"error": str(e),
},
exc_info=True,
)
if job_try >= max_tries:
# Dead-letter : marquer l'image en erreur définitive
await _mark_image_error(db, image_id, str(e), job_try)
logger.error(
"worker.job.dead_letter",
extra={
"image_id": image_id,
"client_id": client_id,
"total_tries": job_try,
},
)
return f"DEAD_LETTER image_id={image_id} after {job_try} tries"
# Retry avec backoff
delay_idx = min(job_try - 1, len(RETRY_DELAYS) - 1)
retry_delay = RETRY_DELAYS[delay_idx]
logger.warning(
"worker.job.retry_scheduled",
extra={
"image_id": image_id,
"retry_in_seconds": retry_delay,
"next_try": job_try + 1,
},
)
raise # ARQ replanifie automatiquement
async def _mark_image_error(
db, image_id: int, error_msg: str, total_tries: int
) -> None:
"""Marque une image en erreur définitive après épuisement des retries."""
result = await db.execute(select(Image).where(Image.id == image_id))
image = result.scalar_one_or_none()
if image:
image.processing_status = ProcessingStatus.ERROR
image.processing_error = f"Échec après {total_tries} tentatives : {error_msg}"
image.processing_done_at = datetime.now(timezone.utc)
await db.commit()
async def on_startup(ctx: dict) -> None:
"""Hook ARQ : appelé au démarrage du worker."""
logger.info("worker.startup", extra={"max_jobs": settings.WORKER_MAX_JOBS})
async def on_shutdown(ctx: dict) -> None:
"""Hook ARQ : appelé à l'arrêt du worker."""
logger.info("worker.shutdown")
async def on_job_start(ctx: dict) -> None:
"""Hook ARQ : appelé au début de chaque job."""
pass # Le logging est fait dans process_image_task
async def on_job_end(ctx: dict) -> None:
"""Hook ARQ : appelé à la fin de chaque job."""
pass # Le logging est fait dans process_image_task
def _parse_redis_settings() -> RedisSettings:
"""Parse REDIS_URL en RedisSettings ARQ."""
url = settings.REDIS_URL
# redis://[:password@]host[:port][/db]
if url.startswith("redis://"):
url = url[8:]
elif url.startswith("rediss://"):
url = url[9:]
password = None
host = "localhost"
port = 6379
database = 0
# Parse password
if "@" in url:
auth_part, url = url.rsplit("@", 1)
if ":" in auth_part:
password = auth_part.split(":", 1)[1]
else:
password = auth_part
# Parse host:port/db
if "/" in url:
host_port, db_str = url.split("/", 1)
if db_str:
database = int(db_str)
else:
host_port = url
if ":" in host_port:
host, port_str = host_port.rsplit(":", 1)
if port_str:
port = int(port_str)
else:
host = host_port
return RedisSettings(
host=host or "localhost",
port=port,
password=password,
database=database,
)
class WorkerSettings:
"""Configuration du worker ARQ."""
functions = [func(process_image_task, name="process_image_task")]
redis_settings = _parse_redis_settings()
max_jobs = settings.WORKER_MAX_JOBS
job_timeout = settings.WORKER_JOB_TIMEOUT
retry_jobs = True
max_tries = settings.WORKER_MAX_TRIES
queue_name = "standard" # Queue par défaut
on_startup = on_startup
on_shutdown = on_shutdown
on_job_start = on_job_start
on_job_end = on_job_end
# Le worker écoute les deux queues
queues = ["standard", "premium"]

View File

@ -0,0 +1,28 @@
"""
Client Redis partagé pool de connexions async pour ARQ et Pub/Sub.
"""
from redis.asyncio import ConnectionPool, Redis
from app.config import settings
_pool: ConnectionPool | None = None
async def get_redis_pool() -> Redis:
"""Retourne un client Redis avec pool de connexions partagé."""
global _pool
if _pool is None:
_pool = ConnectionPool.from_url(
settings.REDIS_URL,
max_connections=20,
decode_responses=True,
)
return Redis(connection_pool=_pool)
async def close_redis_pool() -> None:
"""Ferme proprement le pool de connexions Redis."""
global _pool
if _pool is not None:
await _pool.disconnect()
_pool = None

13
debug_numpy.py Normal file
View File

@ -0,0 +1,13 @@
import sys
import os
print(f"Current working directory: {os.getcwd()}")
print(f"Python path: {sys.path}")
try:
import numpy
print(f"Numpy file: {numpy.__file__}")
print(f"Numpy version: {numpy.__version__}")
except ImportError as e:
print(f"Error importing numpy: {e}")
except Exception as e:
print(f"Unexpected error: {e}")

View File

@ -0,0 +1,281 @@
# Prompt Claude Code — Phase 1 : Fondations sécurité
# Modèle : claude-opus-4-6
# Usage : claude --model claude-opus-4-6 -p "$(cat PROMPT_PHASE1.md)"
# ou coller directement dans une session Claude Code interactive
---
<role>
Tu es un ingénieur backend senior Python spécialisé en FastAPI, SQLAlchemy async et sécurité des APIs REST. Tu travailles sur le projet Imago, un backend centralisé de gestion d'images conçu pour servir plusieurs applications clientes simultanément.
</role>
<project_context>
## Projet : Imago
Backend FastAPI existant servant de hub centralisé pour :
- Stocker et gérer des images avec génération automatique de thumbnails
- Extraire les métadonnées EXIF (appareil photo, GPS, paramètres de prise de vue)
- Effectuer l'OCR sur les images via Tesseract
- Analyser les images avec Claude Vision AI (description + classification par tags)
### Stack technique en place
- FastAPI + Uvicorn (serveur ASGI)
- SQLAlchemy async + Alembic (ORM + migrations)
- Pydantic v2 + pydantic-settings (validation + config)
- Pillow + piexif (traitement images + EXIF)
- pytesseract (OCR)
- Anthropic Claude via httpx (Vision AI)
- aiofiles (I/O async)
- SQLite (développement) / PostgreSQL (production)
### Structure du projet
```
imago/
├── app/
│ ├── main.py # Application FastAPI
│ ├── config.py # Settings depuis .env
│ ├── database.py # Engine SQLAlchemy async + session
│ ├── models/
│ │ └── image.py # Modèle Image (EXIF, OCR, AI, statut)
│ ├── schemas/
│ │ └── __init__.py # Schémas Pydantic
│ ├── routers/
│ │ ├── images.py # Endpoints images (CRUD + pipeline)
│ │ └── ai.py # Endpoints AI (résumé URL, tâches)
│ └── services/
│ ├── storage.py # Sauvegarde fichiers + thumbnails
│ ├── exif_service.py # Extraction EXIF
│ ├── ocr_service.py # OCR Tesseract
│ ├── ai_vision.py # Vision AI Claude
│ ├── scraper.py # Scraping web
│ └── pipeline.py # Orchestration pipeline AI
├── tests/
│ └── test_services.py
├── requirements.txt
└── .env
```
### Problème actuel
Le backend est **entièrement public** : aucune authentification, aucune isolation entre clients. N'importe quelle application peut accéder, modifier ou supprimer toutes les données. C'est incompatible avec un hub multi-clients en production.
</project_context>
<mission>
## Mission : implémenter la Phase 1 — Fondations sécurité
Tu dois réaliser les 4 livrables suivants dans l'ordre indiqué. Chaque livrable doit être **complet, testé et fonctionnel** avant de passer au suivant.
### Livrable 1.1 — Authentification API Keys + JWT avec scopes (priorité CRITIQUE)
**Objectif** : sécuriser tous les endpoints avec deux mécanismes d'authentification complémentaires.
**Ce qui doit être créé ou modifié** :
1. `app/models/client.py` — Nouveau modèle SQLAlchemy `APIClient` :
- `id` : UUID, primary key
- `name` : String, nom de l'application cliente (ex: "Shaarli", "App Mobile")
- `api_key_hash` : String, hash SHA-256 de la clé API (jamais stocker en clair)
- `scopes` : JSON, liste des permissions accordées
- `plan` : Enum (`free`, `standard`, `premium`)
- `is_active` : Boolean, default True
- `created_at` / `updated_at` : DateTime
2. `app/dependencies/__init__.py` + `app/dependencies/auth.py` — Dépendances FastAPI :
- `verify_api_key(authorization: str = Header(...))` → retourne l'`APIClient` authentifié
- `require_scope(scope: str)` → factory qui vérifie qu'un scope est accordé
- `get_current_client` → alias réutilisable dans tous les routers
- Lever `HTTP 401` si clé invalide ou client inactif
- Lever `HTTP 403` si scope manquant
3. `app/routers/auth.py` — Endpoints de gestion des clients :
- `POST /auth/clients` — Créer un nouveau client (retourne la clé en clair une seule fois)
- `GET /auth/clients` — Lister les clients (admin only, scope `admin`)
- `GET /auth/clients/{id}` — Détail d'un client
- `PATCH /auth/clients/{id}` — Modifier un client (scopes, plan, is_active)
- `POST /auth/clients/{id}/rotate-key` — Régénérer la clé API
- `DELETE /auth/clients/{id}` — Désactiver (soft delete)
4. Scopes à définir :
- `images:read` — lire les images et métadonnées
- `images:write` — uploader et modifier des images
- `images:delete` — supprimer des images
- `ai:use` — utiliser les endpoints AI (résumé URL, génération tâches)
- `admin` — gestion des clients (réservé à un super-client)
5. `app/config.py` — Ajouter :
- `ADMIN_API_KEY` : clé du super-client admin (depuis .env)
- `JWT_SECRET_KEY` et `JWT_ALGORITHM` pour les tokens JWT futurs
**Contraintes** :
- Les clés API doivent être générées avec `secrets.token_urlsafe(32)`
- Le hash doit être SHA-256 via `hashlib`
- La clé en clair ne doit **jamais** être stockée en base ni apparaître dans les logs
- Utiliser `python-jose` et `passlib` (ajouter au requirements.txt)
---
### Livrable 1.2 — Modèle clients + isolation complète des données (priorité CRITIQUE)
**Objectif** : rendre toutes les données strictement isolées par client.
**Ce qui doit être créé ou modifié** :
1. `app/models/image.py` — Modifier le modèle `Image` existant :
- Ajouter `client_id = Column(UUID, ForeignKey("api_clients.id"), nullable=False, index=True)`
- Ajouter la relation `client = relationship("APIClient", back_populates="images")`
2. `app/models/client.py` — Ajouter la relation inverse :
- `images = relationship("Image", back_populates="client", cascade="all, delete-orphan")`
3. `app/services/storage.py` — Modifier le service de stockage :
- Les fichiers doivent être stockés dans `uploads/{client_id}/{filename}`
- Les thumbnails dans `thumbnails/{client_id}/{filename}`
- La fonction `save_upload` doit accepter `client_id` en paramètre
4. `app/routers/images.py` — Modifier TOUS les endpoints :
- Injecter `client: APIClient = Depends(get_current_client)` dans chaque endpoint
- Appliquer `require_scope("images:read")` sur les GET
- Appliquer `require_scope("images:write")` sur les POST
- Appliquer `require_scope("images:delete")` sur les DELETE
- Filtrer systématiquement toutes les requêtes avec `WHERE image.client_id = client.id`
- **Un client ne doit jamais pouvoir accéder aux images d'un autre client**
5. `app/routers/ai.py` — Même injection + scope `ai:use`
6. `alembic/versions/` — Créer une migration Alembic pour :
- Créer la table `api_clients`
- Ajouter la colonne `client_id` à la table `images`
- Créer un client "default" avec toutes les permissions pour la migration des données existantes
**Contraintes** :
- Toute requête DB touchant des images DOIT inclure le filtre `client_id`
- Écrire une fonction utilitaire `get_image_or_404(image_id, client_id, db)` qui centralise ce pattern
- Le filtre doit être appliqué au niveau service, pas seulement dans les routers
---
### Livrable 1.3 — Rate limiting par client et par endpoint (priorité HAUTE)
**Objectif** : protéger le hub contre les abus et contrôler la consommation par client.
**Ce qui doit être créé ou modifié** :
1. Ajouter `slowapi` au requirements.txt
2. `app/middleware/rate_limit.py` — Configuration du rate limiting :
- Identifier les requêtes par `client_id` (extrait du token) plutôt que par IP
- Limites différentes selon le plan :
- `free` : 20 uploads/heure, 50 requêtes AI/heure
- `standard` : 100 uploads/heure, 200 requêtes AI/heure
- `premium` : 500 uploads/heure, 1000 requêtes AI/heure
- Retourner `HTTP 429` avec header `Retry-After` si limite atteinte
3. `app/routers/images.py` — Appliquer les décorateurs rate limit sur :
- `POST /images/upload` → limiter par plan
- `POST /images/{id}/reprocess` → limiter par plan
4. `app/routers/ai.py` — Appliquer sur :
- `POST /ai/summarize` → limiter par plan
- `POST /ai/draft-task` → limiter par plan
5. `app/config.py` — Ajouter les variables de configuration des limites par plan
**Contraintes** :
- Les compteurs de rate limit doivent être par client (pas global)
- Inclure les headers standard : `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
---
### Livrable 1.4 — Tests d'intégration auth + multi-tenants (priorité HAUTE)
**Objectif** : valider que la sécurité fonctionne correctement avec une suite de tests exhaustive.
**Ce qui doit être créé** :
1. `tests/conftest.py` — Fixtures pytest :
- `test_db` : base SQLite in-memory pour les tests
- `client_a` et `client_b` : deux clients API de test avec des clés distinctes
- `auth_headers_a` et `auth_headers_b` : headers d'authentification correspondants
- `async_client` : `httpx.AsyncClient` configuré pour les tests
2. `tests/test_auth.py` — Tests d'authentification :
- ✅ Requête sans clé → HTTP 401
- ✅ Requête avec clé invalide → HTTP 401
- ✅ Requête avec clé valide → HTTP 200
- ✅ Client désactivé → HTTP 401
- ✅ Scope manquant → HTTP 403
- ✅ Rotation de clé → ancienne clé invalide, nouvelle clé valide
- ✅ Création de client → clé retournée une seule fois
3. `tests/test_isolation.py` — Tests d'isolation multi-tenants :
- ✅ Client A upload une image → invisible pour Client B
- ✅ Client B ne peut pas lire l'image du Client A (même avec bonne clé)
- ✅ Client B ne peut pas supprimer l'image du Client A → HTTP 404
- ✅ Listing des images de A ne retourne que les images de A
- ✅ Les fichiers sont stockés dans des répertoires séparés
- ✅ Reprocess d'une image appartenant à un autre client → HTTP 404
4. `tests/test_rate_limit.py` — Tests de rate limiting :
- ✅ Dépassement de quota → HTTP 429 avec header `Retry-After`
- ✅ Compteur distinct par client
- ✅ Headers `X-RateLimit-*` présents sur chaque réponse
**Contraintes** :
- Tous les tests doivent être async (`pytest-asyncio`)
- Les appels à l'API Anthropic doivent être mockés dans les tests
- Couverture minimum : 85% sur les modules `auth.py`, `dependencies/auth.py`, `models/client.py`
</mission>
<execution_rules>
## Règles d'exécution
### Ordre de réalisation
Implémenter dans l'ordre strict : 1.1 → 1.2 → 1.3 → 1.4. Ne pas passer au livrable suivant sans avoir validé le précédent avec `pytest`.
### À chaque livrable
1. Lire les fichiers existants concernés avant de les modifier
2. Écrire le code complet (pas de `# TODO` ni de `# ...`)
3. Mettre à jour `requirements.txt` si de nouvelles dépendances sont ajoutées
4. Créer la migration Alembic si le modèle de données change
5. Lancer `pytest tests/ -v` et corriger les erreurs avant de continuer
### Qualité du code
- Typage complet sur toutes les fonctions (mypy compatible)
- Docstrings sur tous les services et dépendances
- Pas de secrets hardcodés — tout passe par `app/config.py` + `.env`
- Logging structuré avec le niveau approprié (pas de `print()`)
- Gestion explicite de toutes les exceptions (pas de `except Exception: pass`)
### Sécurité — règles absolues
- Les clés API ne doivent **jamais** apparaître dans les logs, réponses d'erreur, ou stack traces
- Le hash doit être SHA-256 avec sel (utiliser `hashlib.pbkdf2_hmac` ou `passlib`)
- Les messages d'erreur d'authentification doivent être génériques (ne pas indiquer si la clé existe ou si c'est le scope qui manque — sauf pour HTTP 403 vs 401)
- Valider les entrées avec Pydantic sur tous les endpoints de création/modification
### Migrations Alembic
- Une migration par changement de schéma
- Inclure les `upgrade()` et `downgrade()` complets
- Tester la migration sur une base vierge avant de la considérer terminée
### Résultat attendu
À la fin de la Phase 1, la commande suivante doit réussir sans erreur :
```bash
pytest tests/ -v --cov=app --cov-report=term-missing
```
Et le serveur doit démarrer sans erreur avec :
```bash
python run.py
```
</execution_rules>
<deliverable_summary>
## Résumé des livrables attendus
| # | Fichiers créés ou modifiés | Validation |
|---|---|---|
| 1.1 | `app/models/client.py`, `app/dependencies/auth.py`, `app/routers/auth.py`, `app/config.py`, `requirements.txt` | `pytest tests/test_auth.py` |
| 1.2 | `app/models/image.py`, `app/services/storage.py`, `app/routers/images.py`, `app/routers/ai.py`, `alembic/versions/xxx_add_clients.py` | `pytest tests/test_isolation.py` |
| 1.3 | `app/middleware/rate_limit.py`, `app/routers/images.py`, `app/routers/ai.py`, `app/config.py` | `pytest tests/test_rate_limit.py` |
| 1.4 | `tests/conftest.py`, `tests/test_auth.py`, `tests/test_isolation.py`, `tests/test_rate_limit.py` | `pytest tests/ -v --cov=app` |
**Résultat final de la Phase 1 :** hub sécurisé, données strictement isolées par client, déployable en production.
</deliverable_summary>

View File

@ -0,0 +1,765 @@
# Prompt Claude Code — Phase 2 : Robustesse et scalabilité
# Modèle : claude-opus-4-6
# Usage : claude --model claude-opus-4-6 -p "$(cat PROMPT_PHASE2_claude-opus.md)"
# ou coller directement dans une session Claude Code interactive
---
<role>
Tu es un ingénieur backend senior Python spécialisé en systèmes distribués, FastAPI async et infrastructure de production. Tu travailles sur le projet Imago, un hub centralisé de gestion d'images servant plusieurs applications clientes simultanément.
La Phase 1 (authentification, isolation multi-tenants, rate limiting, tests d'intégration) est entièrement terminée et validée. Tu prends le relais pour rendre ce hub robuste, scalable et observable en production.
</role>
<project_context>
## État du projet après Phase 1
### Stack en place (Phase 1 complète)
- **FastAPI + Uvicorn** — framework web async
- **SQLAlchemy async + Alembic** — ORM + migrations
- **Pydantic v2 + pydantic-settings** — validation + config
- **python-jose + passlib** — JWT + hachage API Keys
- **slowapi** — rate limiting par client
- **Pillow + piexif** — traitement images + EXIF
- **pytesseract** — OCR Tesseract
- **Anthropic Claude via httpx** — Vision AI
- **aiofiles** — I/O async
- **pytest-asyncio + httpx** — tests d'intégration
### Structure complète du projet (après Phase 1)
```
imago/
├── app/
│ ├── main.py # Application FastAPI, lifespan, middlewares
│ ├── config.py # Settings depuis .env (inclut ADMIN_API_KEY, JWT_SECRET)
│ ├── database.py # Engine SQLAlchemy async + session factory
│ ├── models/
│ │ ├── __init__.py
│ │ ├── image.py # Image (avec client_id FK, EXIF, OCR, AI, statut)
│ │ └── client.py # APIClient (id, name, api_key_hash, scopes, plan, quotas)
│ ├── schemas/
│ │ └── __init__.py # Schémas Pydantic complets
│ ├── dependencies/
│ │ ├── __init__.py
│ │ └── auth.py # verify_api_key, require_scope, get_current_client
│ ├── middleware/
│ │ └── rate_limit.py # Rate limiting par plan client (slowapi)
│ ├── routers/
│ │ ├── __init__.py
│ │ ├── auth.py # CRUD clients, rotation de clé
│ │ ├── images.py # Endpoints images (filtrés par client_id)
│ │ └── ai.py # Endpoints AI (résumé URL, tâches)
│ └── services/
│ ├── __init__.py
│ ├── storage.py # Stockage fichiers (uploads/{client_id}/{filename})
│ ├── exif_service.py # Extraction EXIF
│ ├── ocr_service.py # OCR Tesseract
│ ├── ai_vision.py # Vision AI Claude + summarize_url + draft_task
│ ├── scraper.py # Scraping web BeautifulSoup
│ └── pipeline.py # Pipeline EXIF → OCR → AI (via BackgroundTasks)
├── tests/
│ ├── conftest.py # Fixtures : test_db, client_a/b, auth_headers
│ ├── test_services.py # Tests unitaires services
│ ├── test_auth.py # Tests authentification
│ ├── test_isolation.py # Tests isolation multi-tenants
│ └── test_rate_limit.py # Tests rate limiting
├── alembic/
│ └── versions/ # Migrations : table images + table api_clients
├── requirements.txt
├── .env
├── Dockerfile
└── docker-compose.yml
```
### Ce qui reste problématique (cible de la Phase 2)
**Pipeline non persistant** : les `BackgroundTasks` FastAPI perdent toutes les tâches en file si le serveur redémarre. Aucun retry en cas d'échec de l'API Anthropic. Aucune priorité entre clients `free` et `premium`. Concurrence non contrôlée.
**Stockage non abstrait** : fichiers sur disque local uniquement, couplé aux chemins absolus. Impossible de scaler, aucune réplication, URLs statiques exposent les chemins réels.
**Observabilité absente** : tous les logs sont des `print()`. Aucune métrique applicative. Health check basique qui retourne toujours "ok". Impossible de monitorer en production.
**CI/CD inexistant** : pas d'automatisation des tests, pas de pipeline de déploiement, pas de contrôles qualité automatisés.
</project_context>
<mission>
## Mission : implémenter la Phase 2 — Robustesse et scalabilité
Tu dois réaliser les 5 livrables suivants dans l'ordre indiqué. Chaque livrable doit être **complet, testé et fonctionnel** avant de passer au suivant.
---
### Livrable 2.1 — Migration BackgroundTasks → ARQ + Redis (priorité HAUTE)
**Objectif** : remplacer le pipeline fragile par un système de file de tâches persistant avec retry automatique, priorités par plan client et concurrence contrôlée.
**Nouvelles dépendances à ajouter dans `requirements.txt`** :
```
arq==0.25.0
redis==5.0.8
```
**Ce qui doit être créé ou modifié** :
1. `app/config.py` — Ajouter les variables Redis et worker :
```python
REDIS_URL: str = "redis://localhost:6379"
WORKER_MAX_JOBS: int = 10 # concurrence max globale
WORKER_JOB_TIMEOUT: int = 180 # secondes avant timeout forcé
WORKER_MAX_TRIES: int = 3 # tentatives avant dead-letter
AI_STEP_TIMEOUT: int = 120 # timeout spécifique appel Vision AI
OCR_STEP_TIMEOUT: int = 30 # timeout spécifique OCR
```
2. `app/workers/__init__.py` + `app/workers/image_worker.py` — Worker ARQ :
- Fonction `process_image_task(ctx, image_id: int, client_id: str)` qui appelle `pipeline.py`
- `WorkerSettings` avec :
- `functions = [process_image_task]`
- `redis_settings` depuis `settings.REDIS_URL`
- `max_jobs = settings.WORKER_MAX_JOBS`
- `job_timeout = settings.WORKER_JOB_TIMEOUT`
- `retry_jobs = True`
- `max_tries = settings.WORKER_MAX_TRIES`
- `on_job_start`, `on_job_end`, `on_job_abort` — hooks de logging
- Backoff exponentiel : délais `[1, 4, 16]` secondes entre les tentatives
- Dead-letter : après `max_tries` échecs, marquer l'image `status=error` + log `ERROR`
3. `app/workers/redis_client.py` — Client Redis partagé :
- Pool de connexions async (`aioredis` ou `redis.asyncio`)
- Fonction `get_redis_pool()` injectable comme dépendance FastAPI
- Gestion propre de la connexion dans le lifespan de `main.py`
4. `app/services/pipeline.py` — Ajouter publication d'événements Redis :
- À chaque étape complétée, publier sur le channel `pipeline:{image_id}` :
```python
await redis.publish(f"pipeline:{image_id}", json.dumps({
"event": "step.completed",
"step": "exif",
"duration_ms": elapsed,
"data": { ... } # résumé des données extraites
}))
```
- Événements à publier : `pipeline.started`, `step.completed` (×3), `pipeline.done`, `pipeline.error`
5. `app/routers/images.py` — Modifier `POST /images/upload` :
- Remplacer `background_tasks.add_task(process_image_pipeline, ...)` par :
```python
queue_name = "premium" if client.plan == "premium" else "standard"
await arq_pool.enqueue_job(
"process_image_task",
image.id,
str(client.id),
_queue_name=queue_name
)
```
6. `app/main.py` — Modifier le `lifespan` :
- Créer et stocker le pool ARQ au démarrage : `app.state.arq_pool`
- Créer et stocker le pool Redis au démarrage : `app.state.redis`
- Fermer proprement les deux à l'arrêt
7. `worker.py` — Script de démarrage du worker (à la racine du projet) :
```python
# Lancer avec : python worker.py
import asyncio
from arq import run_worker
from app.workers.image_worker import WorkerSettings
if __name__ == "__main__":
asyncio.run(run_worker(WorkerSettings))
```
8. `docker-compose.yml` — Ajouter le service Redis et le worker :
```yaml
redis:
image: redis:7-alpine
ports: ["6379:6379"]
volumes: ["redis_data:/data"]
command: redis-server --appendonly yes # persistance AOF
worker:
build: .
command: python worker.py
depends_on: [backend, redis]
env_file: .env
```
**Contraintes** :
- Le pool ARQ doit être créé une seule fois au démarrage, pas à chaque requête
- Les tâches doivent survivre à un redémarrage du serveur FastAPI (elles restent dans Redis)
- Un job qui dépasse `job_timeout` doit être marqué `error` en base, pas silencieusement ignoré
- Les files `premium` et `standard` doivent être des queues ARQ distinctes (pas juste un label)
---
### Livrable 2.2 — Abstraction StorageBackend + support MinIO/S3 (priorité HAUTE)
**Objectif** : découpler le code du stockage physique pour permettre une migration transparente vers S3/MinIO sans modifier les routers ni les services métier.
**Nouvelle dépendance** :
```
aioboto3==13.0.0
```
**Ce qui doit être créé ou modifié** :
1. `app/services/storage_backend.py` — Interface abstraite + deux implémentations :
**Classe de base** :
```python
class StorageBackend(ABC):
@abstractmethod
async def save(self, content: bytes, path: str, content_type: str) -> str:
"""Sauvegarde un fichier. Retourne le chemin stocké."""
@abstractmethod
async def delete(self, path: str) -> None:
"""Supprime un fichier."""
@abstractmethod
async def get_signed_url(self, path: str, expires_in: int = 900) -> str:
"""Retourne une URL d'accès temporaire signée."""
@abstractmethod
async def exists(self, path: str) -> bool:
"""Vérifie qu'un fichier existe."""
@abstractmethod
async def get_size(self, path: str) -> int:
"""Retourne la taille en bytes."""
```
**`LocalStorage(StorageBackend)`** :
- `save()` : écrit dans `{UPLOAD_DIR}/{path}` avec `aiofiles`, crée les dossiers parents si besoin
- `delete()` : supprime le fichier, ignore si absent
- `get_signed_url()` : génère un token HMAC-SHA256 signé avec `itsdangerous.URLSafeTimedSerializer` incluant `path` + expiration. Retourne `/files/signed/{token}`
- `exists()` : `Path(full_path).exists()`
- `get_size()` : `Path(full_path).stat().st_size`
**`S3Storage(StorageBackend)`** :
- Constructeur : `bucket`, `prefix`, `session` (`aioboto3.Session`)
- `save()` : `put_object` avec le `content_type` correct
- `delete()` : `delete_object`
- `get_signed_url()` : `generate_presigned_url("get_object", ExpiresIn=expires_in)`
- `exists()` : `head_object` → True/False
- `get_size()` : `head_object``ContentLength`
- Compatible S3, MinIO et Cloudflare R2 (même API boto3)
2. `app/services/storage.py` — Refactoring complet :
- Supprimer le code de stockage direct
- La fonction `save_upload(file, client_id)` utilise maintenant le backend injecté
- La fonction `delete_files(paths)` utilise le backend
- Exposer `get_storage_backend() -> StorageBackend` : factory qui lit `settings.STORAGE_BACKEND` et instancie le bon backend
3. `app/config.py` — Ajouter :
```python
STORAGE_BACKEND: str = "local" # "local" | "s3"
S3_BUCKET: str = ""
S3_REGION: str = "us-east-1"
S3_ENDPOINT_URL: str = "" # vide = AWS, sinon MinIO/R2
S3_ACCESS_KEY: str = ""
S3_SECRET_KEY: str = ""
S3_PREFIX: str = "imago" # préfixe dans le bucket
SIGNED_URL_SECRET: str = "changez-moi" # secret HMAC pour LocalStorage
```
4. `app/routers/images.py` — Ajouter les endpoints d'URLs signées :
- `GET /images/{id}/download-url?expires_in=900``{ "url": "...", "expires_in": 900 }`
- `GET /images/{id}/thumbnail-url?expires_in=900``{ "url": "...", "expires_in": 900 }`
- Vérifier que l'image appartient au client avant de générer l'URL
- Appliquer `require_scope("images:read")`
5. `app/routers/files.py` — Nouveau router pour le stockage local signé :
- `GET /files/signed/{token}` — valide le token HMAC, retourne le fichier via `FileResponse`
- Lever `HTTP 403` si token invalide ou expiré
- Lever `HTTP 410 Gone` si le fichier n'existe plus sur disque
- Ce router n'est monté que si `STORAGE_BACKEND == "local"`
6. `app/main.py` — Supprimer les `StaticFiles` mounts `/static/*` et les remplacer par le router `files.py`
**Contraintes** :
- Le reste du code (routers, pipeline) ne doit **jamais** importer `LocalStorage` ou `S3Storage` directement — uniquement `StorageBackend` et la factory
- Les tokens HMAC doivent avoir une durée de vie stricte (vérification côté serveur à la lecture)
- Le backend S3 doit fonctionner avec MinIO en local (via `S3_ENDPOINT_URL`)
---
### Livrable 2.3 — URLs signées : quota et tracking de l'espace disque (priorité HAUTE)
**Objectif** : enrichir le système d'URLs signées avec le suivi de la consommation de stockage par client pour permettre l'application des quotas.
**Ce qui doit être créé ou modifié** :
1. `app/models/client.py` — Ajouter la méthode de calcul de quota :
```python
async def get_storage_used_bytes(self, db: AsyncSession) -> int:
"""Somme de file_size pour toutes les images actives du client."""
result = await db.execute(
select(func.sum(Image.file_size))
.where(Image.client_id == self.id)
)
return result.scalar_one() or 0
```
2. `app/services/storage.py` — Dans `save_upload()` :
- Avant de sauvegarder, vérifier que `storage_used + file_size <= quota_storage_mb * 1024 * 1024`
- Lever `HTTP 413` avec message clair si quota dépassé : `"Quota de stockage atteint (X MB / Y MB)"`
- Vérifier aussi `count(images) < quota_images` avant l'upload
3. `app/routers/images.py` — Ajouter dans `GET /images` la consommation actuelle dans la réponse :
```json
{
"total": 42,
"storage_used_mb": 128.5,
"storage_quota_mb": 500,
"quota_pct": 25.7
}
```
4. `app/schemas/__init__.py` — Mettre à jour `PaginatedImages` avec les champs de quota
**Contraintes** :
- La vérification de quota doit être atomique (SELECT + INSERT dans la même transaction si possible)
- Ne pas recalculer la somme à chaque requête GET — utiliser une colonne `storage_used_bytes` dénormalisée sur `APIClient`, mise à jour lors des uploads et suppressions
---
### Livrable 2.4 — Logs structurés + métriques Prometheus (priorité MOYENNE)
**Objectif** : remplacer tous les `print()` par des logs JSON structurés et exposer des métriques Prometheus exploitables pour le monitoring de production.
**Nouvelles dépendances** :
```
structlog==24.4.0
prometheus-fastapi-instrumentator==0.14.0
```
**Ce qui doit être créé ou modifié** :
1. `app/logging_config.py` — Configuration structlog centralisée :
```python
import structlog
def configure_logging(debug: bool = False):
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.dev.ConsoleRenderer() if debug
else structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(
logging.DEBUG if debug else logging.INFO
),
)
```
2. Remplacer **chaque `print()`** dans le projet par le logger structlog approprié :
Dans `app/services/pipeline.py` :
```python
log = structlog.get_logger()
# Remplacer print(f"[Pipeline:{image_id}] Étape 1/3 — EXIF") par :
log.info("pipeline.step.started", image_id=image_id, step="exif")
# Remplacer print(f"[Pipeline:{image_id}] EXIF OK") par :
log.info("pipeline.step.completed", image_id=image_id, step="exif",
duration_ms=elapsed, camera=image.exif_make)
# En cas d'erreur :
log.error("pipeline.step.failed", image_id=image_id, step="exif",
error=str(e), exc_info=True)
```
Dans `app/services/storage.py`, `exif_service.py`, `ocr_service.py`, `ai_vision.py` : même principe.
Dans `app/workers/image_worker.py` : logs sur `on_job_start`, `on_job_end`, `on_job_abort`.
3. `app/middleware/logging_middleware.py` — Middleware HTTP de logs :
- Logger chaque requête : `method`, `path`, `status_code`, `duration_ms`, `client_id` (si authentifié)
- Exclure `/health` et `/metrics` pour éviter le bruit
- Format : `log.info("http.request", method="POST", path="/api/v1/images/upload", status=201, duration_ms=45, client_id="abc")`
4. `app/main.py` — Intégration Prometheus :
```python
from prometheus_fastapi_instrumentator import Instrumentator
Instrumentator(
should_group_status_codes=True,
should_ignore_untemplated=True,
excluded_handlers=["/health", "/metrics"],
).instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
```
5. `app/metrics.py` — Métriques custom applicatives :
```python
from prometheus_client import Counter, Histogram, Gauge
images_uploaded = Counter(
"hub_images_uploaded_total",
"Total uploads par client et par plan",
["client_id", "plan"]
)
pipeline_duration = Histogram(
"hub_pipeline_duration_seconds",
"Durée du pipeline par étape",
["step"],
buckets=[0.1, 0.5, 1, 2, 5, 10, 30, 60, 120]
)
ai_tokens_consumed = Counter(
"hub_ai_tokens_consumed_total",
"Tokens AI consommés par client",
["client_id", "token_type"] # prompt / output
)
pipeline_errors = Counter(
"hub_pipeline_errors_total",
"Erreurs pipeline par étape",
["step"]
)
storage_used_bytes = Gauge(
"hub_storage_used_bytes",
"Espace disque utilisé par client",
["client_id"]
)
arq_queue_size = Gauge(
"hub_arq_queue_size",
"Tâches en attente dans ARQ",
["queue"]
)
```
- Incrémenter ces métriques aux bons endroits (upload, pipeline, suppression)
6. `app/main.py` — Améliorer `/health/detailed` :
```python
@app.get("/health/detailed")
async def health_detailed(db: AsyncSession = Depends(get_db)):
checks = {}
t0 = time()
# Base de données
try:
await db.execute(text("SELECT 1"))
checks["database"] = {"status": "ok", "latency_ms": round((time()-t0)*1000)}
except Exception as e:
checks["database"] = {"status": "error", "detail": str(e)}
# Redis
try:
await app.state.redis.ping()
checks["redis"] = {"status": "ok"}
except Exception as e:
checks["redis"] = {"status": "error", "detail": str(e)}
# ARQ queue
try:
queue_info = await app.state.arq_pool.queued_jobs()
checks["queue"] = {"status": "ok", "pending_jobs": len(queue_info)}
except Exception as e:
checks["queue"] = {"status": "error", "detail": str(e)}
# Tesseract
try:
pytesseract.get_tesseract_version()
checks["tesseract"] = {"status": "ok"}
except Exception as e:
checks["tesseract"] = {"status": "error", "detail": str(e)}
# API Anthropic (vérification clé uniquement, sans appel facturable)
checks["anthropic"] = {
"status": "ok" if settings.ANTHROPIC_API_KEY else "error",
"configured": bool(settings.ANTHROPIC_API_KEY)
}
# Espace disque
try:
usage = shutil.disk_usage(settings.UPLOAD_DIR)
pct = round(usage.used / usage.total * 100, 1)
checks["disk"] = {
"status": "warning" if pct > 85 else "ok",
"used_pct": pct,
"free_gb": round(usage.free / 1e9, 2)
}
except Exception as e:
checks["disk"] = {"status": "error", "detail": str(e)}
overall = "healthy" if all(
c.get("status") in ("ok", "warning") for c in checks.values()
) else "degraded"
return {
"status": overall,
"version": settings.APP_VERSION,
"checks": checks,
"checked_at": datetime.utcnow().isoformat()
}
```
**Contraintes** :
- Zéro `print()` restant dans tout le projet après ce livrable
- Les logs de requêtes HTTP ne doivent pas logger les valeurs des headers `Authorization`
- Les métriques Prometheus ne doivent pas exposer de données sensibles (pas de `client_id` en clair dans les labels si trop nombreux — utiliser le `plan` à la place)
---
### Livrable 2.5 — CI/CD complet avec GitHub Actions (priorité HAUTE)
**Objectif** : automatiser entièrement la qualité du code, les tests, le build Docker et le déploiement.
**Nouvelles dépendances dev** — créer `requirements-dev.txt` :
```
pytest==8.3.0
pytest-asyncio==0.24.0
pytest-cov==5.0.0
httpx==0.27.2
ruff==0.6.0
black==24.8.0
mypy==1.11.0
bandit==1.7.10
pre-commit==3.8.0
faker==27.0.0 # génération de données de test
```
**Ce qui doit être créé** :
1. `.github/workflows/ci.yml` — Pipeline CI complet :
**Job `quality`** — sur chaque push et PR :
```yaml
- name: Lint (ruff)
run: ruff check . --output-format=github
- name: Format check (black)
run: black --check . --line-length 100
- name: Type check (mypy)
run: mypy app/ --strict --ignore-missing-imports
- name: Security scan (bandit)
run: bandit -r app/ -ll -x tests/
```
**Job `tests`** — sur chaque push et PR :
```yaml
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
steps:
- run: pytest tests/ -v --cov=app --cov-report=xml --cov-fail-under=80
- uses: codecov/codecov-action@v4
```
**Job `docker`** — sur push vers `main` uniquement :
```yaml
needs: [quality, tests]
steps:
- uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/${{ github.repository }}:latest
```
**Job `deploy-staging`** — sur push vers `main`, après `docker` :
- Déploiement vers un environnement de staging via SSH ou webhook
- Commenter dans le workflow s'il n'y a pas encore d'environnement de staging
2. `.pre-commit-config.yaml` — Hooks locaux :
```yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.0
hooks:
- id: mypy
args: [--strict, --ignore-missing-imports]
additional_dependencies: [types-all]
- repo: https://github.com/PyCQA/bandit
rev: 1.7.10
hooks:
- id: bandit
args: [-ll, -x, tests/]
```
3. `pyproject.toml` — Configuration centralisée des outils :
```toml
[tool.ruff]
line-length = 100
target-version = "py312"
select = ["E", "F", "I", "N", "W", "UP", "S", "B", "A"]
ignore = ["S101"] # autorise les assert dans les tests
[tool.black]
line-length = 100
target-version = ["py312"]
[tool.mypy]
python_version = "3.12"
strict = true
ignore_missing_imports = true
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
addopts = "--tb=short -q"
[tool.coverage.run]
source = ["app"]
omit = ["app/main.py", "*/migrations/*"]
[tool.coverage.report]
fail_under = 80
show_missing = true
```
4. `Makefile` — Raccourcis de développement :
```makefile
.PHONY: dev test lint format typecheck security ci
dev:
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
worker:
python worker.py
test:
pytest tests/ -v --cov=app --cov-report=term-missing
lint:
ruff check . --fix
format:
black . --line-length 100
typecheck:
mypy app/ --strict --ignore-missing-imports
security:
bandit -r app/ -ll -x tests/
ci: lint format typecheck security test
@echo "✅ Tous les checks CI passent"
migrate:
alembic upgrade head
docker-up:
docker-compose up -d
docker-logs:
docker-compose logs -f backend worker
```
5. `tests/test_pipeline_arq.py` — Tests du pipeline ARQ :
- Mocker `arq_pool.enqueue_job` pour vérifier que la bonne queue est utilisée selon le plan
- Tester que `process_image_task` est appelé avec les bons paramètres
- Tester le comportement en cas d'échec (image marquée `error` après `max_tries`)
- Tester que les événements Redis sont publiés aux bonnes étapes
6. `tests/test_storage.py` — Tests du StorageBackend :
- Tester `LocalStorage` avec un répertoire temporaire (`tmp_path` de pytest)
- Tester que les tokens HMAC expirent correctement
- Tester que `S3Storage` appelle les bonnes méthodes boto3 (avec `moto` pour mocker AWS)
- Tester la vérification de quota dans `save_upload()`
7. `tests/test_observability.py` — Tests des métriques et logs :
- Vérifier que `GET /metrics` retourne `HTTP 200` avec du texte Prometheus
- Vérifier que `GET /health/detailed` retourne un statut structuré
- Vérifier que les métriques `hub_images_uploaded_total` s'incrémentent à chaque upload
**Contraintes** :
- Le pipeline CI doit échouer si la couverture de tests descend sous 80%
- `mypy --strict` doit passer sans erreur sur tout le répertoire `app/`
- `bandit` ne doit rapporter aucun problème de sévérité HIGH ou MEDIUM
- Aucun secret ne doit apparaître dans les logs du CI (utiliser `${{ secrets.* }}` pour tout)
</mission>
<execution_rules>
## Règles d'exécution
### Prérequis avant de commencer
1. Vérifier que la Phase 1 est bien en place : `pytest tests/ -v` doit passer sans erreur
2. Lire les fichiers existants concernés **avant** de les modifier
3. S'assurer que Redis est disponible localement pour les tests ARQ
### Ordre de réalisation
Implémenter dans l'ordre strict : **2.1 → 2.2 → 2.3 → 2.4 → 2.5**. Valider chaque livrable avec `pytest` avant de continuer.
### À chaque livrable
1. Lire les fichiers concernés avec l'outil `Read` ou `Glob`
2. Implémenter le code complet (zéro `# TODO`, zéro `...` comme corps de fonction)
3. Mettre à jour `requirements.txt` et `requirements-dev.txt` si nécessaire
4. Créer la migration Alembic si le schéma change
5. Lancer `pytest tests/ -v` et corriger avant de passer au suivant
6. Pour le livrable 2.5, lancer `make ci` pour valider l'ensemble
### Qualité du code
- **Typage complet** : toutes les fonctions annotées, compatible `mypy --strict`
- **Docstrings** sur toutes les classes et méthodes publiques
- **Zéro secret hardcodé** : tout passe par `app/config.py` + `.env`
- **Zéro `print()`** : remplacé par `structlog` dans tout le projet
- **Gestion explicite des exceptions** : chaque `except` doit logger + réagir, jamais `pass`
- **Fermeture propre des ressources** : pools Redis, connexions S3 fermées dans le lifespan
### Infrastructure locale pour le développement
Ajouter dans `docker-compose.yml` (si pas déjà présent) :
```yaml
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
volumes: ["redis_data:/data"]
command: redis-server --appendonly yes
minio:
image: minio/minio
ports: ["9000:9000", "9001:9001"]
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001"
volumes: ["minio_data:/data"]
volumes:
redis_data:
minio_data:
```
### Critère de succès final
Les commandes suivantes doivent toutes réussir sans erreur :
```bash
# Tests complets avec couverture
pytest tests/ -v --cov=app --cov-report=term-missing
# Qualité de code
make ci
# Démarrage du serveur
python run.py
# Démarrage du worker ARQ (dans un autre terminal)
python worker.py
# Vérification de santé
curl http://localhost:8000/health/detailed | python -m json.tool
```
La mise a jour des documentations API_GUIDE.md et README.md est aussi requise.
</execution_rules>
<deliverable_summary>
## Résumé des livrables attendus
| # | Fichiers créés ou modifiés | Validation |
|---|---|---|
| **2.1** | `app/workers/image_worker.py`, `app/workers/redis_client.py`, `app/services/pipeline.py`, `app/routers/images.py`, `app/main.py`, `app/config.py`, `worker.py`, `docker-compose.yml` | `pytest tests/test_pipeline_arq.py` |
| **2.2** | `app/services/storage_backend.py`, `app/services/storage.py`, `app/routers/files.py`, `app/routers/images.py`, `app/main.py`, `app/config.py` | `pytest tests/test_storage.py` |
| **2.3** | `app/models/client.py`, `app/services/storage.py`, `app/routers/images.py`, `app/schemas/__init__.py` | `pytest tests/test_storage.py -k quota` |
| **2.4** | `app/logging_config.py`, `app/metrics.py`, `app/middleware/logging_middleware.py`, `app/main.py`, + remplacement de tous les `print()` dans `services/` et `workers/` | `pytest tests/test_observability.py` |
| **2.5** | `.github/workflows/ci.yml`, `.pre-commit-config.yaml`, `pyproject.toml`, `Makefile`, `requirements-dev.txt`, `tests/test_pipeline_arq.py`, `tests/test_storage.py`, `tests/test_observability.py` | `make ci` |
**Résultat final de la Phase 2 :** hub production-ready avec pipeline persistant et retry automatique, stockage abstrait compatible S3/MinIO, logs JSON structurés, métriques Prometheus et pipeline CI/CD complet.
</deliverable_summary>

View File

@ -0,0 +1,855 @@
# Prompt Claude Code — Phase 3 : Expérience développeur
# Modèle : claude-opus-4-6
# Usage : claude --model claude-opus-4-6 -p "$(cat PROMPT_PHASE3_claude-opus.md)"
# ou coller directement dans une session Claude Code interactive
---
<role>
Tu es un ingénieur backend senior Python spécialisé en conception d'APIs publiques, expérience développeur (DX) et intégration de systèmes. Tu travailles sur le projet Imago pour livrer sa phase finale : rendre ce hub aussi simple et agréable à intégrer que possible pour ses clients.
Les Phases 1 (sécurité, multi-tenants, rate limiting) et Phase 2 (ARQ + Redis, StorageBackend abstrait, structlog, Prometheus, CI/CD) sont entièrement terminées et validées. Tu prends le relais pour la dernière phase : WebSockets temps réel, versioning de l'API, SDK Python officiel, dashboard admin et intégration complète avec Shaarli.
</role>
<project_context>
## État du projet après Phase 1 et Phase 2
### Stack complète en place
```
# Phase 0 — Base
fastapi==0.115.0 # framework web async
uvicorn[standard]==0.30.6 # serveur ASGI
sqlalchemy[asyncio]==2.0.35 # ORM async
alembic==1.13.3 # migrations
pydantic-settings==2.5.2 # config depuis .env
pillow==10.4.0 # traitement images + thumbnails
piexif==1.1.3 # extraction EXIF avancée
pytesseract==0.3.13 # OCR Tesseract
anthropic==0.34.2 / httpx==0.27.2 # Vision AI Claude
beautifulsoup4==4.12.3 # scraping web
aiofiles==24.1.0 # I/O fichiers async
# Phase 1 — Sécurité
python-jose[cryptography]==3.3.0 # JWT
passlib[bcrypt]==1.7.4 # hachage API Keys
slowapi==0.1.9 # rate limiting par client/plan
# Phase 2 — Robustesse
arq==0.25.0 # file de tâches async persistante
redis==5.0.8 # client Redis (Pub/Sub + ARQ)
aioboto3==13.0.0 # stockage S3/MinIO/R2 async
structlog==24.4.0 # logs JSON structurés
prometheus-fastapi-instrumentator==0.14.0 # métriques Prometheus
```
### Structure complète du projet (après Phase 2)
```
imago/
├── app/
│ ├── main.py # App FastAPI, lifespan, middlewares, /metrics, /health
│ ├── config.py # Settings complets (auth, redis, S3, logging, quotas)
│ ├── database.py # Engine SQLAlchemy async + session factory
│ ├── logging_config.py # Configuration structlog JSON
│ ├── metrics.py # Compteurs/Histogrammes Prometheus custom
│ ├── models/
│ │ ├── image.py # Image (client_id FK, EXIF, OCR, AI, statut pipeline)
│ │ └── client.py # APIClient (id, name, hash, scopes, plan, quotas, storage_used)
│ ├── schemas/
│ │ └── __init__.py # Schémas Pydantic complets avec quotas
│ ├── dependencies/
│ │ └── auth.py # verify_api_key, require_scope, get_current_client
│ ├── middleware/
│ │ ├── rate_limit.py # Rate limiting par plan (slowapi)
│ │ └── logging_middleware.py # Logs HTTP structurés par requête
│ ├── routers/
│ │ ├── auth.py # CRUD clients, rotation de clé (/auth/*)
│ │ ├── images.py # Endpoints images filtrés par client (/images/*)
│ │ ├── ai.py # Endpoints AI (/ai/summarize, /ai/draft-task)
│ │ └── files.py # Serveur fichiers signés (/files/signed/{token})
│ ├── services/
│ │ ├── storage_backend.py # ABC StorageBackend + LocalStorage + S3Storage
│ │ ├── storage.py # Orchestration upload/delete avec quota check
│ │ ├── exif_service.py # Extraction EXIF (piexif)
│ │ ├── ocr_service.py # OCR Tesseract
│ │ ├── ai_vision.py # Vision AI + summarize_url + draft_task
│ │ ├── scraper.py # Scraping web BeautifulSoup
│ │ └── pipeline.py # Orchestration EXIF→OCR→AI + publication Redis Pub/Sub
│ └── workers/
│ ├── image_worker.py # Worker ARQ (process_image_task, WorkerSettings)
│ └── redis_client.py # Pool Redis async partagé
├── tests/
│ ├── conftest.py # Fixtures : test_db, client_a/b, auth_headers, redis_mock
│ ├── test_services.py # Tests unitaires services (EXIF, OCR, storage)
│ ├── test_auth.py # Tests authentification et scopes
│ ├── test_isolation.py # Tests isolation multi-tenants
│ ├── test_rate_limit.py # Tests rate limiting par plan
│ ├── test_pipeline_arq.py # Tests worker ARQ (enqueue, retry, dead-letter)
│ ├── test_storage.py # Tests StorageBackend + quota + URLs signées
│ └── test_observability.py # Tests /metrics et /health/detailed
├── alembic/
│ └── versions/ # Migrations : images, api_clients, storage_used
├── worker.py # Point d'entrée : python worker.py
├── run.py # Point d'entrée serveur : python run.py
├── Makefile # make dev | test | ci | lint | docker-up
├── pyproject.toml # Config ruff, black, mypy, pytest, coverage
├── requirements.txt # Dépendances production
├── requirements-dev.txt # Dépendances dev (pytest, ruff, mypy, bandit...)
├── .pre-commit-config.yaml # Hooks pre-commit
├── .github/workflows/ci.yml # Pipeline CI/CD (quality + tests + docker + deploy)
├── Dockerfile
└── docker-compose.yml # backend + worker + redis + minio
```
### Ce qui manque encore (cible de la Phase 3)
**WebSockets absents** : le pipeline publie déjà sur Redis Pub/Sub (Phase 2), mais aucun endpoint WebSocket n'expose ces événements aux clients. Ils doivent encore poller `GET /images/{id}/status`.
**API non versionnée** : tous les endpoints sont sous `/images/`, `/ai/`, `/auth/`. Si l'on change un contrat, tous les clients cassent. Aucune politique de dépréciation.
**Pas de SDK** : chaque client doit réimplémenter l'upload, le polling, la gestion des erreurs, le retry. Shaarli devra faire de même.
**Dashboard admin absent** : aucune interface pour superviser les clients, leurs quotas et la santé du hub. Les opérations admin passent par des appels curl directs.
**Intégration Shaarli non finalisée** : Shaarli est le premier client officiel mais l'intégration n'est pas documentée, pas testée de bout en bout, et aucun exemple de code n'existe.
</project_context>
<mission>
## Mission : implémenter la Phase 3 — Expérience développeur
Tu dois réaliser les 5 livrables suivants dans l'ordre indiqué. Chaque livrable doit être **complet, testé et fonctionnel** avant de passer au suivant.
---
### Livrable 3.1 — WebSockets pipeline temps réel (priorité MOYENNE)
**Objectif** : exposer les événements Redis Pub/Sub aux clients via WebSocket, éliminant complètement le besoin de polling.
**Aucune nouvelle dépendance** : FastAPI supporte nativement les WebSockets via `websockets` (déjà inclus dans `uvicorn[standard]`).
**Ce qui doit être créé ou modifié** :
1. `app/routers/websocket.py` — Nouveau router WebSocket :
**Endpoint principal** :
```
WS /ws/pipeline/{image_id}?token=<api_key>
```
- Authentification via query param `token` (pas de header Authorization sur WebSocket)
- Valider que l'image appartient au client authentifié avant d'accepter la connexion
- S'abonner au channel Redis `pipeline:{image_id}`
- Pusher chaque message JSON reçu vers le client WebSocket
- Fermer proprement après réception de `pipeline.done` ou `pipeline.error`
- Gérer la déconnexion du client : si le client se déconnecte avant la fin, se désabonner proprement de Redis
**Endpoint de liste des pipelines actifs** (admin) :
```
WS /ws/admin/monitor?token=<admin_api_key>
```
- Nécessite le scope `admin`
- Pusher un événement à chaque fois qu'un pipeline démarre ou se termine sur n'importe quelle image
**Buffer de reconnexion** :
- Stocker les 10 derniers événements d'un pipeline dans Redis (clé `pipeline:buffer:{image_id}`, TTL 60s)
- Si un client se connecte après que le pipeline a démarré, envoyer d'abord les événements bufferisés puis continuer en live
**Format exact des messages** (doit correspondre à ce que publie `pipeline.py`) :
```json
{ "event": "pipeline.started", "image_id": 42, "steps": ["exif", "ocr", "ai"], "timestamp": "..." }
{ "event": "step.completed", "image_id": 42, "step": "exif", "duration_ms": 45, "data": { "camera": "Canon EOS R5", "has_gps": true } }
{ "event": "step.completed", "image_id": 42, "step": "ocr", "duration_ms": 820, "data": { "has_text": true, "preview": "Café de..." } }
{ "event": "step.completed", "image_id": 42, "step": "ai", "duration_ms": 3200, "data": { "tags": ["café", "paris"], "confidence": 0.97 } }
{ "event": "pipeline.done", "image_id": 42, "total_duration_ms": 4065, "status": "done" }
{ "event": "pipeline.error", "image_id": 42, "step": "ai", "error": "API timeout", "retry_attempt": 1 }
```
**Gestion des erreurs** :
- Si l'image est déjà en statut `done` quand le client se connecte → envoyer immédiatement un message `pipeline.done` synthétique et fermer
- Si l'image est en statut `error` → envoyer `pipeline.error` synthétique et fermer
- Si l'image n'appartient pas au client → fermer avec code WebSocket 4003 (Forbidden)
2. `app/metrics.py` — Ajouter la gauge `hub_active_websockets` :
- Incrémenter à l'ouverture d'une connexion WebSocket
- Décrémenter à la fermeture (succès ou erreur)
3. `app/main.py` — Monter le router WebSocket :
```python
from app.routers.websocket import router as ws_router
app.include_router(ws_router) # pas de préfixe /api/v1 — WS garde son propre préfixe /ws
```
4. `tests/test_websocket.py` — Tests complets :
- ✅ Connexion sans token → refus immédiat (code 4001)
- ✅ Connexion avec token valide → acceptée
- ✅ Image appartenant à un autre client → refus (code 4003)
- ✅ Image déjà `done` → reçoit `pipeline.done` synthétique et connexion fermée proprement
- ✅ Réception des événements dans l'ordre : started → step×3 → done
- ✅ Déconnexion client en cours de pipeline → désabonnement Redis propre (pas de goroutine/tâche en fuite)
- ✅ Reconnexion → buffer de 60s rejoué
Pour les tests, utiliser `httpx` avec `ASGITransport` ou le client WebSocket de `starlette.testclient` (`with client.websocket_connect(...)`)
---
### Livrable 3.2 — API versioning `/api/v1/` + politique de dépréciation (priorité MOYENNE)
**Objectif** : structurer l'API sous un préfixe versionné pour garantir la stabilité des contrats pour tous les clients intégrés.
**Ce qui doit être créé ou modifié** :
1. `app/main.py` — Restructurer le montage des routers :
**Avant** (sans versioning) :
```python
app.include_router(images_router)
app.include_router(ai_router)
app.include_router(auth_router)
```
**Après** (versioning propre) :
```python
from fastapi import APIRouter
# Router versionné v1
api_v1 = APIRouter(prefix="/api/v1", tags=["v1"])
api_v1.include_router(images_router)
api_v1.include_router(ai_router)
api_v1.include_router(auth_router)
# WebSocket et fichiers signés — pas de préfixe /api/v1
app.include_router(ws_router) # /ws/*
app.include_router(files_router) # /files/*
# Routes non versionnées (infra)
# GET / → info
# GET /health
# GET /health/detailed
# GET /metrics
app.include_router(api_v1)
```
2. `app/middleware/versioning.py` — Middleware de versioning :
- Ajouter le header `X-API-Version: v1` sur toutes les réponses des routes `/api/v1/*`
- Préparer (en commentaire) l'ajout futur de `Deprecation: true` et `Sunset: <date>` quand v2 existera
- Logger un warning structlog si une route `/api/v1/*` est appelée après la date de sunset (configurable)
3. `app/config.py` — Ajouter :
```python
API_V1_SUNSET_DATE: str = "" # vide = pas de sunset, sinon "2027-06-01"
```
4. `app/main.py` — Mettre à jour la documentation OpenAPI :
```python
app = FastAPI(
title="Imago API",
version="1.0.0",
description="""
## Imago — API v1
Hub centralisé de gestion d'images avec pipeline AI automatique.
### Authentification
Toutes les routes `/api/v1/*` nécessitent un header :
```
Authorization: Bearer <votre_api_key>
```
### Versioning
Cette API respecte le versioning sémantique. La version actuelle est **v1**.
Les changements breaking seront introduits dans `/api/v2/` avec un préavis minimum de 12 mois.
### Rate Limiting
Les limites varient selon le plan :
- `free` : 20 uploads/h, 50 requêtes AI/h
- `standard` : 100 uploads/h, 200 requêtes AI/h
- `premium` : 500 uploads/h, 1000 requêtes AI/h
""",
openapi_tags=[
{"name": "Images", "description": "Gestion des images et pipeline AI"},
{"name": "Intelligence Artificielle", "description": "Résumé d'URL, rédaction de tâches"},
{"name": "Authentification", "description": "Gestion des clients API"},
{"name": "WebSocket", "description": "Notifications temps réel du pipeline"},
{"name": "Santé", "description": "Health checks et métriques"},
],
)
```
5. `tests/test_versioning.py` — Tests :
- ✅ Tous les endpoints images/ai/auth répondent sous `/api/v1/`
- ✅ Les anciennes URLs sans préfixe retournent `HTTP 404`
- ✅ Header `X-API-Version: v1` présent sur toutes les réponses `/api/v1/*`
- ✅ Header absent sur `/health`, `/metrics`, `/ws/*`
---
### Livrable 3.3 — SDK Python officiel `imago-client` (priorité MOYENNE)
**Objectif** : fournir un SDK Python idiomatique que n'importe quel client (Shaarli, scripts, autres apps) peut installer avec `pip install imago-client` et utiliser sans connaître les détails de l'API REST.
**Architecture du SDK** : le SDK est un **package Python séparé** dans le même dépôt (monorepo), dans le dossier `sdk/`.
**Structure du SDK** :
```
sdk/
├── pyproject.toml # Package config (build-system, metadata)
├── README.md # Documentation d'utilisation du SDK
├── imago_client/
│ ├── __init__.py # Exports publics : HubClient, HubImage, HubError
│ ├── client.py # HubClient — point d'entrée principal
│ ├── resources/
│ │ ├── images.py # ImagesResource — méthodes images
│ │ ├── ai.py # AIResource — résumé URL, tâches
│ │ └── auth.py # AuthResource — gestion clients (admin)
│ ├── models.py # Dataclasses : HubImage, HubExif, HubOcr, HubAI
│ ├── websocket.py # PipelineStream — suivi temps réel
│ ├── exceptions.py # HubError, AuthError, QuotaError, NotFoundError
│ └── utils.py # Retry, timeout, helpers
└── tests/
├── test_client.py
├── test_images.py
└── test_websocket_sdk.py
```
**Ce qui doit être créé** :
1. `sdk/imago_client/exceptions.py` :
```python
class HubError(Exception):
"""Erreur de base du SDK."""
def __init__(self, message: str, status_code: int | None = None):
self.status_code = status_code
super().__init__(message)
class AuthError(HubError):
"""Clé API invalide ou scope manquant (401/403)."""
class QuotaError(HubError):
"""Quota dépassé (413 ou 429)."""
class NotFoundError(HubError):
"""Ressource introuvable (404)."""
class PipelineError(HubError):
"""Erreur lors du traitement AI d'une image."""
```
2. `sdk/imago_client/models.py` — Dataclasses des réponses :
```python
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class HubExif:
camera_make: str | None = None
camera_model: str | None = None
taken_at: datetime | None = None
gps_latitude: float | None = None
gps_longitude: float | None = None
iso: int | None = None
aperture: str | None = None
@dataclass
class HubOcr:
has_text: bool = False
text: str | None = None
language: str | None = None
confidence: float | None = None
@dataclass
class HubAI:
description: str | None = None
tags: list[str] = field(default_factory=list)
confidence: float | None = None
model_used: str | None = None
@dataclass
class HubImage:
id: int
uuid: str
original_name: str
status: str # pending | processing | done | error
exif: HubExif = field(default_factory=HubExif)
ocr: HubOcr = field(default_factory=HubOcr)
ai: HubAI = field(default_factory=HubAI)
width: int | None = None
height: int | None = None
file_size: int | None = None
uploaded_at: datetime | None = None
```
3. `sdk/imago_client/websocket.py``PipelineStream` :
```python
class PipelineStream:
"""Suivi temps réel du pipeline via WebSocket avec fallback polling."""
async def __aiter__(self) -> AsyncIterator[PipelineEvent]:
"""Itère sur les événements du pipeline jusqu'à done/error."""
async def wait_until_done(self, timeout: float = 120.0) -> HubImage:
"""Attend la fin du pipeline et retourne l'image complète."""
async def _try_websocket(self) -> bool:
"""Tente la connexion WebSocket. Retourne False si indisponible."""
async def _fallback_polling(self, interval: float = 2.0) -> None:
"""Polling de /status si WebSocket indisponible."""
```
4. `sdk/imago_client/resources/images.py``ImagesResource` :
```python
class ImagesResource:
async def upload(
self,
file: str | Path | bytes | BinaryIO,
*,
filename: str | None = None,
) -> PipelineStream:
"""Upload une image et retourne un stream de suivi du pipeline."""
async def get(self, image_id: int) -> HubImage:
"""Récupère les détails complets d'une image."""
async def list(
self,
*,
page: int = 1,
page_size: int = 20,
tag: str | None = None,
status: str | None = None,
search: str | None = None,
) -> tuple[list[HubImage], int]: # (images, total)
"""Liste les images avec pagination et filtres."""
async def delete(self, image_id: int) -> None:
"""Supprime une image."""
async def get_download_url(self, image_id: int, expires_in: int = 900) -> str:
"""Retourne une URL signée pour télécharger l'image."""
async def reprocess(self, image_id: int) -> None:
"""Relance le pipeline AI sur une image existante."""
```
5. `sdk/imago_client/client.py``HubClient` :
```python
class HubClient:
def __init__(
self,
api_key: str,
base_url: str = "http://localhost:8000",
*,
timeout: float = 30.0,
max_retries: int = 3,
retry_backoff: float = 1.0,
):
self.images = ImagesResource(self)
self.ai = AIResource(self)
self.auth = AuthResource(self) # admin uniquement
async def __aenter__(self) -> "HubClient": ...
async def __aexit__(self, *args) -> None: ...
# Usage :
# async with HubClient(api_key="sk-...") as hub:
# stream = await hub.images.upload("photo.jpg")
# image = await stream.wait_until_done()
# print(image.ai.description)
```
6. `sdk/imago_client/resources/ai.py``AIResource` :
```python
class AIResource:
async def summarize_url(self, url: str, language: str = "français") -> dict:
"""Résumé AI d'une URL web."""
async def draft_task(
self, description: str, context: str | None = None, language: str = "français"
) -> dict:
"""Génère une tâche structurée depuis une description libre."""
```
7. `sdk/pyproject.toml` :
```toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "imago-client"
version = "1.0.0"
description = "SDK Python officiel pour le Imago"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"httpx>=0.27.0",
"websockets>=13.0",
"pydantic>=2.0",
]
[project.optional-dependencies]
dev = ["pytest>=8.0", "pytest-asyncio", "respx"] # respx pour mocker httpx
```
8. `sdk/README.md` — Documentation complète avec exemples :
```markdown
# imago-client
## Installation
pip install imago-client
## Usage rapide
```python
import asyncio
from imago_client import HubClient
async def main():
async with HubClient(api_key="sk-...", base_url="https://hub.example.com") as hub:
# Upload et attente du pipeline
stream = await hub.images.upload("photo.jpg")
image = await stream.wait_until_done(timeout=120)
print(f"Description : {image.ai.description}")
print(f"Tags : {', '.join(image.ai.tags)}")
print(f"Appareil : {image.exif.camera_make} {image.exif.camera_model}")
# Résumé d'URL
result = await hub.ai.summarize_url("https://example.com/article")
print(result["summary"])
```
9. `sdk/tests/` — Tests du SDK avec `respx` pour mocker httpx :
- ✅ `upload()` retourne un `PipelineStream` valide
- ✅ `wait_until_done()` complète via WebSocket
- ✅ `wait_until_done()` complète via polling si WebSocket indisponible
- ✅ `HubError` levée sur HTTP 4xx/5xx
- ✅ `AuthError` sur 401, `QuotaError` sur 413/429, `NotFoundError` sur 404
- ✅ Retry automatique sur erreurs 5xx (max_retries fois)
- ✅ Context manager `async with HubClient(...) as hub:` ferme les connexions proprement
---
### Livrable 3.4 — Dashboard admin (priorité FAIBLE)
**Objectif** : fournir une interface web légère permettant de superviser le hub sans passer par curl — visualiser les clients, leurs quotas, les métriques du pipeline et la santé des composants.
**Choix technique** : **pas de framework frontend séparé**. Le dashboard est une interface HTML/JS servie directement par FastAPI via `Jinja2Templates`. Simple, sans build step, déployé avec le backend.
**Nouvelle dépendance** :
```
jinja2==3.1.4
```
**Ce qui doit être créé** :
1. `app/routers/admin.py` — Router admin avec pages HTML et endpoints JSON :
**Pages HTML** (rendu Jinja2, scope `admin` requis) :
- `GET /admin/` → Dashboard overview (métriques globales, santé)
- `GET /admin/clients` → Liste des clients avec quotas et consommation
- `GET /admin/clients/{id}` → Détail d'un client (images, tokens AI, activité)
- `GET /admin/queue` → État de la file ARQ (jobs en attente, en cours, échoués)
- `GET /admin/storage` → Consommation disque par client
**Endpoints JSON** (appelés par le JS du dashboard) :
- `GET /admin/api/stats` → Statistiques globales (total images, tokens AI, taille stockage)
- `GET /admin/api/clients` → Liste clients avec métriques temps réel
- `GET /admin/api/queue/status` → Jobs ARQ en cours et en attente
- `POST /admin/api/clients/{id}/toggle` → Activer/désactiver un client
- `POST /admin/api/clients/{id}/reset-quota` → Réinitialiser les compteurs de quota
2. `app/templates/admin/` — Templates Jinja2 :
**`base.html`** — Layout commun :
- Sidebar avec navigation (Dashboard, Clients, Queue, Stockage)
- Header avec nom du hub et version
- Zone de contenu principale
- Style minimal avec CSS vanilla (pas de dépendance externe — tout inline ou dans `static/`)
**`dashboard.html`** — Page principale :
- Cartes métriques : total images, clients actifs, tokens AI consommés ce mois, espace utilisé
- Graphique simple (Chart.js CDN) : uploads par jour sur 30 jours
- Tableau santé des composants (BDD, Redis, ARQ, Tesseract, Anthropic) avec badge coloré
**`clients.html`** — Liste des clients :
- Tableau : nom, plan, images, stockage utilisé/quota, tokens AI ce mois, statut actif
- Badge coloré par plan (free = gris, standard = bleu, premium = or)
- Bouton activer/désactiver inline (appel AJAX vers `/admin/api/clients/{id}/toggle`)
- Lien vers le détail de chaque client
**`client_detail.html`** — Détail d'un client :
- En-tête : nom, plan, API key (masquée `sk-...****`), date de création
- Jauges de quota : images (X/Y), stockage (X MB / Y MB), tokens AI (X/Y)
- Tableau des 20 dernières images avec statut pipeline et tags AI
**`queue.html`** — État de la file ARQ :
- Compteurs : jobs en attente, en cours, réussis, échoués (dernières 24h)
- Tableau des jobs actifs : image_id, client, étape en cours, durée
- Tableau des jobs échoués récents avec message d'erreur
3. `app/static/admin/` — Fichiers statiques du dashboard :
- `style.css` — styles du dashboard (palette cohérente avec le projet)
- `dashboard.js` — rafraîchissement automatique des métriques toutes les 30s via `fetch`
4. `app/main.py` — Monter le dashboard :
```python
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
templates = Jinja2Templates(directory="app/templates")
app.mount("/admin/static", StaticFiles(directory="app/static/admin"), name="admin_static")
app.include_router(admin_router)
```
5. `tests/test_admin.py` — Tests du dashboard :
- ✅ `GET /admin/` sans scope `admin` → HTTP 403
- ✅ `GET /admin/` avec scope `admin` → HTTP 200 avec HTML valide
- ✅ `GET /admin/api/stats` → JSON avec les bons champs
- ✅ `POST /admin/api/clients/{id}/toggle` → change `is_active`
- ✅ Client désactivé → ses requêtes API retournent 401
---
### Livrable 3.5 — Intégration Shaarli complète + documentation (priorité HAUTE)
**Objectif** : finaliser l'intégration entre Shaarli (premier client officiel) et le hub, avec une documentation technique complète qui permettrait à n'importe quel développeur d'intégrer le hub en moins d'une heure.
**Ce qui doit être créé** :
1. `integration/shaarli/` — Module d'intégration Shaarli :
**`integration/shaarli/hub_plugin.py`** — Plugin Python utilisable depuis Shaarli :
```python
"""
Plugin d'intégration Shaarli ↔ Imago.
Installe dans Shaarli via : pip install imago-client
"""
import asyncio
from pathlib import Path
from imago_client import HubClient
class ShaarliHubPlugin:
"""
Enrichit les bookmarks Shaarli avec des images analysées par le hub.
Usage :
plugin = ShaarliHubPlugin(api_key="sk-...", hub_url="http://hub:8000")
# Sur ajout d'un bookmark avec image :
result = asyncio.run(plugin.process_bookmark_image("photo.jpg", bookmark_id=42))
"""
def __init__(self, api_key: str, hub_url: str):
self.hub_url = hub_url
self.api_key = api_key
async def process_bookmark_image(
self, image_path: str | Path, bookmark_id: int
) -> dict:
"""Upload une image associée à un bookmark et attend l'analyse AI."""
async with HubClient(self.api_key, self.hub_url) as hub:
stream = await hub.images.upload(image_path)
image = await stream.wait_until_done(timeout=120)
return {
"bookmark_id": bookmark_id,
"image_id": image.id,
"description": image.ai.description,
"tags": image.ai.tags,
"ocr_text": image.ocr.text,
}
async def enrich_bookmark_url(self, url: str) -> dict:
"""Génère un résumé AI d'un lien web pour enrichir un bookmark."""
async with HubClient(self.api_key, self.hub_url) as hub:
return await hub.ai.summarize_url(url)
```
**`integration/shaarli/config_example.env`** :
```bash
# Configuration du plugin Imago
HUB_URL=http://imago:8000
HUB_API_KEY=sk-votre-cle-api-ici
HUB_TIMEOUT=120
```
**`integration/shaarli/example_usage.py`** — Exemples commentés de tous les cas d'usage :
- Upload d'image depuis un bookmark
- Résumé d'URL pour un nouveau lien
- Recherche d'images par tag
- Gestion des erreurs (quota dépassé, timeout, réseau)
2. `docs/` — Documentation technique complète :
**`docs/getting-started.md`** — Guide de démarrage en 5 minutes :
```markdown
# Démarrage rapide
## 1. Lancer le hub
git clone ...
cp .env.example .env
# Éditer .env : ANTHROPIC_API_KEY=sk-ant-...
docker-compose up -d
# Hub disponible sur http://localhost:8000
## 2. Créer votre premier client
curl -X POST http://localhost:8000/api/v1/auth/clients \
-H "Authorization: Bearer $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Mon App", "plan": "standard", "scopes": ["images:read", "images:write", "ai:use"]}'
# → { "api_key": "sk-VOTRE-CLE-ICI" } ← la clé n'est affichée qu'une seule fois
## 3. Uploader votre première image
curl -X POST http://localhost:8000/api/v1/images/upload \
-H "Authorization: Bearer sk-VOTRE-CLE-ICI" \
-F "file=@photo.jpg"
# → { "id": 1, "status": "pending", ... }
## 4. Suivre le pipeline en temps réel (WebSocket)
# Voir docs/websocket.md
## 5. Récupérer les résultats
curl http://localhost:8000/api/v1/images/1 \
-H "Authorization: Bearer sk-VOTRE-CLE-ICI"
# → { "ai": { "description": "...", "tags": [...] }, "exif": {...}, "ocr": {...} }
```
**`docs/api-reference.md`** — Référence complète de tous les endpoints :
- Chaque endpoint documenté avec : méthode, URL, authentification requise, scopes, paramètres, exemple de réponse JSON
- Codes d'erreur possibles avec leur signification
**`docs/websocket.md`** — Guide WebSocket :
- Connexion, authentification via query param
- Format des événements (avec exemples JSON)
- Gestion de la reconnexion et du buffer 60s
- Exemples en Python, JavaScript et curl (via websocat)
**`docs/sdk.md`** — Guide du SDK Python :
- Installation
- Tous les cas d'usage avec exemples complets
- Gestion des erreurs (chaque exception du SDK documentée)
- Configuration avancée (timeout, retry, proxy)
**`docs/deployment.md`** — Guide de déploiement en production :
- Docker Compose (backend + worker + Redis + MinIO)
- Variables d'environnement obligatoires et optionnelles
- Passage de SQLite à PostgreSQL
- Passage de LocalStorage à S3/MinIO/R2
- Configuration Nginx en reverse proxy
- Recommandations de sécurité (HTTPS, headers, firewall)
**`docs/shaarli-integration.md`** — Guide d'intégration Shaarli :
- Installation du plugin
- Configuration
- Tous les cas d'usage (image bookmark, résumé URL, recherche par tag)
- Dépannage des problèmes courants
3. `tests/test_shaarli_integration.py` — Tests d'intégration end-to-end Shaarli :
- ✅ `ShaarliHubPlugin.process_bookmark_image()` → image uploadée + pipeline terminé
- ✅ `ShaarliHubPlugin.enrich_bookmark_url()` → résumé retourné
- ✅ Gestion d'une `QuotaError` (quota dépassé)
- ✅ Gestion d'un timeout (pipeline trop long)
- ✅ Plugin fonctionne sans event loop existant (appel depuis code synchrone Shaarli)
4. `CHANGELOG.md` — Historique des versions :
```markdown
# Changelog
## [1.0.0] — 2026-02-24
### Phase 3 — Expérience développeur
- WebSockets : suivi temps réel du pipeline avec buffer de reconnexion
- API versioning : tous les endpoints sous /api/v1/ avec header X-API-Version
- SDK Python officiel : imago-client 1.0.0 sur PyPI
- Dashboard admin : interface web de supervision des clients et quotas
- Intégration Shaarli : plugin + documentation complète
## [0.2.0] — Phase 2 — Robustesse et scalabilité
...
## [0.1.0] — Phase 1 — Fondations sécurité
...
```
5. `README.md` (racine) — Réécrire pour être le point d'entrée de toute la documentation :
- Badge CI (passing), badge version, badge Python
- Une phrase de description claire
- Liens rapides vers : getting-started, api-reference, sdk, déploiement
- Section "Architecture" avec le schéma texte du système
- Section "Clients supportés" : Shaarli (officiel) + instructions pour créer son propre client
</mission>
<execution_rules>
## Règles d'exécution
### Prérequis avant de commencer
1. Vérifier que les Phases 1 et 2 sont bien en place : `make ci` doit passer sans erreur
2. Redis doit être accessible en local pour les tests WebSocket
3. Lire les fichiers existants **avant** de les modifier — notamment `pipeline.py` (qui publie déjà sur Redis) et `main.py` (pour comprendre la structure de montage des routers)
### Ordre de réalisation
Implémenter dans l'ordre strict : **3.1 → 3.2 → 3.3 → 3.4 → 3.5**. Valider chaque livrable avant de continuer.
### À chaque livrable
1. Lire les fichiers concernés avec l'outil `Read` ou `Glob`
2. Implémenter le code complet (zéro `# TODO`, zéro `...` comme corps de fonction)
3. Mettre à jour `requirements.txt` si nécessaire
4. Lancer `pytest tests/ -v` après chaque livrable — corriger avant de passer au suivant
5. Pour le livrable 3.5 (docs), valider que tous les exemples de code dans la doc fonctionnent réellement
### Qualité du code
- **Typage complet** : toutes les fonctions annotées, compatible `mypy --strict`
- **Docstrings** sur toutes les classes et méthodes publiques des modules créés
- **Zéro secret** : aucune clé, URL ou credential hardcodé
- **Gestion propre des connexions WebSocket** : toujours se désabonner de Redis Pub/Sub et fermer proprement même en cas d'exception
- **SDK** : doit fonctionner de manière totalement indépendante du backend — aucun import depuis `app/`
### Spécificités WebSocket (livrable 3.1)
- Tester la déconnexion client pendant le pipeline : vérifier qu'aucune tâche orpheline ne reste dans l'event loop
- Le buffer Redis (`pipeline:buffer:{image_id}`) doit avoir un TTL de 60 secondes — ne pas oublier de le configurer dans Redis
- Si le pipeline est déjà terminé quand le client se connecte, retourner immédiatement le résultat final depuis la BDD (pas depuis Redis)
### Spécificités SDK (livrable 3.3)
- Le SDK doit gérer le retry automatique sur les erreurs 5xx (backoff exponentiel)
- `wait_until_done()` doit tenter le WebSocket d'abord (timeout 5s pour la connexion), puis basculer automatiquement sur le polling si le WebSocket échoue ou est indisponible
- Les exceptions du SDK doivent contenir le `status_code` HTTP pour faciliter le débogage
- Le SDK ne doit pas avoir de dépendance vers `fastapi`, `sqlalchemy` ou tout autre élément du backend
### Critère de succès final
Les commandes suivantes doivent toutes réussir :
```bash
# Tests backend complets
pytest tests/ -v --cov=app --cov-report=term-missing
# Tests SDK
cd sdk && pytest tests/ -v
# Qualité globale
make ci
# Vérification manuelle du dashboard
python run.py &
python worker.py &
curl -s http://localhost:8000/admin/api/stats | python -m json.tool
# Test WebSocket rapide (nécessite websocat)
websocat "ws://localhost:8000/ws/pipeline/1?token=sk-test-key"
```
</execution_rules>
<deliverable_summary>
## Résumé des livrables attendus
| # | Fichiers créés ou modifiés | Validation |
|---|---|---|
| **3.1** | `app/routers/websocket.py`, `app/metrics.py`, `app/main.py`, `tests/test_websocket.py` | `pytest tests/test_websocket.py` |
| **3.2** | `app/main.py`, `app/middleware/versioning.py`, `app/config.py`, `tests/test_versioning.py` | `pytest tests/test_versioning.py` |
| **3.3** | `sdk/` (package complet : client, resources, models, exceptions, websocket), `sdk/README.md`, `sdk/tests/` | `cd sdk && pytest tests/ -v` |
| **3.4** | `app/routers/admin.py`, `app/templates/admin/` (4 templates), `app/static/admin/`, `app/main.py`, `tests/test_admin.py` | `pytest tests/test_admin.py` |
| **3.5** | `integration/shaarli/`, `docs/` (5 guides), `CHANGELOG.md`, `README.md`, `tests/test_shaarli_integration.py` | `pytest tests/test_shaarli_integration.py` |
**Résultat final de la Phase 3 et du projet complet :**
- Hub multi-clients sécurisé, robuste, observable et entièrement documenté
- Pipeline AI avec suivi temps réel via WebSocket
- API versionnée stable avec contrat de dépréciation
- SDK Python officiel installable via pip
- Dashboard admin pour superviser clients et quotas
- Intégration Shaarli officielle avec documentation complète
- `make ci` vert, coverage > 80%, zéro warning mypy
</deliverable_summary>

Binary file not shown.

View File

@ -0,0 +1,735 @@
# Rapport d'amélioration — Imago
**Architecture d'un hub centralisé multi-clients**
| | |
|---|---|
| **Version** | 1.0.0 |
| **Date** | Février 2026 |
| **Statut** | Proposition — v1 Initiale |
| **Contexte** | Backend FastAPI — Hub image multi-clients |
---
## Table des matières
1. [Résumé exécutif](#1-résumé-exécutif)
2. [Axe 1 — Authentification et autorisation](#2-axe-1--authentification-et-autorisation)
3. [Axe 2 — Architecture multi-clients (tenants)](#3-axe-2--architecture-multi-clients-tenants)
4. [Axe 3 — Pipeline asynchrone robuste](#4-axe-3--pipeline-asynchrone-robuste)
5. [Axe 4 — Stockage et distribution des fichiers](#5-axe-4--stockage-et-distribution-des-fichiers)
6. [Axe 5 — WebSockets et notifications en temps réel](#6-axe-5--websockets-et-notifications-en-temps-réel)
7. [Axe 6 — Observabilité et monitoring](#7-axe-6--observabilité-et-monitoring)
8. [Axe 7 — Versioning de l'API et SDK clients](#8-axe-7--versioning-de-lapi-et-sdk-clients)
9. [Axe 8 — Tests, qualité de code et CI/CD](#9-axe-8--tests-qualité-de-code-et-cicd)
10. [Feuille de route proposée](#10-feuille-de-route-proposée)
11. [Stack technique finale recommandée](#11-stack-technique-finale-recommandée)
12. [Conclusion](#12-conclusion)
---
## 1. Résumé exécutif
Le backend Imago a été conçu initialement comme un complément à l'interface Shaarli pour gérer les images, l'OCR et les analyses AI. L'objectif a évolué : ce backend doit devenir un **hub centralisé** capable de servir plusieurs applications clientes simultanément.
Ce rapport identifie **8 axes d'amélioration** couvrant la sécurité, la scalabilité, la qualité du code et l'expérience développeur. Chaque axe est accompagné de recommandations concrètes, d'exemples de code et d'une estimation d'effort.
> **Contexte actuel**
> - Clients identifiés : Interface Shaarli (client 1), futures applications tierces
> - Technologies actuelles : FastAPI, SQLAlchemy async, Pillow, Tesseract, Anthropic Claude
> - Points critiques immédiats : absence d'authentification, pas de gestion multi-clients, pipeline non persistant
> - Horizon de développement proposé : 3 phases sur 3 mois
### 1.1 Synthèse des axes d'amélioration
| Axe d'amélioration | Impact principal | Priorité | Effort |
|---|---|:---:|:---:|
| Authentification & Autorisation | Sécurité multi-clients | 🔴 Critique | 35 j |
| Gestion multi-clients (tenants) | Isolation des données | 🔴 Critique | 57 j |
| Pipeline asynchrone robuste | Fiabilité du traitement AI | 🟠 Haute | 45 j |
| Stockage & CDN | Performance, scalabilité | 🟠 Haute | 34 j |
| WebSockets & temps réel | Expérience client | 🟡 Moyenne | 23 j |
| Observabilité & Monitoring | Production-ready | 🟡 Moyenne | 34 j |
| API versioning & SDK clients | Contrat API stable | 🟡 Moyenne | 23 j |
| Tests & CI/CD | Qualité et maintenabilité | 🟠 Haute | 45 j |
---
## 2. Axe 1 — Authentification et autorisation
### 2.1 Problématique
Dans l'état actuel, le backend est entièrement public. N'importe quelle application ou personne ayant accès au réseau peut uploader, lire ou supprimer des images. Cela est incompatible avec un hub servant plusieurs clients distincts.
> ⚠️ **Risques actuels**
> - Accès non contrôlé à l'ensemble des images et métadonnées
> - Consommation illimitée de tokens AI (coût financier direct)
> - Suppression accidentelle ou malveillante de données
### 2.2 Solution recommandée — API Keys + JWT
Pour un hub multi-clients, la combinaison la plus pragmatique est : des **API Keys** pour l'authentification machine-to-machine (clients automatiques, scripts), et des **tokens JWT** pour les sessions utilisateurs interactives.
| Mécanisme | Cas d'usage | Avantages |
|---|---|---|
| API Key | Shaarli, applications serveur, scripts CLI | Simple, stateless, facile à révoquer par client |
| JWT (Bearer) | Interface web, utilisateur connecté | Expiration automatique, payload riche |
| OAuth2 (futur) | Intégration systèmes tiers | Standard industriel, écosystème large |
### 2.3 Implémentation
Ajouter un modèle `APIClient` en base de données reliant chaque client à ses permissions et sa clé. Injecter une dépendance FastAPI `verify_api_key` qui valide la clé sur chaque requête. Définir des scopes granulaires : `images:read`, `images:write`, `images:delete`, `ai:use`.
```python
# app/models/client.py
class APIClient(Base):
__tablename__ = "api_clients"
id = Column(UUID, primary_key=True, default=uuid4)
name = Column(String, nullable=False) # "Shaarli", "App Mobile"
api_key_hash = Column(String, nullable=False) # SHA-256, jamais en clair
scopes = Column(JSON, default=list) # ["images:read", "ai:use"]
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
```
```python
# app/dependencies/auth.py
async def verify_api_key(
authorization: str = Header(...),
db: AsyncSession = Depends(get_db)
) -> APIClient:
key = authorization.removeprefix("Bearer ").strip()
key_hash = hashlib.sha256(key.encode()).hexdigest()
result = await db.execute(
select(APIClient).where(APIClient.api_key_hash == key_hash)
)
client = result.scalar_one_or_none()
if not client or not client.is_active:
raise HTTPException(status_code=401, detail="Clé API invalide")
return client
```
> 💡 **Points d'implémentation**
> - Librairies : `python-jose` (JWT), `passlib` (hachage), `itsdangerous` (tokens signés)
> - Stockage des clés : hachées en base (sha256), **jamais en clair**
> - Rotation des clés : endpoint `POST /auth/rotate-key` avec période de transition
> - Audit log : chaque requête authentifiée loggée avec `client_id`, `action`, `timestamp`
---
## 3. Axe 2 — Architecture multi-clients (tenants)
### 3.1 Vision cible
Le hub doit servir plusieurs applications clientes avec une **isolation complète des données**. Shaarli est le client 1, mais d'autres applications peuvent s'inscrire et utiliser l'API de manière indépendante. Chaque client possède son propre espace de stockage, ses propres images et ses propres quotas.
| Concept | Description |
|---|---|
| **Client (Tenant)** | Une application consommatrice de l'API. Identifiée par son API Key. Ex : Shaarli, App Mobile, Script de backup. |
| **Espace de stockage** | Répertoire dédié : `uploads/{client_id}/`. Les images d'un client ne sont jamais accessibles par un autre. |
| **Quota** | Limite configurable : nb d'images, espace disque total, tokens AI consommés par mois. |
| **Plan** | Niveaux de service : `free` (limite stricte), `standard`, `premium` (OCR + AI complets). |
### 3.2 Modifications de la base de données
Ajouter une table `clients` avec les champs essentiels. Lier chaque image à son client via une clé étrangère `client_id`. Tous les endpoints filtrent automatiquement par `client_id` injecté depuis l'authentification.
| Champ | Type | Description |
|---|---|---|
| `id` | UUID | Identifiant unique du client |
| `name` | String | Nom de l'application cliente |
| `api_key_hash` | String | Hash SHA-256 de la clé (jamais en clair) |
| `scopes` | JSON | Permissions accordées : `["images:read", "ai:use", ...]` |
| `quota_images` | Integer | Nombre maximum d'images stockées |
| `quota_storage_mb` | Integer | Espace disque alloué en mégaoctets |
| `quota_ai_tokens` | Integer | Tokens AI mensuels autorisés |
| `is_active` | Boolean | Désactiver un client sans supprimer ses données |
| `created_at` | DateTime | Date d'inscription |
### 3.3 Injection automatique du client dans les endpoints
```python
# Avant (aucun contrôle)
@router.get("/images")
async def list_images(db: AsyncSession = Depends(get_db)):
return await db.execute(select(Image))
# Après (filtrage automatique par client)
@router.get("/images")
async def list_images(
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(verify_api_key), # ← injecté
):
query = select(Image).where(Image.client_id == client.id) # ← filtré
return await db.execute(query)
```
---
## 4. Axe 3 — Pipeline asynchrone robuste
### 4.1 Limites de l'implémentation actuelle
Le pipeline actuel utilise les `BackgroundTasks` de FastAPI. Cette approche est fonctionnelle pour un usage léger mais présente des limites importantes pour un hub en production.
> ⚠️ **Problèmes identifiés**
> - **Perte de tâches** : si le serveur redémarre, toutes les tâches en attente sont perdues (pas de persistance)
> - **Pas de limite de concurrence** : risque de saturer l'API Anthropic et de dépasser les quotas
> - **Pas de retry automatique** : un timeout API ou une erreur réseau = tâche perdue définitivement
> - **Pas de priorité** : un client `premium` attend autant qu'un client `free`
### 4.2 Solution — File de messages avec ARQ ou Celery
| Solution | Avantages | Inconvénients |
|---|---|---|
| **ARQ + Redis** | 100% async, léger, parfait pour FastAPI, retry natif | Nécessite Redis (service supplémentaire) |
| **Celery + Redis** | Très mature, monitoring Flower intégré, priorités | Plus lourd, mélange sync/async parfois délicat |
| **Dramatiq** | Simple, middleware extensible | Communauté plus petite qu'Celery |
**Recommandation : ARQ avec Redis** pour rester dans un écosystème async pur. Redis sert à la fois de broker de tâches et de cache pour les résultats AI.
```python
# app/workers/image_worker.py
import arq
async def process_image_task(ctx, image_id: int):
"""Tâche ARQ — remplace le BackgroundTask actuel."""
db = ctx["db"]
await process_image_pipeline(image_id, db)
class WorkerSettings:
functions = [process_image_task]
redis_settings = arq.connections.RedisSettings()
max_jobs = 10 # concurrence max
job_timeout = 180 # timeout par tâche (secondes)
retry_jobs = True
max_tries = 3 # retry automatique × 3
```
> 💡 **Points d'implémentation**
> - Retry configurable : 3 tentatives avec backoff exponentiel (1s, 4s, 16s)
> - Timeout par tâche : 120 secondes pour l'appel Vision AI, 30s pour l'OCR
> - Files distinctes : `queue:premium` (priorité haute) et `queue:standard`
> - Dead-letter queue : tâches échouées après 3 tentatives → alerte + log persistant
---
## 5. Axe 4 — Stockage et distribution des fichiers
### 5.1 Limites du stockage local
Le stockage sur disque local fonctionne pour un serveur unique mais devient problématique dès que l'on veut scaler horizontalement ou séparer le serveur applicatif du stockage.
> ⚠️ **Problèmes identifiés**
> - Le disque du serveur est partagé par tous les clients sans isolation
> - Pas de réplication — une panne disque = perte de toutes les images
> - Les URLs statiques exposent le chemin réel du fichier sur le serveur
> - Impossible de scaler sur plusieurs instances (fichiers non partagés)
### 5.2 Abstraction du stockage
Introduire une interface `StorageBackend` abstraite permettant de basculer entre stockage local et S3 sans modifier le reste du code.
```python
# app/services/storage_backend.py
from abc import ABC, abstractmethod
class StorageBackend(ABC):
@abstractmethod
async def save(self, file: bytes, path: str) -> str: ...
@abstractmethod
async def delete(self, path: str) -> None: ...
@abstractmethod
async def get_url(self, path: str, expires_in: int = 900) -> str: ...
class LocalStorage(StorageBackend):
"""Backend local — développement et serveur unique."""
async def save(self, file: bytes, path: str) -> str:
full_path = Path(settings.UPLOAD_DIR) / path
async with aiofiles.open(full_path, "wb") as f:
await f.write(file)
return str(full_path)
async def get_url(self, path: str, expires_in: int = 900) -> str:
# Token HMAC signé avec expiration
token = signing.dumps({"path": path, "exp": time() + expires_in})
return f"/files/{token}"
class S3Storage(StorageBackend):
"""Backend S3/MinIO/R2 — production."""
async def get_url(self, path: str, expires_in: int = 900) -> str:
# URL pré-signée AWS native
return await self.client.generate_presigned_url(
"get_object", Params={"Bucket": self.bucket, "Key": path},
ExpiresIn=expires_in
)
```
| Backend | Usage recommandé | Librairie | Coût |
|---|---|---|---|
| Local FileSystem | Développement, serveur unique | `aiofiles` (déjà présent) | Gratuit |
| MinIO (self-hosted) | Production on-premise | `aioboto3` | Infrastructure |
| AWS S3 | Production cloud | `aioboto3` | ~0.023$/GB/mois |
| Cloudflare R2 | Production cloud économique | `aioboto3` (compatible S3) | 0$ egress |
### 5.3 URLs signées et sécurité d'accès
Remplacer les routes `/static/` par des **URLs signées à durée limitée**. Le client reçoit une URL temporaire valide X minutes.
> 💡 **Points d'implémentation**
> - Endpoint : `GET /images/{id}/download-url` → retourne une URL signée valide 15 minutes
> - Paramètre optionnel : `expires_in` (en secondes, max configurable par plan)
> - Pour S3/R2 : URL pré-signée native. Pour stockage local : token HMAC signé par le serveur
> - Thumbnail : même mécanisme via `GET /images/{id}/thumbnail-url`
---
## 6. Axe 5 — WebSockets et notifications en temps réel
### 6.1 Problématique du polling
Actuellement, les clients doivent interroger `GET /images/{id}/status` régulièrement pour connaître l'avancement du pipeline. Cette approche génère du trafic inutile et ajoute de la latence perceptible.
### 6.2 Solution — WebSocket par session d'upload
Proposer un endpoint WebSocket que le client connecte après l'upload. Le serveur **pousse** des événements au fur et à mesure de l'avancement du pipeline.
**Endpoint :** `WS /ws/pipeline/{image_id}?token=<api_key>`
| Événement WebSocket | Payload |
|---|---|
| `pipeline.started` | `{ "image_id": 42, "steps": ["exif", "ocr", "ai"] }` |
| `step.completed` | `{ "step": "exif", "duration_ms": 45, "data": { "camera": "Canon EOS R5" } }` |
| `step.completed` | `{ "step": "ocr", "duration_ms": 820, "data": { "has_text": true, "preview": "Café de Flore..." } }` |
| `step.completed` | `{ "step": "ai", "duration_ms": 3200, "data": { "tags": ["café", "paris"], "confidence": 0.97 } }` |
| `pipeline.done` | `{ "image_id": 42, "total_duration_ms": 4100, "status": "done" }` |
| `pipeline.error` | `{ "step": "ai", "error": "API timeout", "retry": 1 }` |
```python
# app/routers/websocket.py
@router.websocket("/ws/pipeline/{image_id}")
async def pipeline_ws(
websocket: WebSocket,
image_id: int,
token: str = Query(...),
):
client = await verify_api_key_ws(token) # auth via query param
await websocket.accept()
# S'abonner aux événements Redis publiés par le pipeline
async with redis.subscribe(f"pipeline:{image_id}") as channel:
async for message in channel:
await websocket.send_json(message)
if message.get("event") in ("pipeline.done", "pipeline.error"):
break
```
> 💡 **Points d'implémentation**
> - Fallback automatique : si WebSocket non disponible, le client retombe sur le polling REST
> - FastAPI supporte nativement les WebSockets sans dépendance supplémentaire
> - Gestion de la déconnexion : événements bufferisés 60s si le client se reconnecte
> - Redis Pub/Sub : le pipeline publie ses événements, le WebSocket handler s'y abonne
---
## 7. Axe 6 — Observabilité et monitoring
### 7.1 Logs structurés
Remplacer les `print()` du code actuel par des **logs structurés en JSON**. Chaque log doit contenir au minimum : `timestamp`, `level`, `client_id`, `image_id`, `action`, `duration_ms`.
```python
# app/logging.py
import structlog
log = structlog.get_logger()
# Exemple d'usage dans le pipeline
log.info(
"pipeline.step.completed",
image_id=image.id,
client_id=client.id,
step="ocr",
duration_ms=820,
has_text=True,
)
# Sortie JSON en production :
# {"event": "pipeline.step.completed", "image_id": 42, "client_id": "abc",
# "step": "ocr", "duration_ms": 820, "has_text": true, "timestamp": "2026-02-23T..."}
```
> 💡 **Points d'implémentation**
> - Librairie recommandée : `structlog` (compatible FastAPI, format JSON natif)
> - Middleware de logs : enregistrer chaque requête HTTP avec méthode, path, status, duration, client_id
> - Niveaux : `DEBUG` (dev), `INFO` (prod normal), `WARNING` (anomalie récupérable), `ERROR` (action requise)
> - Exportation vers Loki, Datadog, CloudWatch selon infrastructure cible
### 7.2 Métriques applicatives
Exposer des métriques **Prometheus** sur `/metrics` pour un monitoring en temps réel.
| Métrique | Type | Utilité |
|---|---|---|
| `hub_images_uploaded_total` | Counter | Volume d'uploads par client |
| `hub_pipeline_duration_seconds` | Histogram | Temps de traitement p50/p95/p99 |
| `hub_ai_tokens_consumed_total` | Counter | Suivi des coûts AI par client |
| `hub_pipeline_errors_total` | Counter | Taux d'erreur par étape |
| `hub_storage_used_bytes` | Gauge | Espace disque par client |
| `hub_active_websockets` | Gauge | Connexions WebSocket actives |
```python
# main.py
from prometheus_fastapi_instrumentator import Instrumentator
Instrumentator().instrument(app).expose(app, endpoint="/metrics")
```
### 7.3 Health checks détaillés
Améliorer `/health/detailed` pour qu'il vérifie activement chaque composant.
```python
@app.get("/health/detailed")
async def health_detailed():
checks = {}
# Base de données
try:
await db.execute(text("SELECT 1"))
checks["database"] = {"status": "ok", "latency_ms": 12}
except Exception as e:
checks["database"] = {"status": "error", "detail": str(e)}
# Tesseract
checks["tesseract"] = _check_tesseract()
# API Anthropic
checks["anthropic"] = await _check_anthropic_api()
# Espace disque
usage = shutil.disk_usage(settings.UPLOAD_DIR)
checks["disk"] = {
"status": "ok" if usage.percent < 85 else "warning",
"used_pct": round(usage.percent, 1)
}
# File ARQ
checks["queue"] = await _check_arq_queue()
overall = "healthy" if all(c["status"] == "ok" for c in checks.values()) else "degraded"
return {"status": overall, "checks": checks}
```
---
## 8. Axe 7 — Versioning de l'API et SDK clients
### 8.1 Versioning des endpoints
Un hub servant plusieurs clients ne peut pas modifier ses endpoints sans risquer de casser les intégrations existantes.
| Stratégie | Exemple | Recommandation |
|---|---|---|
| **URL path** | `/v1/images/upload`, `/v2/images/upload` | ✅ Simple, visible, **recommandé** |
| Header | `API-Version: 2024-01-15` | Flexible mais moins visible |
| Query param | `?version=1` | ❌ À éviter, pollue les URLs |
Adopter `/api/v1/` comme préfixe dès maintenant. Définir une **politique de dépréciation** : une version est supportée au minimum 12 mois après publication de la suivante.
```python
# main.py
from fastapi import APIRouter
v1 = APIRouter(prefix="/api/v1")
v1.include_router(images_router)
v1.include_router(ai_router)
app.include_router(v1)
# Middleware de dépréciation
@app.middleware("http")
async def deprecation_header(request: Request, call_next):
response = await call_next(request)
if request.url.path.startswith("/api/v1/"):
# Ajouter quand v2 sera disponible
# response.headers["Deprecation"] = "true"
# response.headers["Sunset"] = "2027-02-01"
pass
return response
```
### 8.2 SDK Python officiel
Générer un SDK Python à partir des schémas OpenAPI du backend.
```bash
# Génération automatique depuis /openapi.json
openapi-generator-cli generate \
-i http://localhost:8000/openapi.json \
-g python \
-o ./sdk \
--package-name imago_client
```
```python
# Exemple d'utilisation du SDK par les clients
from imago_client import HubClient
client = HubClient(api_key="sk-...", base_url="https://hub.example.com")
# Upload avec gestion automatique du pipeline
result = await client.images.upload("photo.jpg")
await result.wait_until_done() # WebSocket ou polling selon dispo
print(result.ai.description)
print(result.ai.tags)
print(result.exif.camera.model)
```
> 💡 **Points d'implémentation**
> - Package installable : `pip install imago-client`
> - Fonctionnalités : upload, polling, WebSocket, gestion des erreurs, retry automatique
> - Publication : PyPI (public) ou Gitea/GitHub Packages (privé)
---
## 9. Axe 8 — Tests, qualité de code et CI/CD
### 9.1 Stratégie de tests
| Niveau | Ce qu'on teste | Couverture cible | Outils |
|---|---|:---:|---|
| **Unitaires** | Services isolés (EXIF, OCR, storage) | 90%+ | `pytest`, `unittest.mock` |
| **Intégration** | Endpoints FastAPI + BDD de test | 80%+ | `httpx.AsyncClient`, SQLite in-memory |
| **End-to-End** | Pipeline complet upload → AI | Scénarios clés | `pytest-asyncio`, mocks API Anthropic |
| **Performance** | Charge concurrente, temps de réponse | Benchmarks | `locust`, `k6` |
```python
# tests/test_api_integration.py
import pytest
from httpx import AsyncClient
from app.main import app
@pytest.mark.asyncio
async def test_upload_and_pipeline():
async with AsyncClient(app=app, base_url="http://test") as client:
# Upload avec API Key de test
headers = {"Authorization": "Bearer test-key-123"}
with open("tests/fixtures/photo.jpg", "rb") as f:
response = await client.post(
"/api/v1/images/upload",
files={"file": f},
headers=headers
)
assert response.status_code == 201
data = response.json()
assert data["status"] == "pending"
# Vérifier le statut après pipeline (mocké)
status = await client.get(f"/api/v1/images/{data['id']}/status", headers=headers)
assert status.json()["status"] in ("processing", "done")
```
### 9.2 Qualité de code
| Outil | Rôle | Configuration |
|---|---|---|
| `ruff` | Linter ultra-rapide (remplace flake8 + isort) | `ruff check . --fix` |
| `black` | Formatage automatique | `black . --line-length 100` |
| `mypy` | Vérification des types statiques | `mypy app/ --strict` |
| `bandit` | Analyse de sécurité du code Python | `bandit -r app/` |
| `pre-commit` | Exécute tous les checks avant chaque commit | `.pre-commit-config.yaml` |
```yaml
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.0
hooks:
- id: ruff
args: [--fix]
- repo: https://github.com/psf/black
rev: 24.2.0
hooks:
- id: black
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.8.0
hooks:
- id: mypy
args: [--strict]
```
### 9.3 Pipeline CI/CD
```yaml
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -r requirements-dev.txt
- run: ruff check .
- run: black --check .
- run: mypy app/ --strict
- run: bandit -r app/ -ll
tests:
runs-on: ubuntu-latest
services:
redis:
image: redis:7
ports: ["6379:6379"]
steps:
- uses: actions/checkout@v4
- run: pip install -r requirements.txt -r requirements-dev.txt
- run: pytest tests/ -v --cov=app --cov-report=xml
- uses: codecov/codecov-action@v4
docker:
needs: [quality, tests]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t imago:latest .
- run: docker push registry.example.com/imago:latest
```
> 💡 **Étapes du pipeline**
> - **Sur chaque Pull Request** : ruff + mypy + bandit + tests unitaires + tests d'intégration
> - **Sur merge main** : build image Docker + push registry + déploiement staging automatique
> - **Sur tag release** : déploiement production avec approbation manuelle
> - **Rapports** : couverture de tests publiée sur chaque PR, alertes Slack si échec
---
## 10. Feuille de route proposée
Les 8 axes ont été organisés en trois phases. Chaque phase est indépendante et livre de la valeur. Les phases ne se bloquent pas mutuellement, certains travaux peuvent commencer en parallèle.
### Phase 1 — Fondations sécurité (Semaines 13)
| # | Livrable | Axe | Effort | Priorité |
|:---:|---|:---:|:---:|:---:|
| 1.1 | Authentification API Keys + JWT avec scopes | Axe 1 | 35 j | 🔴 Critique |
| 1.2 | Modèle clients + isolation des données | Axe 2 | 57 j | 🔴 Critique |
| 1.3 | Rate limiting par client et par endpoint | Axe 1 | 12 j | 🟠 Haute |
| 1.4 | Tests d'intégration auth + multi-tenants | Axe 8 | 23 j | 🟠 Haute |
**Résultat de la phase 1 :** hub sécurisé, données isolées par client, déployable en production.
### Phase 2 — Robustesse et scalabilité (Semaines 46)
| # | Livrable | Axe | Effort | Priorité |
|:---:|---|:---:|:---:|:---:|
| 2.1 | Migration BackgroundTasks → ARQ + Redis | Axe 3 | 45 j | 🟠 Haute |
| 2.2 | Abstraction StorageBackend + support MinIO/S3 | Axe 4 | 34 j | 🟠 Haute |
| 2.3 | URLs signées pour accès aux fichiers | Axe 4 | 12 j | 🟠 Haute |
| 2.4 | Logs structurés (structlog) + métriques Prometheus | Axe 6 | 34 j | 🟡 Moyenne |
| 2.5 | CI/CD complet avec GitHub Actions | Axe 8 | 23 j | 🟠 Haute |
**Résultat de la phase 2 :** pipeline robuste avec retry, stockage abstrait, observabilité opérationnelle.
### Phase 3 — Expérience développeur (Semaines 710)
| # | Livrable | Axe | Effort | Priorité |
|:---:|---|:---:|:---:|:---:|
| 3.1 | WebSocket pipeline temps réel | Axe 5 | 23 j | 🟡 Moyenne |
| 3.2 | API versioning `/api/v1/` + politique de dépréciation | Axe 7 | 12 j | 🟡 Moyenne |
| 3.3 | SDK Python généré + publié sur PyPI | Axe 7 | 34 j | 🟡 Moyenne |
| 3.4 | Dashboard admin (quotas, métriques, clients) | Axe 6 | 45 j | 🟢 Faible |
| 3.5 | Intégration Shaarli complète + documentation | Axe 7 | 23 j | 🟠 Haute |
**Résultat de la phase 3 :** hub avec SDK, WebSockets et intégration Shaarli finalisée.
---
## 11. Stack technique finale recommandée
La stack suivante est une **évolution directe** de la stack actuelle. Chaque ajout répond à un besoin identifié dans ce rapport. Rien n'est ajouté par effet de mode.
| Couche | Librairie | Justification |
|---|---|---|
| **Web Framework** | FastAPI + Uvicorn | Déjà en place. Performance async excellente. |
| **Base de données** | SQLAlchemy async + Alembic | Déjà en place. Migrations propres. |
| **File de tâches** | ARQ + Redis | Pipeline robuste, retry, persistance, priorités. |
| **Cache** | Redis (partagé avec ARQ) | Cache résultats AI, sessions WebSocket. |
| **Authentification** | python-jose + passlib | JWT + hachage des API Keys. |
| **Stockage fichiers** | Local → MinIO → S3/R2 | Interface abstraite. Migration transparente. |
| **Traitement images** | Pillow + piexif | Déjà en place. |
| **OCR** | pytesseract + Tesseract | Déjà en place. |
| **Vision AI** | Anthropic Claude (httpx) | Déjà en place. Abstraction multi-provider future. |
| **Logs** | structlog | JSON structuré, compatible Loki/Datadog. |
| **Métriques** | prometheus-fastapi-instrumentator | Exposition automatique des métriques HTTP. |
| **Qualité code** | ruff + black + mypy + bandit | Lint, format, types, sécurité. |
| **Tests** | pytest-asyncio + httpx | Tests unitaires et d'intégration async. |
| **CI/CD** | GitHub Actions / Gitea CI | Automatisation complète du cycle de vie. |
### requirements.txt mis à jour
```
# Existant
fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy[asyncio]==2.0.35
alembic==1.13.3
pydantic-settings==2.5.2
pillow==10.4.0
piexif==1.1.3
pytesseract==0.3.13
anthropic==0.34.2
httpx==0.27.2
beautifulsoup4==4.12.3
aiofiles==24.1.0
# Nouveaux — Phase 1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
slowapi==0.1.9 # rate limiting
# Nouveaux — Phase 2
arq==0.25.0 # file de tâches async
redis==5.0.8 # client Redis
aioboto3==13.0.0 # S3/MinIO async
structlog==24.4.0 # logs structurés
prometheus-fastapi-instrumentator==0.14.0
# Nouveaux — Phase 3
websockets==13.0 # WebSocket (déjà dans FastAPI)
```
---
## 12. Conclusion
Le backend Imago dispose d'une **base technique solide**. L'architecture FastAPI + SQLAlchemy async est bien choisie et évolutive. Les services EXIF, OCR et Vision AI sont fonctionnels et bien découpés.
La **priorité absolue** est la sécurisation avant tout passage en production multi-clients. L'authentification et l'isolation des données par client (Phase 1) sont des prérequis non négociables. Ces deux axes représentent environ 10 jours de développement.
Une fois la sécurité en place, la Phase 2 transforme le backend en un service de production véritable avec un pipeline robuste, un stockage abstrait et un monitoring opérationnel. La Phase 3 améliore l'expérience des équipes qui intègrent le hub.
> ✅ **Résumé des horizons**
> - **10 jours** : Phase 1 complète — hub sécurisé et multi-clients opérationnel
> - **20 jours** : Phase 2 complète — hub production-ready, robuste et observable
> - **30 jours** : Phase 3 complète — hub avec SDK, WebSockets et intégration Shaarli finalisée
---
*Imago — Rapport d'amélioration v1.0.0 — Février 2026*

66
docker-compose.yml Normal file
View File

@ -0,0 +1,66 @@
version: "3.9"
services:
backend:
build: .
ports:
- "8000:8000"
volumes:
- ./data:/app/data
env_file:
- .env
environment:
- DATABASE_URL=sqlite+aiosqlite:///./data/imago.db
depends_on:
- redis
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
worker:
build: .
command: python worker.py
volumes:
- ./data:/app/data
env_file:
- .env
environment:
- DATABASE_URL=sqlite+aiosqlite:///./data/imago.db
depends_on:
- backend
- redis
restart: unless-stopped
minio:
image: minio/minio
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
restart: unless-stopped
volumes:
redis_data:
minio_data:

23
list_models.py Normal file
View File

@ -0,0 +1,23 @@
import os
import sys
from google import genai
from dotenv import load_dotenv
# Load env from the project root
load_dotenv(r"c:\dev\git\python\imago\imago\.env")
api_key = os.getenv("GEMINI_API_KEY")
if not api_key:
print("No API Key found in .env")
sys.exit(1)
try:
client = genai.Client(api_key=api_key)
print("Listing models...")
# The SDK might have a different way to list models, checking typical pattern
# For google-genai v1.0.0
for m in client.models.list():
if "gemini" in m.name:
print(f"Found model: {m.name}")
except Exception as e:
print(f"Error listing models: {e}")

70
pyproject.toml Normal file
View File

@ -0,0 +1,70 @@
[project]
name = "imago"
version = "2.0.0"
description = "Backend FastAPI pour la gestion d'images et fonctionnalités AI — Imago"
requires-python = ">=3.12"
license = "MIT"
[tool.ruff]
target-version = "py312"
line-length = 120
src = ["app", "tests"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"N", # pep8-naming
"UP", # pyupgrade
"B", # bugbear
"S", # bandit (security)
"T20", # flake8-print (catch stray print())
"SIM", # flake8-simplify
"RUF", # ruff-specific
]
ignore = [
"S101", # allow assert in tests
"S104", # allow 0.0.0.0 bindings
"B008", # allow Depends() in function args (FastAPI pattern)
"E501", # line length handled by formatter
"UP007", # keep Optional[] syntax for readability
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101", "S106", "T20"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
docstring-code-format = true
[tool.mypy]
python_version = "3.12"
warn_return_any = true
warn_unused_configs = true
ignore_missing_imports = true
check_untyped_defs = true
plugins = ["pydantic.mypy"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
filterwarnings = ["ignore::DeprecationWarning"]
addopts = "--tb=short -q"
[tool.coverage.run]
source = ["app"]
omit = ["app/workers/*", "tests/*"]
[tool.coverage.report]
show_missing = true
fail_under = 60
exclude_lines = [
"pragma: no cover",
"if __name__ ==",
"if TYPE_CHECKING:",
]

8
pytest.ini Normal file
View File

@ -0,0 +1,8 @@
[pytest]
asyncio_mode = auto
testpaths = tests
python_files = test_*.py
python_functions = test_*
filterwarnings =
ignore::DeprecationWarning

17
requirements-dev.txt Normal file
View File

@ -0,0 +1,17 @@
# Dev/Test dependencies (install via: pip install -r requirements-dev.txt)
-r requirements.txt
# Testing
pytest==8.3.3
pytest-asyncio==0.24.0
pytest-cov==5.0.0
# Linting / Formatting
ruff==0.8.6
mypy==1.13.0
# Pre-commit hooks
pre-commit==4.0.1
# Type stubs
types-aiofiles==24.1.0.20240626

53
requirements.txt Normal file
View File

@ -0,0 +1,53 @@
# Web Framework
fastapi==0.115.0
uvicorn[standard]==0.30.6
python-multipart==0.0.9
# Database
sqlalchemy==2.0.35
alembic==1.13.3
aiosqlite==0.20.0
# Validation
pydantic==2.9.2; python_version < "3.14"
pydantic>=2.12.0; python_version >= "3.14"
pydantic-settings==2.5.2
# Image Processing
Pillow==10.4.0; python_version < "3.14"
Pillow>=11.0.0; python_version >= "3.14"
piexif==1.1.3
# OCR
pytesseract==0.3.13
# AI
google-genai==1.0.0
httpx==0.27.2
# Web scraping (pour les URLs)
beautifulsoup4==4.12.3
# Task scheduling
apscheduler==3.10.4
# Task queue (ARQ + Redis)
arq==0.25.0
redis==5.0.8
# Storage abstraction (S3/MinIO)
aioboto3==13.0.0
itsdangerous==2.2.0
# Observability
structlog==24.4.0
prometheus-fastapi-instrumentator==7.0.2
# Utilities
python-dotenv==1.0.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
aiofiles==24.1.0
# Rate Limiting
slowapi==0.1.9

14
run.py Normal file
View File

@ -0,0 +1,14 @@
"""
Point d'entrée — lancement du serveur Uvicorn
"""
import uvicorn
from app.config import settings
if __name__ == "__main__":
uvicorn.run(
"app.main:app",
host=settings.HOST,
port=settings.PORT,
reload=settings.DEBUG,
log_level="debug" if settings.DEBUG else "info",
)

95
test_coverage.txt Normal file
View File

@ -0,0 +1,95 @@
============================= test session starts =============================
platform win32 -- Python 3.14.3, pytest-9.0.1, pluggy-1.6.0 -- C:\Users\bruno\scoop\apps\python\current\python.exe
cachedir: .pytest_cache
rootdir: C:\dev\git\python\imago\imago
configfile: pytest.ini
plugins: anyio-4.12.0, asyncio-1.3.0, cov-7.0.0, mock-3.15.1, respx-0.22.0
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... collected 29 items
tests/test_auth.py::test_no_auth_returns_401 PASSED [ 3%]
tests/test_auth.py::test_invalid_key_returns_401 PASSED [ 6%]
tests/test_auth.py::test_no_bearer_prefix_returns_401 PASSED [ 10%]
tests/test_auth.py::test_empty_bearer_returns_401 PASSED [ 13%]
tests/test_auth.py::test_valid_key_returns_200 PASSED [ 17%]
tests/test_auth.py::test_inactive_client_returns_401 PASSED [ 20%]
tests/test_auth.py::test_missing_scope_returns_403 PASSED [ 24%]
tests/test_auth.py::test_scope_images_read_allowed PASSED [ 27%]
tests/test_auth.py::test_key_rotation PASSED [ 31%]
tests/test_auth.py::test_create_client_returns_key_once PASSED [ 34%]
tests/test_auth.py::test_list_clients_admin_only PASSED [ 37%]
tests/test_auth.py::test_update_client PASSED [ 41%]
tests/test_auth.py::test_soft_delete_client PASSED [ 44%]
tests/test_isolation.py::test_client_a_image_invisible_to_client_b PASSED [ 48%]
tests/test_isolation.py::test_client_b_cannot_read_client_a_image PASSED [ 51%]
tests/test_isolation.py::test_client_b_cannot_delete_client_a_image PASSED [ 55%]
tests/test_isolation.py::test_listing_returns_only_own_images PASSED [ 58%]
tests/test_isolation.py::test_reprocess_other_client_image_returns_404 PASSED [ 62%]
tests/test_isolation.py::test_sub_endpoints_other_client_returns_404 PASSED [ 65%]
tests/test_rate_limit.py::test_rate_limit_headers_present PASSED [ 68%]
tests/test_rate_limit.py::test_rate_limit_per_client_independent PASSED [ 72%]
tests/test_rate_limit.py::test_rate_limiter_is_configured PASSED [ 75%]
tests/test_services.py::test_exif_missing_file PASSED [ 79%]
tests/test_services.py::test_exif_dms_to_decimal PASSED [ 82%]
tests/test_services.py::test_ocr_disabled PASSED [ 86%]
tests/test_services.py::test_ocr_language_detection PASSED [ 89%]
tests/test_services.py::test_generate_filename PASSED [ 93%]
tests/test_services.py::test_generate_filename_no_extension PASSED [ 96%]
tests/test_services.py::test_image_detail_schema PASSED [100%]
============================== warnings summary ===============================
..\..\..\..\..\Users\bruno\scoop\apps\python\current\Lib\site-packages\google\genai\types.py:32
C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\google\genai\types.py:32: DeprecationWarning: '_UnionGenericAlias' is deprecated and slated for removal in Python 3.17
VersionedUnionType = Union[typing.types.UnionType, typing._UnionGenericAlias]
..\..\..\..\..\Users\bruno\scoop\apps\python\current\Lib\site-packages\slowapi\extension.py:717
..\..\..\..\..\Users\bruno\scoop\apps\python\current\Lib\site-packages\slowapi\extension.py:717
..\..\..\..\..\Users\bruno\scoop\apps\python\current\Lib\site-packages\slowapi\extension.py:717
..\..\..\..\..\Users\bruno\scoop\apps\python\current\Lib\site-packages\slowapi\extension.py:717
C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\slowapi\extension.py:717: DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16; use inspect.iscoroutinefunction() instead
if asyncio.iscoroutinefunction(func):
..\..\..\..\..\Users\bruno\scoop\apps\python\current\Lib\site-packages\fastapi\routing.py:233: 38 warnings
C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\fastapi\routing.py:233: DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16; use inspect.iscoroutinefunction() instead
is_coroutine = asyncio.iscoroutinefunction(dependant.call)
..\..\..\..\..\Users\bruno\scoop\apps\python\current\Lib\site-packages\starlette\_utils.py:40: 42 warnings
tests/test_auth.py: 9 warnings
tests/test_isolation.py: 15 warnings
tests/test_rate_limit.py: 3 warnings
C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\starlette\_utils.py:40: DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16; use inspect.iscoroutinefunction() instead
return asyncio.iscoroutinefunction(obj) or (callable(obj) and asyncio.iscoroutinefunction(obj.__call__))
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=============================== tests coverage ================================
_______________ coverage: platform win32, python 3.14.3-final-0 _______________
Name Stmts Miss Cover Missing
------------------------------------------------------------
app\__init__.py 0 0 100%
app\config.py 66 4 94% 78-81
app\database.py 21 12 43% 30-38, 43-46
app\dependencies\__init__.py 2 0 100%
app\dependencies\auth.py 37 0 100%
app\main.py 39 14 64% 24-39, 112, 122-128
app\middleware\__init__.py 22 7 68% 31, 40-45, 50-55, 60, 65
app\middleware\rate_limit.py 2 2 0% 1-3
app\models\__init__.py 3 0 100%
app\models\client.py 25 1 96% 60
app\models\image.py 65 4 94% 87, 95-97
app\routers\__init__.py 4 0 100%
app\routers\ai.py 31 16 48% 38-65, 90-102
app\routers\auth.py 68 4 94% 109, 131, 162, 191
app\routers\images.py 95 23 76% 60, 126, 129, 132, 214, 240, 285, 310, 340-354, 379-387, 408-413
app\schemas\__init__.py 77 0 100%
app\schemas\auth.py 22 0 100%
app\services\__init__.py 2 0 100%
app\services\ai_vision.py 172 149 13% 23-25, 30-45, 49-56, 60-65, 75-100, 110-169, 179-186, 190-199, 224-265, 272-323, 328-368, 373-413
app\services\exif_service.py 109 86 21% 22-23, 28-36, 41-45, 77-169
app\services\ocr_service.py 51 31 39% 11-13, 31, 50-104
app\services\pipeline.py 94 84 11% 17-18, 32-154
app\services\scraper.py 31 26 16% 23-70
app\services\storage.py 58 10 83% 53, 63, 89, 113-117, 122-123
------------------------------------------------------------
TOTAL 1096 473 57%
====================== 29 passed, 112 warnings in 2.18s =======================

11
test_imports.py Normal file
View File

@ -0,0 +1,11 @@
try:
from PIL import Image
print("PIL imported successfully")
except Exception as e:
print(f"PIL import failed: {e}")
try:
import pytesseract
print("pytesseract imported successfully")
except Exception as e:
print(f"pytesseract import failed: {e}")

17
test_ocr.py Normal file
View File

@ -0,0 +1,17 @@
import sys
import os
print(f"CWD: {os.getcwd()}")
print(f"Sys Path: {sys.path}")
try:
import pytesseract
print(f"Pytesseract file: {pytesseract.__file__}")
except Exception as e:
print(f"Error importing pytesseract: {e}")
try:
import numpy
print(f"Numpy file: {numpy.__file__}")
except Exception as e:
print(f"Error importing numpy: {e}")

57
test_output.txt Normal file
View File

@ -0,0 +1,57 @@
============================= test session starts =============================
platform win32 -- Python 3.14.3, pytest-9.0.1, pluggy-1.6.0 -- C:\Users\bruno\scoop\apps\python\current\python.exe
cachedir: .pytest_cache
rootdir: C:\dev\git\python\imago\imago
configfile: pytest.ini
plugins: anyio-4.12.0, asyncio-1.3.0, cov-7.0.0, mock-3.15.1, respx-0.22.0
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... collected 22 items
tests/test_auth.py::test_no_auth_returns_401 PASSED [ 4%]
tests/test_auth.py::test_invalid_key_returns_401 PASSED [ 9%]
tests/test_auth.py::test_no_bearer_prefix_returns_401 PASSED [ 13%]
tests/test_auth.py::test_empty_bearer_returns_401 PASSED [ 18%]
tests/test_auth.py::test_valid_key_returns_200 PASSED [ 22%]
tests/test_auth.py::test_inactive_client_returns_401 PASSED [ 27%]
tests/test_auth.py::test_missing_scope_returns_403 PASSED [ 31%]
tests/test_auth.py::test_scope_images_read_allowed PASSED [ 36%]
tests/test_auth.py::test_key_rotation PASSED [ 40%]
tests/test_auth.py::test_create_client_returns_key_once PASSED [ 45%]
tests/test_auth.py::test_list_clients_admin_only PASSED [ 50%]
tests/test_auth.py::test_update_client PASSED [ 54%]
tests/test_auth.py::test_soft_delete_client PASSED [ 59%]
tests/test_isolation.py::test_client_a_image_invisible_to_client_b PASSED [ 63%]
tests/test_isolation.py::test_client_b_cannot_read_client_a_image PASSED [ 68%]
tests/test_isolation.py::test_client_b_cannot_delete_client_a_image PASSED [ 72%]
tests/test_isolation.py::test_listing_returns_only_own_images PASSED [ 77%]
tests/test_isolation.py::test_reprocess_other_client_image_returns_404 PASSED [ 81%]
tests/test_isolation.py::test_sub_endpoints_other_client_returns_404 PASSED [ 86%]
tests/test_rate_limit.py::test_rate_limit_headers_present PASSED [ 90%]
tests/test_rate_limit.py::test_rate_limit_per_client_independent PASSED [ 95%]
tests/test_rate_limit.py::test_rate_limiter_is_configured PASSED [100%]
============================== warnings summary ===============================
..\..\..\..\..\Users\bruno\scoop\apps\python\current\Lib\site-packages\google\genai\types.py:32
C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\google\genai\types.py:32: DeprecationWarning: '_UnionGenericAlias' is deprecated and slated for removal in Python 3.17
VersionedUnionType = Union[typing.types.UnionType, typing._UnionGenericAlias]
..\..\..\..\..\Users\bruno\scoop\apps\python\current\Lib\site-packages\slowapi\extension.py:717
..\..\..\..\..\Users\bruno\scoop\apps\python\current\Lib\site-packages\slowapi\extension.py:717
..\..\..\..\..\Users\bruno\scoop\apps\python\current\Lib\site-packages\slowapi\extension.py:717
..\..\..\..\..\Users\bruno\scoop\apps\python\current\Lib\site-packages\slowapi\extension.py:717
C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\slowapi\extension.py:717: DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16; use inspect.iscoroutinefunction() instead
if asyncio.iscoroutinefunction(func):
..\..\..\..\..\Users\bruno\scoop\apps\python\current\Lib\site-packages\fastapi\routing.py:233: 38 warnings
C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\fastapi\routing.py:233: DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16; use inspect.iscoroutinefunction() instead
is_coroutine = asyncio.iscoroutinefunction(dependant.call)
..\..\..\..\..\Users\bruno\scoop\apps\python\current\Lib\site-packages\starlette\_utils.py:40: 42 warnings
tests/test_auth.py: 9 warnings
tests/test_isolation.py: 15 warnings
tests/test_rate_limit.py: 3 warnings
C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\starlette\_utils.py:40: DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16; use inspect.iscoroutinefunction() instead
return asyncio.iscoroutinefunction(obj) or (callable(obj) and asyncio.iscoroutinefunction(obj.__call__))
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
====================== 22 passed, 112 warnings in 1.60s =======================

0
tests/__init__.py Normal file
View File

179
tests/conftest.py Normal file
View File

@ -0,0 +1,179 @@
"""
Fixtures pytest base de données de test + clients API.
"""
import secrets
import asyncio
from typing import AsyncGenerator
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from app.database import Base, get_db
from app.dependencies.auth import hash_api_key
from app.models.client import APIClient, ClientPlan
from app.main import app
# ─────────────────────────────────────────────────────────────
# Engine de test (SQLite in-memory)
# ─────────────────────────────────────────────────────────────
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
test_engine = create_async_engine(TEST_DATABASE_URL, echo=False)
TestSessionLocal = async_sessionmaker(
bind=test_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
autocommit=False,
)
# ─────────────────────────────────────────────────────────────
# Override de la dépendance get_db
# ─────────────────────────────────────────────────────────────
async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
async with TestSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
app.dependency_overrides[get_db] = override_get_db
# ─────────────────────────────────────────────────────────────
# Mock ARQ pool et Redis pour les tests (pas de Redis requis)
# ─────────────────────────────────────────────────────────────
from unittest.mock import AsyncMock
_mock_arq_pool = AsyncMock()
_mock_arq_pool.enqueue_job = AsyncMock(return_value=None)
_mock_arq_pool.close = AsyncMock()
_mock_redis = AsyncMock()
_mock_redis.ping = AsyncMock(return_value=True)
_mock_redis.publish = AsyncMock(return_value=0)
app.state.arq_pool = _mock_arq_pool
app.state.redis = _mock_redis
# ─────────────────────────────────────────────────────────────
# Fixtures
# ─────────────────────────────────────────────────────────────
@pytest_asyncio.fixture(autouse=True)
async def setup_database():
"""Crée et détruit toutes les tables avant/après chaque test."""
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest_asyncio.fixture
async def db_session() -> AsyncGenerator[AsyncSession, None]:
"""Session DB de test."""
async with TestSessionLocal() as session:
yield session
@pytest_asyncio.fixture
async def client_a_key() -> str:
"""Clé API en clair pour le client A."""
return "test-key-client-a-secret-123456"
@pytest_asyncio.fixture
async def client_b_key() -> str:
"""Clé API en clair pour le client B."""
return "test-key-client-b-secret-789012"
@pytest_asyncio.fixture
async def admin_key() -> str:
"""Clé API en clair pour le client admin."""
return "test-key-admin-secret-000000"
@pytest_asyncio.fixture
async def client_a(db_session: AsyncSession, client_a_key: str) -> APIClient:
"""Client A — droits standard (images + AI)."""
client = APIClient(
name="Test Client A",
api_key_hash=hash_api_key(client_a_key),
scopes=["images:read", "images:write", "images:delete", "ai:use"],
plan=ClientPlan.STANDARD,
)
db_session.add(client)
await db_session.commit()
await db_session.refresh(client)
return client
@pytest_asyncio.fixture
async def client_b(db_session: AsyncSession, client_b_key: str) -> APIClient:
"""Client B — droits standard (images + AI)."""
client = APIClient(
name="Test Client B",
api_key_hash=hash_api_key(client_b_key),
scopes=["images:read", "images:write", "images:delete", "ai:use"],
plan=ClientPlan.FREE,
)
db_session.add(client)
await db_session.commit()
await db_session.refresh(client)
return client
@pytest_asyncio.fixture
async def admin_client(db_session: AsyncSession, admin_key: str) -> APIClient:
"""Client admin — tous les droits."""
client = APIClient(
name="Admin Client",
api_key_hash=hash_api_key(admin_key),
scopes=["images:read", "images:write", "images:delete", "ai:use", "admin"],
plan=ClientPlan.PREMIUM,
)
db_session.add(client)
await db_session.commit()
await db_session.refresh(client)
return client
@pytest_asyncio.fixture
async def auth_headers_a(client_a_key: str) -> dict[str, str]:
"""Headers d'authentification pour le client A."""
return {"Authorization": f"Bearer {client_a_key}"}
@pytest_asyncio.fixture
async def auth_headers_b(client_b_key: str) -> dict[str, str]:
"""Headers d'authentification pour le client B."""
return {"Authorization": f"Bearer {client_b_key}"}
@pytest_asyncio.fixture
async def admin_headers(admin_key: str) -> dict[str, str]:
"""Headers d'authentification pour le client admin."""
return {"Authorization": f"Bearer {admin_key}"}
@pytest_asyncio.fixture
async def async_client() -> AsyncGenerator[AsyncClient, None]:
"""Client HTTP async pour tester l'API FastAPI."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac

57
tests/test_ai_extended.py Normal file
View File

@ -0,0 +1,57 @@
import pytest
from httpx import AsyncClient
from unittest.mock import patch, MagicMock
@pytest.mark.asyncio
async def test_ai_summarize(async_client: AsyncClient, client_a, auth_headers_a):
mock_page = {
"title": "Example Title",
"description": "Example Description",
"text": "Example Text content for the page.",
}
mock_ai_result = {
"summary": "This is a summary.",
"tags": ["tag1", "tag2"],
"model": "gemini-1.5-flash"
}
with patch("app.routers.ai.fetch_page_content", return_value=mock_page), \
patch("app.routers.ai.summarize_url", return_value=mock_ai_result), \
patch("app.routers.ai.settings.AI_ENABLED", True):
payload = {"url": "https://example.com", "language": "français"}
response = await async_client.post("/ai/summarize", json=payload, headers=auth_headers_a)
assert response.status_code == 200
data = response.json()
assert data["summary"] == "This is a summary."
assert "tag1" in data["tags"]
assert data["title"] == "Example Title"
@pytest.mark.asyncio
async def test_ai_draft_task(async_client: AsyncClient, client_a, auth_headers_a):
mock_task = {
"title": "Fix Bug",
"description": "Fix the bug in the code",
"steps": ["Step 1", "Step 2"],
"priority": "high",
"estimated_time": "1h"
}
with patch("app.routers.ai.draft_task", return_value=mock_task), \
patch("app.routers.ai.settings.AI_ENABLED", True):
payload = {"description": "Fix bug", "context": "Backend project"}
response = await async_client.post("/ai/draft-task", json=payload, headers=auth_headers_a)
assert response.status_code == 200
data = response.json()
assert data["title"] == "Fix Bug"
assert data["priority"] == "high"
@pytest.mark.asyncio
async def test_ai_disabled(async_client: AsyncClient, client_a, auth_headers_a):
with patch("app.routers.ai.settings.AI_ENABLED", False):
response = await async_client.post("/ai/draft-task", json={"description": "test"}, headers=auth_headers_a)
assert response.status_code == 503
assert response.json()["detail"] == "AI désactivée"

View File

@ -0,0 +1,62 @@
import pytest
from unittest.mock import patch, MagicMock
from app.services.ai_vision import analyze_image, summarize_url, draft_task, _extract_json
def test_extract_json():
text = '```json {"key": "value"} ```'
assert _extract_json(text) == {"key": "value"}
text = 'Here is the result: {"a": 1}'
assert _extract_json(text) == {"a": 1}
assert _extract_json("Invalid") is None
@pytest.mark.asyncio
@patch("app.services.ai_vision._generate")
@patch("app.services.ai_vision._read_image")
@patch("app.services.ai_vision.settings")
async def test_analyze_image_success(mock_settings, mock_read_image, mock_generate):
mock_settings.AI_ENABLED = True
mock_settings.AI_PROVIDER = "gemini"
mock_settings.AI_TAGS_MIN = 5
mock_settings.AI_TAGS_MAX = 10
mock_read_image.return_value = (b"fake_bytes", "image/jpeg")
mock_generate.return_value = {
"text": '{"description": "A test image", "tags": ["tag1", "tag2"], "confidence": 0.9}',
"usage": (10, 20)
}
result = await analyze_image("fake/path.jpg")
assert result["description"] == "A test image"
assert "tag1" in result["tags"]
assert result["prompt_tokens"] == 10
@pytest.mark.asyncio
@patch("app.services.ai_vision._generate")
@patch("app.services.ai_vision.settings")
async def test_summarize_url_success(mock_settings, mock_generate):
mock_settings.AI_ENABLED = True
mock_generate.return_value = {
"text": '{"summary": "A summary", "tags": ["s1", "s2"]}',
"usage": (5, 5)
}
result = await summarize_url("http://url", "content")
assert result["summary"] == "A summary"
assert "s1" in result["tags"]
@pytest.mark.asyncio
@patch("app.services.ai_vision._generate")
@patch("app.services.ai_vision.settings")
async def test_draft_task_success(mock_settings, mock_generate):
mock_settings.AI_ENABLED = True
mock_generate.return_value = {
"text": '{"title": "Task Title", "description": "Task Desc", "steps": ["S1"]}',
"usage": (5, 5)
}
result = await draft_task("Fix bug", "Context")
assert result["title"] == "Task Title"
assert "S1" in result["steps"]

View File

@ -0,0 +1,94 @@
import pytest
import os
import base64
from pathlib import Path
from unittest.mock import patch, MagicMock
from app.services.ai_vision import (
_read_image, _extract_json, _usage_tokens_gemini,
_generate_gemini, _generate_openrouter, _generate,
extract_text_with_ai, analyze_image
)
from app.config import settings
def test_read_image_variants(tmp_path):
img_file = tmp_path / "test.png"
img_file.write_bytes(b"fake_png_data")
data, mime = _read_image(str(img_file))
assert data == b"fake_png_data"
assert mime == "image/png"
def test_extract_json_bad_cases():
assert _extract_json(None) is None
assert _extract_json("No JSON here") is None
assert _extract_json("Broken { json : 1") is None
def test_usage_tokens_gemini_none():
assert _usage_tokens_gemini(None) == (None, None)
mock_resp = MagicMock()
mock_resp.usage_metadata = None
assert _usage_tokens_gemini(mock_resp) == (None, None)
@pytest.mark.asyncio
@patch("app.services.ai_vision._get_client")
@patch("app.services.ai_vision.settings")
async def test_generate_gemini_no_key(mock_settings, mock_get_client):
mock_settings.GEMINI_API_KEY = None
res = await _generate_gemini("test prompt")
assert res["text"] is None
@pytest.mark.asyncio
@patch("httpx.AsyncClient.post")
@patch("app.services.ai_vision.settings")
async def test_generate_openrouter_success(mock_settings, mock_post):
mock_settings.OPENROUTER_API_KEY = "test_key"
mock_settings.OPENROUTER_MODEL = "test_model"
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"choices": [{"message": {"content": "openrouter test response"}}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5}
}
mock_post.return_value = mock_response
res = await _generate_openrouter("test prompt")
assert res["text"] == "openrouter test response"
assert res["usage"] == (10, 5)
@pytest.mark.asyncio
@patch("app.services.ai_vision._generate_openrouter")
@patch("app.services.ai_vision.settings")
async def test_generate_dispatcher_openrouter(mock_settings, mock_gen_or):
mock_settings.AI_PROVIDER = "openrouter"
await _generate("test")
mock_gen_or.assert_called_once()
@pytest.mark.asyncio
@patch("app.services.ai_vision._generate")
@patch("app.services.ai_vision._read_image")
@patch("app.services.ai_vision.settings")
async def test_extract_text_with_ai(mock_settings, mock_read_image, mock_generate):
mock_settings.AI_ENABLED = True
mock_settings.AI_PROVIDER = "gemini"
mock_read_image.return_value = (b"fake", "image/jpeg")
mock_generate.return_value = {
"text": '{"text": "Extracted text", "language": "en", "confidence": 0.9}'
}
res = await extract_text_with_ai("fake/path.jpg")
assert res["text"] == "Extracted text"
assert res["language"] == "en"
@pytest.mark.asyncio
@patch("app.services.ai_vision._generate")
@patch("app.services.ai_vision._read_image")
@patch("app.services.ai_vision.settings")
async def test_analyze_image_failure(mock_settings, mock_read_image, mock_generate):
mock_settings.AI_ENABLED = True
mock_read_image.return_value = (b"fake", "image/jpeg")
mock_generate.return_value = {"text": "Invalid JSON", "usage": (None, None)}
res = await analyze_image("fake/path.jpg")
assert res["description"] is None

254
tests/test_auth.py Normal file
View File

@ -0,0 +1,254 @@
"""
Tests d'authentification — API Keys + scopes.
"""
import pytest
import pytest_asyncio
from httpx import AsyncClient
from app.dependencies.auth import hash_api_key
from app.models.client import APIClient
pytestmark = pytest.mark.asyncio
# ─────────────────────────────────────────────────────────────
# 401 — Pas de clé / clé invalide
# ─────────────────────────────────────────────────────────────
async def test_no_auth_returns_401(async_client: AsyncClient):
"""Requête sans header Authorization → HTTP 401."""
response = await async_client.get("/images")
assert response.status_code == 422 or response.status_code == 401
async def test_invalid_key_returns_401(async_client: AsyncClient):
"""Requête avec une clé invalide → HTTP 401."""
response = await async_client.get(
"/images",
headers={"Authorization": "Bearer invalid-key-that-does-not-exist"},
)
assert response.status_code == 401
assert "Authentification requise" in response.json()["detail"]
async def test_no_bearer_prefix_returns_401(async_client: AsyncClient):
"""Requête avec un header Authorization sans 'Bearer ' → HTTP 401."""
response = await async_client.get(
"/images",
headers={"Authorization": "Basic some-key"},
)
assert response.status_code == 401
async def test_empty_bearer_returns_401(async_client: AsyncClient):
"""Requête avec 'Bearer ' mais sans clé → HTTP 401."""
response = await async_client.get(
"/images",
headers={"Authorization": "Bearer "},
)
assert response.status_code == 401
# ─────────────────────────────────────────────────────────────
# 200 — Clé valide
# ─────────────────────────────────────────────────────────────
async def test_valid_key_returns_200(
async_client: AsyncClient,
client_a: APIClient,
auth_headers_a: dict,
):
"""Requête avec une clé valide → HTTP 200."""
response = await async_client.get("/images", headers=auth_headers_a)
assert response.status_code == 200
# ─────────────────────────────────────────────────────────────
# 401 — Client inactif
# ─────────────────────────────────────────────────────────────
async def test_inactive_client_returns_401(
async_client: AsyncClient,
client_a: APIClient,
auth_headers_a: dict,
db_session,
):
"""Client désactivé → HTTP 401 même avec une clé valide."""
# Désactiver le client
client_a.is_active = False
db_session.add(client_a)
await db_session.commit()
response = await async_client.get("/images", headers=auth_headers_a)
assert response.status_code == 401
# ─────────────────────────────────────────────────────────────
# 403 — Scope manquant
# ─────────────────────────────────────────────────────────────
async def test_missing_scope_returns_403(
async_client: AsyncClient,
db_session,
):
"""Client sans le bon scope → HTTP 403."""
# Créer un client avec seulement images:read (pas images:write)
key = "test-readonly-key-123"
client = APIClient(
name="Read Only Client",
api_key_hash=hash_api_key(key),
scopes=["images:read"], # pas images:write ni admin
plan="free",
)
db_session.add(client)
await db_session.commit()
# Tenter d'accéder aux endpoints admin
response = await async_client.get(
"/auth/clients",
headers={"Authorization": f"Bearer {key}"},
)
assert response.status_code == 403
assert "Permission insuffisante" in response.json()["detail"]
async def test_scope_images_read_allowed(
async_client: AsyncClient,
client_a: APIClient,
auth_headers_a: dict,
):
"""Client avec scope images:read peut lister les images."""
response = await async_client.get("/images", headers=auth_headers_a)
assert response.status_code == 200
# ─────────────────────────────────────────────────────────────
# Rotation de clé
# ─────────────────────────────────────────────────────────────
async def test_key_rotation(
async_client: AsyncClient,
admin_client: APIClient,
admin_headers: dict,
client_a: APIClient,
auth_headers_a: dict,
):
"""Après rotation, l'ancienne clé est invalide et la nouvelle fonctionne."""
# Vérifier que la clé actuelle fonctionne
response = await async_client.get("/images", headers=auth_headers_a)
assert response.status_code == 200
# Rotation de la clé
response = await async_client.post(
f"/auth/clients/{client_a.id}/rotate-key",
headers=admin_headers,
)
assert response.status_code == 200
new_key = response.json()["api_key"]
assert new_key # La nouvelle clé est retournée
# L'ancienne clé ne fonctionne plus
response = await async_client.get("/images", headers=auth_headers_a)
assert response.status_code == 401
# La nouvelle clé fonctionne
response = await async_client.get(
"/images",
headers={"Authorization": f"Bearer {new_key}"},
)
assert response.status_code == 200
# ─────────────────────────────────────────────────────────────
# Création de client
# ─────────────────────────────────────────────────────────────
async def test_create_client_returns_key_once(
async_client: AsyncClient,
admin_client: APIClient,
admin_headers: dict,
):
"""La clé API est retournée une seule fois à la création."""
response = await async_client.post(
"/auth/clients",
json={
"name": "New Test Client",
"scopes": ["images:read"],
"plan": "free",
},
headers=admin_headers,
)
assert response.status_code == 201
data = response.json()
assert "api_key" in data
assert len(data["api_key"]) > 20 # clé suffisamment longue
assert data["name"] == "New Test Client"
assert data["scopes"] == ["images:read"]
# La clé n'apparaît pas dans les réponses GET
client_id = data["id"]
response = await async_client.get(
f"/auth/clients/{client_id}",
headers=admin_headers,
)
assert response.status_code == 200
assert "api_key" not in response.json()
# ─────────────────────────────────────────────────────────────
# CRUD clients — admin only
# ─────────────────────────────────────────────────────────────
async def test_list_clients_admin_only(
async_client: AsyncClient,
client_a: APIClient,
auth_headers_a: dict,
admin_client: APIClient,
admin_headers: dict,
):
"""Seul un admin peut lister les clients."""
# Non-admin → 403
response = await async_client.get("/auth/clients", headers=auth_headers_a)
assert response.status_code == 403
# Admin → 200
response = await async_client.get("/auth/clients", headers=admin_headers)
assert response.status_code == 200
async def test_update_client(
async_client: AsyncClient,
admin_client: APIClient,
admin_headers: dict,
client_a: APIClient,
):
"""L'admin peut modifier les scopes et le plan d'un client."""
response = await async_client.patch(
f"/auth/clients/{client_a.id}",
json={"plan": "premium", "scopes": ["images:read"]},
headers=admin_headers,
)
assert response.status_code == 200
assert response.json()["plan"] == "premium"
assert response.json()["scopes"] == ["images:read"]
async def test_soft_delete_client(
async_client: AsyncClient,
admin_client: APIClient,
admin_headers: dict,
client_a: APIClient,
auth_headers_a: dict,
):
"""Soft delete désactive le client — les requêtes suivantes retournent 401."""
response = await async_client.delete(
f"/auth/clients/{client_a.id}",
headers=admin_headers,
)
assert response.status_code == 200
assert response.json()["is_active"] is False
# Le client désactivé ne peut plus s'authentifier
response = await async_client.get("/images", headers=auth_headers_a)
assert response.status_code == 401

View File

@ -0,0 +1,45 @@
import pytest
import hashlib
from sqlalchemy import select
from app.database import init_db
from app.models.client import APIClient
from unittest.mock import patch, MagicMock
@pytest.mark.asyncio
async def test_init_db_creates_default_client():
# Mock engine and AsyncSessionLocal
with patch("app.database.engine") as mock_engine, \
patch("app.database.AsyncSessionLocal") as mock_session_factory:
# Mock engine.begin() context manager
mock_conn = AsyncMock()
mock_engine.begin.return_value.__aenter__.return_value = mock_conn
# Mock session behavior
mock_session = AsyncMockSession()
mock_session_factory.return_value.__aenter__.return_value = mock_session
# Simulate empty table
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = None
mock_session.execute.return_value = mock_result
await init_db()
# Check if add was called for bootstrap client
assert any(isinstance(call.args[0], APIClient) for call in mock_session.add.call_args_list)
class AsyncMockSession:
def __init__(self):
self.add = MagicMock()
self.commit = AsyncMock()
self.execute = AsyncMock()
self.close = AsyncMock()
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
class AsyncMock(MagicMock):
async def __call__(self, *args, **kwargs):
return super(AsyncMock, self).__call__(*args, **kwargs)
def __await__(self):
return self().__await__()

View File

@ -0,0 +1,54 @@
import pytest
from app.database import get_db
from unittest.mock import patch, MagicMock
@pytest.mark.asyncio
async def test_get_db_generator():
mock_session = MagicMock()
mock_session.commit = AsyncMock()
mock_session.rollback = AsyncMock()
mock_session.close = AsyncMock()
# We need to mock AsyncSessionLocal as context manager
mock_session_factory = MagicMock()
mock_session_factory.return_value.__aenter__.return_value = mock_session
with patch("app.database.AsyncSessionLocal", mock_session_factory):
generator = get_db()
# Initial yield
session = await generator.__anext__()
assert session == mock_session
# After yield, it tries to commit
try:
await generator.__anext__()
except StopAsyncIteration:
pass
mock_session.commit.assert_called_once()
mock_session.close.assert_called_once()
@pytest.mark.asyncio
async def test_get_db_generator_exception():
mock_session = MagicMock()
mock_session.commit = AsyncMock()
mock_session.rollback = AsyncMock()
mock_session.close = AsyncMock()
mock_session_factory = MagicMock()
mock_session_factory.return_value.__aenter__.return_value = mock_session
with patch("app.database.AsyncSessionLocal", mock_session_factory):
generator = get_db()
session = await generator.__anext__()
# Simulate exception during use
with pytest.raises(ValueError):
await generator.athrow(ValueError("test error"))
mock_session.rollback.assert_called_once()
mock_session.close.assert_called_once()
class AsyncMock(MagicMock):
async def __call__(self, *args, **kwargs):
return super(AsyncMock, self).__call__(*args, **kwargs)

View File

@ -0,0 +1,57 @@
import pytest
from unittest.mock import patch, MagicMock
from app.services.exif_service import extract_exif, _dms_to_decimal
def test_dms_to_decimal():
# 48° 51' 23.82" N -> 48.8566167
dms = ((48, 1), (51, 1), (2382, 100))
assert _dms_to_decimal(dms, "N") == 48.8566167
assert _dms_to_decimal(dms, "S") == -48.8566167
@patch("app.services.exif_service.piexif")
@patch("app.services.exif_service.PILImage")
@patch("app.services.exif_service.Path.exists", return_value=True)
def test_extract_exif_success(mock_path_exists, mock_pil, mock_piexif):
# Mock piexif data
import piexif
mock_data = {
"0th": {
piexif.ImageIFD.Make: b"Canon",
piexif.ImageIFD.Model: b"EOS R5",
piexif.ImageIFD.Software: b"1.1.0"
},
"Exif": {
piexif.ExifIFD.DateTimeOriginal: b"2024:06:15 14:30:00",
piexif.ExifIFD.ISOSpeedRatings: 400,
piexif.ExifIFD.FNumber: (28, 10),
piexif.ExifIFD.ExposureTime: (1, 250),
piexif.ExifIFD.FocalLength: (500, 10),
piexif.ExifIFD.Flash: 0
},
"GPS": {
piexif.GPSIFD.GPSLatitude: ((48, 1), (51, 1), (2382, 100)),
piexif.GPSIFD.GPSLatitudeRef: b"N",
piexif.GPSIFD.GPSLongitude: ((2, 1), (21, 1), (792, 100)),
piexif.GPSIFD.GPSLongitudeRef: b"E",
piexif.GPSIFD.GPSAltitude: (350, 10)
}
}
mock_piexif.load.return_value = mock_data
mock_piexif.ImageIFD = piexif.ImageIFD
mock_piexif.ExifIFD = piexif.ExifIFD
mock_piexif.GPSIFD = piexif.GPSIFD
# Mock PIL
mock_img = mock_pil.open.return_value.__enter__.return_value
mock_img._getexif.return_value = {271: "Canon"} # 271 is Make
result = extract_exif("fake/path.jpg")
assert result["make"] == "Canon"
assert result["model"] == "EOS R5"
assert result["iso"] == 400
assert result["aperture"] == "f/2.8"
assert result["shutter"] == "1/250"
assert result["focal"] == "50mm"
assert result["gps_lat"] == 48.8566167
assert result["altitude"] == 35.0

View File

@ -0,0 +1,130 @@
import pytest
import uuid
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.image import Image, ProcessingStatus
from unittest.mock import patch
def create_test_image(client_id, **kwargs):
u = str(uuid.uuid4())
defaults = {
"uuid": u,
"client_id": client_id,
"original_name": "test.jpg",
"filename": f"{u}.jpg",
"file_path": f"fake/{u}.jpg",
}
defaults.update(kwargs)
return Image(**defaults)
@pytest.mark.asyncio
async def test_get_image_exif(async_client: AsyncClient, client_a, auth_headers_a, db_session: AsyncSession):
# Seed an image
image = create_test_image(
client_id=client_a.id,
exif_make="Apple",
exif_model="iPhone 13",
exif_gps_lat=48.8566,
exif_gps_lon=2.3522
)
db_session.add(image)
await db_session.commit()
await db_session.refresh(image)
response = await async_client.get(f"/images/{image.id}/exif", headers=auth_headers_a)
assert response.status_code == 200
data = response.json()
assert data["camera"]["make"] == "Apple"
assert data["gps"]["latitude"] == 48.8566
assert "maps_url" in data["gps"]
@pytest.mark.asyncio
async def test_get_image_ocr(async_client: AsyncClient, client_a, auth_headers_a, db_session: AsyncSession):
# Seed an image
image = create_test_image(
client_id=client_a.id,
ocr_has_text=True,
ocr_text="Hello World",
ocr_language="en",
ocr_confidence=0.95
)
db_session.add(image)
await db_session.commit()
await db_session.refresh(image)
response = await async_client.get(f"/images/{image.id}/ocr", headers=auth_headers_a)
assert response.status_code == 200
data = response.json()
assert data["has_text"] is True
assert data["text"] == "Hello World"
@pytest.mark.asyncio
async def test_get_image_ai(async_client: AsyncClient, client_a, auth_headers_a, db_session: AsyncSession):
# Seed an image
image = create_test_image(
client_id=client_a.id,
ai_description="A beautiful landscape",
ai_tags=["nature", "landscape"],
ai_confidence=0.99,
ai_model_used="gemini-1.5-pro"
)
db_session.add(image)
await db_session.commit()
await db_session.refresh(image)
response = await async_client.get(f"/images/{image.id}/ai", headers=auth_headers_a)
assert response.status_code == 200
data = response.json()
assert data["description"] == "A beautiful landscape"
assert "nature" in data["tags"]
@pytest.mark.asyncio
async def test_get_all_tags(async_client: AsyncClient, client_a, auth_headers_a, db_session: AsyncSession):
# Seed images
db_session.add(create_test_image(client_id=client_a.id, original_name="1.jpg", ai_tags=["tag1", "tag2"]))
db_session.add(create_test_image(client_id=client_a.id, original_name="2.jpg", ai_tags=["tag2", "tag3"]))
await db_session.commit()
response = await async_client.get("/images/tags/all", headers=auth_headers_a)
assert response.status_code == 200
data = response.json()
assert set(data["tags"]) == {"tag1", "tag2", "tag3"}
assert data["total"] == 3
@pytest.mark.asyncio
async def test_reprocess_image(async_client: AsyncClient, client_a, auth_headers_a, db_session: AsyncSession):
image = create_test_image(
client_id=client_a.id,
processing_status=ProcessingStatus.DONE
)
db_session.add(image)
await db_session.commit()
await db_session.refresh(image)
# Le ARQ pool est mocké globalement dans conftest.py
response = await async_client.post(f"/images/{image.id}/reprocess", headers=auth_headers_a)
assert response.status_code == 200
await db_session.refresh(image)
assert image.processing_status == ProcessingStatus.PENDING
@pytest.mark.asyncio
async def test_delete_image(async_client: AsyncClient, client_a, auth_headers_a, db_session: AsyncSession):
image = create_test_image(
client_id=client_a.id,
file_path="fake/path.jpg",
thumbnail_path="fake/thumb.jpg"
)
db_session.add(image)
await db_session.commit()
await db_session.refresh(image)
with patch("app.routers.images.storage.delete_files") as mock_delete:
response = await async_client.delete(f"/images/{image.id}", headers=auth_headers_a)
assert response.status_code == 200
mock_delete.assert_called_once_with("fake/path.jpg", "fake/thumb.jpg")
# Check if deleted from DB
from sqlalchemy import select
res = await db_session.execute(select(Image).where(Image.id == image.id))
assert res.scalar_one_or_none() is None

209
tests/test_isolation.py Normal file
View File

@ -0,0 +1,209 @@
"""
Tests d'isolation multi-tenants — un client ne peut jamais voir les données d'un autre.
"""
import io
import pytest
import pytest_asyncio
from unittest.mock import patch, AsyncMock
from httpx import AsyncClient
from app.models.client import APIClient
from app.models.image import Image
pytestmark = pytest.mark.asyncio
# ─────────────────────────────────────────────────────────────
# Helper : upload d'une image de test
# ─────────────────────────────────────────────────────────────
async def _upload_test_image(
async_client: AsyncClient,
headers: dict,
filename: str = "test.jpg",
) -> dict:
"""Upload une image de test minimale (1x1 pixel JPEG) et retourne la réponse JSON."""
# Image JPEG minimale valide (1x1 pixel)
jpeg_bytes = (
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
b"\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t"
b"\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a"
b"\x1f\x1e\x1d\x1a\x1c\x1c $.\' \",#\x1c\x1c(7),01444\x1f\'9=82<.342"
b"\xff\xc0\x00\x0b\x08\x00\x01\x00\x01\x01\x01\x11\x00"
b"\xff\xc4\x00\x1f\x00\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b"
b"\xff\xc4\x00\xb5\x10\x00\x02\x01\x03\x03\x02\x04\x03\x05\x05\x04"
b"\x04\x00\x00\x01}\x01\x02\x03\x00\x04\x11\x05\x12!1A\x06\x13Qa\x07"
b"\x22q\x142\x81\x91\xa1\x08#B\xb1\xc1\x15R\xd1\xf0$3br\x82\t\n\x16"
b"\x17\x18\x19\x1a%&\'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz\x83"
b"\x84\x85\x86\x87\x88\x89\x8a\x92\x93\x94\x95\x96\x97\x98\x99\x9a"
b"\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xb2\xb3\xb4\xb5\xb6\xb7\xb8"
b"\xb9\xba\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xd2\xd3\xd4\xd5\xd6"
b"\xd7\xd8\xd9\xda\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xf1\xf2"
b"\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa"
b"\xff\xda\x00\x08\x01\x01\x00\x00?\x00T\xdb\xae\x8a(\x03\xff\xd9"
)
# Le pipeline ARQ est mocké globalement dans conftest.py
response = await async_client.post(
"/images/upload",
files={"file": (filename, io.BytesIO(jpeg_bytes), "image/jpeg")},
headers=headers,
)
return response
# ─────────────────────────────────────────────────────────────
# Client A upload une image → invisible pour Client B
# ─────────────────────────────────────────────────────────────
async def test_client_a_image_invisible_to_client_b(
async_client: AsyncClient,
client_a: APIClient,
client_b: APIClient,
auth_headers_a: dict,
auth_headers_b: dict,
):
"""L'image uploadée par A n'apparaît pas dans la liste de B."""
# A uploade une image
upload_resp = await _upload_test_image(async_client, auth_headers_a, "photo_a.jpg")
assert upload_resp.status_code == 201
# Liste pour A → contient l'image
response = await async_client.get("/images", headers=auth_headers_a)
assert response.status_code == 200
data_a = response.json()
assert data_a["total"] == 1
assert data_a["items"][0]["original_name"] == "photo_a.jpg"
# Liste pour B → vide
response = await async_client.get("/images", headers=auth_headers_b)
assert response.status_code == 200
data_b = response.json()
assert data_b["total"] == 0
assert len(data_b["items"]) == 0
# ─────────────────────────────────────────────────────────────
# Client B ne peut pas lire l'image de Client A
# ─────────────────────────────────────────────────────────────
async def test_client_b_cannot_read_client_a_image(
async_client: AsyncClient,
client_a: APIClient,
client_b: APIClient,
auth_headers_a: dict,
auth_headers_b: dict,
):
"""Client B reçoit 404 en essayant de lire l'image de A."""
# A uploade
upload_resp = await _upload_test_image(async_client, auth_headers_a)
assert upload_resp.status_code == 201
image_id = upload_resp.json()["id"]
# B essaie de lire l'image de A → 404
response = await async_client.get(f"/images/{image_id}", headers=auth_headers_b)
assert response.status_code == 404
# ─────────────────────────────────────────────────────────────
# Client B ne peut pas supprimer l'image de Client A
# ─────────────────────────────────────────────────────────────
async def test_client_b_cannot_delete_client_a_image(
async_client: AsyncClient,
client_a: APIClient,
client_b: APIClient,
auth_headers_a: dict,
auth_headers_b: dict,
):
"""Client B reçoit 404 en essayant de supprimer l'image de A."""
# A uploade
upload_resp = await _upload_test_image(async_client, auth_headers_a)
assert upload_resp.status_code == 201
image_id = upload_resp.json()["id"]
# B essaie de supprimer → 404
response = await async_client.delete(f"/images/{image_id}", headers=auth_headers_b)
assert response.status_code == 404
# L'image existe toujours pour A
response = await async_client.get(f"/images/{image_id}", headers=auth_headers_a)
assert response.status_code == 200
# ─────────────────────────────────────────────────────────────
# Chaque client ne voit que ses propres images
# ─────────────────────────────────────────────────────────────
async def test_listing_returns_only_own_images(
async_client: AsyncClient,
client_a: APIClient,
client_b: APIClient,
auth_headers_a: dict,
auth_headers_b: dict,
):
"""Chaque client ne voit que ses propres images dans la liste."""
# A uploade 2 images
await _upload_test_image(async_client, auth_headers_a, "a1.jpg")
await _upload_test_image(async_client, auth_headers_a, "a2.jpg")
# B uploade 1 image
await _upload_test_image(async_client, auth_headers_b, "b1.jpg")
# A voit 2 images
resp_a = await async_client.get("/images", headers=auth_headers_a)
assert resp_a.json()["total"] == 2
# B voit 1 image
resp_b = await async_client.get("/images", headers=auth_headers_b)
assert resp_b.json()["total"] == 1
assert resp_b.json()["items"][0]["original_name"] == "b1.jpg"
# ─────────────────────────────────────────────────────────────
# Reprocess d'une image d'un autre client → 404
# ─────────────────────────────────────────────────────────────
async def test_reprocess_other_client_image_returns_404(
async_client: AsyncClient,
client_a: APIClient,
client_b: APIClient,
auth_headers_a: dict,
auth_headers_b: dict,
):
"""Reprocess l'image d'un autre client → HTTP 404."""
# A uploade
upload_resp = await _upload_test_image(async_client, auth_headers_a)
assert upload_resp.status_code == 201
image_id = upload_resp.json()["id"]
# B essaie de reprocess → 404
response = await async_client.post(
f"/images/{image_id}/reprocess",
headers=auth_headers_b,
)
assert response.status_code == 404
# ─────────────────────────────────────────────────────────────
# Statut / EXIF / OCR / AI d'un autre client → 404
# ─────────────────────────────────────────────────────────────
async def test_sub_endpoints_other_client_returns_404(
async_client: AsyncClient,
client_a: APIClient,
client_b: APIClient,
auth_headers_a: dict,
auth_headers_b: dict,
):
"""Les sous-endpoints (status, exif, ocr, ai) retournent 404 pour l'autre client."""
upload_resp = await _upload_test_image(async_client, auth_headers_a)
assert upload_resp.status_code == 201
image_id = upload_resp.json()["id"]
for endpoint in [f"/images/{image_id}/status", f"/images/{image_id}/exif",
f"/images/{image_id}/ocr", f"/images/{image_id}/ai"]:
response = await async_client.get(endpoint, headers=auth_headers_b)
assert response.status_code == 404, f"Endpoint {endpoint} should return 404 for other client"

View File

@ -0,0 +1,25 @@
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
from app.config import settings
@pytest.mark.asyncio
async def test_root_endpoint(async_client: AsyncClient):
response = await async_client.get("/")
assert response.status_code == 200
assert response.json()["app"] == settings.APP_NAME
@pytest.mark.asyncio
async def test_health_endpoint(async_client: AsyncClient):
response = await async_client.get("/health")
assert response.status_code == 200
assert "status" in response.json()
assert response.json()["status"] == "healthy"
@pytest.mark.asyncio
async def test_lifespan(async_client: AsyncClient):
# Testing that the lifespan context manager can be entered/exited
# ASGITransport already handles this if used correctly
async with ASGITransport(app=app) as transport:
# Just entering the context triggers lifespan
pass

View File

@ -0,0 +1,34 @@
import pytest
from unittest.mock import MagicMock
from app.middleware import (
_get_client_id_from_request, get_upload_rate_limit,
get_ai_rate_limit, upload_rate_limit_key, ai_rate_limit_key
)
def test_get_client_id_from_request_with_id():
request = MagicMock()
request.state.client_id = "test-client"
assert _get_client_id_from_request(request) == "test-client"
def test_get_client_id_from_request_fallback():
request = MagicMock()
del request.state.client_id # Ensure it's not there
request.client.host = "1.2.3.4"
request.headers = {}
# slowapi's get_remote_address uses request.client.host or headers
with patch("app.middleware.get_remote_address", return_value="1.2.3.4"):
assert _get_client_id_from_request(request) == "1.2.3.4"
from unittest.mock import patch
def test_rate_limit_helpers():
assert "hour" in get_upload_rate_limit("free")
assert "hour" in get_ai_rate_limit("premium")
assert get_upload_rate_limit("invalid") == get_upload_rate_limit("free")
def test_rate_limit_keys():
request = MagicMock()
request.state.client_id = "abc"
assert upload_rate_limit_key(request) == "abc"
assert ai_rate_limit_key(request) == "abc"

View File

@ -0,0 +1,41 @@
import pytest
from unittest.mock import patch, MagicMock
from app.services.ocr_service import extract_text, _detect_language
def test_detect_language():
assert _detect_language("C'est une phrase en français.") == "fr"
assert _detect_language("This is a sentence in English.") == "en"
assert _detect_language("") == "unknown"
assert _detect_language("123456789") == "unknown"
@patch("app.services.ocr_service.pytesseract")
@patch("app.services.ocr_service.PILImage")
@patch("app.services.ocr_service.Path.exists", return_value=True)
@patch("app.services.ocr_service.settings")
def test_extract_text_success(mock_settings, mock_path_exists, mock_pil, mock_tesseract):
mock_settings.OCR_ENABLED = True
mock_settings.OCR_LANGUAGES = "fra+eng"
mock_settings.TESSERACT_CMD = None
# Mock image_to_data
mock_tesseract.image_to_data.return_value = {
"conf": ["90", "80", "-1", "70"]
}
mock_tesseract.Output.DICT = "dict"
# Mock image_to_string
mock_tesseract.image_to_string.return_value = "This is a test OCR output."
result = extract_text("fake/path.jpg")
assert result["has_text"] is True
assert result["text"] == "This is a test OCR output."
assert result["confidence"] == 0.8 # (90+80+70)/3 / 100 = 0.8
assert result["language"] == "en"
@patch("app.services.ocr_service.settings")
def test_extract_text_disabled(mock_settings):
mock_settings.OCR_ENABLED = False
result = extract_text("any/path.jpg")
assert result["has_text"] is False
assert result["text"] is None

View File

@ -0,0 +1,106 @@
import pytest
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.image import Image, ProcessingStatus
from app.services.pipeline import process_image_pipeline
from unittest.mock import patch, AsyncMock
def create_test_image(client_id, **kwargs):
u = str(uuid.uuid4())
defaults = {
"uuid": u,
"client_id": client_id,
"original_name": "test.jpg",
"filename": f"{u}.jpg",
"file_path": f"fake/{u}.jpg",
}
defaults.update(kwargs)
return Image(**defaults)
@pytest.mark.asyncio
async def test_pipeline_full_success(db_session: AsyncSession, client_a):
image = create_test_image(client_id=client_a.id)
db_session.add(image)
await db_session.commit()
await db_session.refresh(image)
mock_exif = {"make": "Canon", "model": "EOS R"}
mock_ocr = {"text": "Hello", "has_text": True, "confidence": 0.9}
mock_ai = {"description": "A photo", "tags": ["tag1"], "confidence": 0.8, "model": "gemini"}
with patch("app.services.pipeline.extract_exif", return_value=mock_exif), \
patch("app.services.pipeline.extract_text", return_value=mock_ocr), \
patch("app.services.pipeline.analyze_image", return_value=mock_ai):
await process_image_pipeline(image.id, db_session)
await db_session.refresh(image)
assert image.processing_status == ProcessingStatus.DONE
assert image.exif_make == "Canon"
assert image.ocr_text == "Hello"
assert image.ai_description == "A photo"
@pytest.mark.asyncio
async def test_pipeline_partial_failure(db_session: AsyncSession, client_a):
image = create_test_image(client_id=client_a.id)
db_session.add(image)
await db_session.commit()
await db_session.refresh(image)
mock_ocr = {"text": "Hello", "has_text": True}
mock_ai = {"description": "A photo", "tags": ["tag1"]}
# Step 1 fails, Step 2 & 3 succeed
with patch("app.services.pipeline.extract_exif", side_effect=Exception("EXIF error")), \
patch("app.services.pipeline.extract_text", return_value=mock_ocr), \
patch("app.services.pipeline.analyze_image", return_value=mock_ai):
await process_image_pipeline(image.id, db_session)
await db_session.refresh(image)
assert image.processing_status == ProcessingStatus.DONE # Still DONE because AI succeeded
assert "EXIF error" in image.processing_error
assert image.ocr_text == "Hello"
@pytest.mark.asyncio
async def test_pipeline_total_failure(db_session: AsyncSession, client_a):
image = create_test_image(client_id=client_a.id)
db_session.add(image)
await db_session.commit()
await db_session.refresh(image)
# All steps fail
with patch("app.services.pipeline.extract_exif", side_effect=Exception("EXIF error")), \
patch("app.services.pipeline.extract_text", side_effect=Exception("OCR error")), \
patch("app.services.pipeline.analyze_image", side_effect=Exception("AI error")):
await process_image_pipeline(image.id, db_session)
await db_session.refresh(image)
assert image.processing_status == ProcessingStatus.ERROR
assert "EXIF error" in image.processing_error
assert "OCR error" in image.processing_error
assert "AI error" in image.processing_error
@pytest.mark.asyncio
async def test_pipeline_ocr_fallback(db_session: AsyncSession, client_a):
image = create_test_image(client_id=client_a.id)
db_session.add(image)
await db_session.commit()
await db_session.refresh(image)
mock_exif = {}
mock_ocr_empty = {"has_text": False}
mock_ai_ocr = {"text": "AI extracted text", "has_text": True}
mock_ai_vision = {"description": "A photo"}
with patch("app.services.pipeline.extract_exif", return_value=mock_exif), \
patch("app.services.pipeline.extract_text", return_value=mock_ocr_empty), \
patch("app.services.pipeline.extract_text_with_ai", return_value=mock_ai_ocr), \
patch("app.services.pipeline.analyze_image", return_value=mock_ai_vision):
await process_image_pipeline(image.id, db_session)
await db_session.refresh(image)
assert image.ocr_text == "AI extracted text"
assert image.ocr_has_text is True

107
tests/test_rate_limit.py Normal file
View File

@ -0,0 +1,107 @@
"""
Tests de rate limiting vérification des limites par client.
"""
import io
import pytest
from unittest.mock import patch, AsyncMock
from httpx import AsyncClient
from app.models.client import APIClient, ClientPlan
from app.dependencies.auth import hash_api_key
pytestmark = pytest.mark.asyncio
# ─────────────────────────────────────────────────────────────
# Helper : upload rapide
# ─────────────────────────────────────────────────────────────
async def _quick_upload(async_client: AsyncClient, headers: dict) -> int:
"""Upload rapide et retourne le status code."""
# Image JPEG minimale
jpeg_bytes = (
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
b"\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t"
b"\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a"
b"\x1f\x1e\x1d\x1a\x1c\x1c $.\' \",#\x1c\x1c(7),01444\x1f\'9=82<.342"
b"\xff\xc0\x00\x0b\x08\x00\x01\x00\x01\x01\x01\x11\x00"
b"\xff\xc4\x00\x1f\x00\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b"
b"\xff\xc4\x00\xb5\x10\x00\x02\x01\x03\x03\x02\x04\x03\x05\x05\x04"
b"\x04\x00\x00\x01}\x01\x02\x03\x00\x04\x11\x05\x12!1A\x06\x13Qa\x07"
b"\x22q\x142\x81\x91\xa1\x08#B\xb1\xc1\x15R\xd1\xf0$3br\x82\t\n\x16"
b"\x17\x18\x19\x1a%&\'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz\x83"
b"\x84\x85\x86\x87\x88\x89\x8a\x92\x93\x94\x95\x96\x97\x98\x99\x9a"
b"\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xb2\xb3\xb4\xb5\xb6\xb7\xb8"
b"\xb9\xba\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xd2\xd3\xd4\xd5\xd6"
b"\xd7\xd8\xd9\xda\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xf1\xf2"
b"\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa"
b"\xff\xda\x00\x08\x01\x01\x00\x00?\x00T\xdb\xae\x8a(\x03\xff\xd9"
)
# Le pipeline ARQ est mocké globalement dans conftest.py
response = await async_client.post(
"/images/upload",
files={"file": ("test.jpg", io.BytesIO(jpeg_bytes), "image/jpeg")},
headers=headers,
)
return response.status_code
# ─────────────────────────────────────────────────────────────
# Rate limit headers sont présents dans les réponses
# ─────────────────────────────────────────────────────────────
async def test_rate_limit_headers_present(
async_client: AsyncClient,
client_a: APIClient,
auth_headers_a: dict,
):
# Le pipeline ARQ est mocké globalement dans conftest.py
jpeg_bytes = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
response = await async_client.post(
"/images/upload",
files={"file": ("test.jpg", io.BytesIO(jpeg_bytes), "image/jpeg")},
headers=auth_headers_a,
)
# slowapi met des headers rate limit dans la réponse
# Vérifier que la réponse est soit 201 (succès) soit contient des headers rate limit
assert response.status_code in (201, 429)
# ─────────────────────────────────────────────────────────────
# Compteurs distincts par client
# ─────────────────────────────────────────────────────────────
async def test_rate_limit_per_client_independent(
async_client: AsyncClient,
client_a: APIClient,
client_b: APIClient,
auth_headers_a: dict,
auth_headers_b: dict,
):
"""Les compteurs de rate limit sont indépendants par client."""
# Uploads pour A
status_a = await _quick_upload(async_client, auth_headers_a)
assert status_a == 201
# Uploads pour B (ne doit pas être affecté par les requêtes de A)
status_b = await _quick_upload(async_client, auth_headers_b)
assert status_b == 201
# ─────────────────────────────────────────────────────────────
# Vérification fonctionnelle du rate limiter
# ─────────────────────────────────────────────────────────────
async def test_rate_limiter_is_configured(
async_client: AsyncClient,
client_a: APIClient,
auth_headers_a: dict,
):
"""Le rate limiter est bien actif sur le endpoint upload."""
from app.middleware import limiter
# Le limiter doit être configuré et actif
assert limiter is not None
assert limiter.enabled

View File

@ -0,0 +1,45 @@
import pytest
import respx
from httpx import Response
from app.services.scraper import fetch_page_content
@pytest.mark.asyncio
@respx.mock
async def test_fetch_page_content_success():
url = "https://example.com"
html_content = """
<html>
<head><title>Test Title</title><meta name="description" content="Test Description"></head>
<body>
<article>
<p>This is a long enough paragraph to be captured by the scraper logic which requires > 30 chars.</p>
<p>Another long paragraph that should be joined with the previous one for the final text.</p>
</article>
</body>
</html>
"""
respx.get(url).mock(return_value=Response(200, text=html_content))
result = await fetch_page_content(url)
assert result["title"] == "Test Title"
assert result["description"] == "Test Description"
assert "This is a long enough paragraph" in result["text"]
assert result["error"] is None
@pytest.mark.asyncio
@respx.mock
async def test_fetch_page_content_http_error():
url = "https://example.com/404"
respx.get(url).mock(return_value=Response(404))
result = await fetch_page_content(url)
assert result["error"] == "HTTP 404"
@pytest.mark.asyncio
@respx.mock
async def test_fetch_page_content_request_error():
url = "https://broken.url"
respx.get(url).mock(side_effect=Exception("Connection reset"))
result = await fetch_page_content(url)
assert "Connection reset" in result["error"]

121
tests/test_services.py Normal file
View File

@ -0,0 +1,121 @@
"""
Tests unitaires services et endpoints
"""
import pytest
import asyncio
from unittest.mock import patch, MagicMock
from pathlib import Path
# ── Tests EXIF ────────────────────────────────────────────────
def test_exif_missing_file():
from app.services.exif_service import extract_exif
result = extract_exif("/tmp/non_existant.jpg")
assert result["make"] is None
assert result["gps_lat"] is None
assert result["raw"] == {}
def test_exif_dms_to_decimal():
from app.services.exif_service import _dms_to_decimal
# Paris : 48°51'24.12"N → ~48.856700
dms = ((48, 1), (51, 1), (2412, 100))
result = _dms_to_decimal(dms, "N")
assert result is not None
assert 48.85 < result < 48.86
# Direction Sud = négatif
result_s = _dms_to_decimal(dms, "S")
assert result_s < 0
# ── Tests OCR ─────────────────────────────────────────────────
def test_ocr_disabled(monkeypatch):
monkeypatch.setattr("app.services.ocr_service.settings.OCR_ENABLED", False)
from app.services.ocr_service import extract_text
result = extract_text("/tmp/test.jpg")
assert result["has_text"] is False
assert result["text"] is None
def test_ocr_language_detection():
from app.services.ocr_service import _detect_language
fr_text = "le chat est sur le tapis et la maison est grande"
en_text = "the cat is on the mat and the house is great"
assert _detect_language(fr_text) == "fr"
assert _detect_language(en_text) == "en"
assert _detect_language("") == "unknown"
# ── Tests Storage ─────────────────────────────────────────────
def test_generate_filename():
from app.services.storage import _generate_filename
filename, uuid = _generate_filename("photo.jpg")
assert filename.endswith(".jpg")
assert len(uuid) == 36 # UUID format
def test_generate_filename_no_extension():
from app.services.storage import _generate_filename
filename, uuid = _generate_filename("image")
assert filename.endswith(".jpg") # extension par défaut
# ── Tests Schémas ─────────────────────────────────────────────
def test_image_detail_schema():
from app.schemas import ImageDetail
from app.models.image import ProcessingStatus
# Simule un objet Image ORM
mock_img = MagicMock()
mock_img.id = 1
mock_img.uuid = "abc-123"
mock_img.original_name = "test.jpg"
mock_img.mime_type = "image/jpeg"
mock_img.file_size = 1024
mock_img.width = 800
mock_img.height = 600
mock_img.uploaded_at = None
mock_img.processing_status = ProcessingStatus.DONE
mock_img.thumbnail_path = None
mock_img.exif_make = "Canon"
mock_img.exif_model = "EOS R5"
mock_img.exif_lens = None
mock_img.exif_iso = 400
mock_img.exif_aperture = "f/2.8"
mock_img.exif_shutter = "1/250"
mock_img.exif_focal = "50mm"
mock_img.exif_flash = False
mock_img.exif_orientation = 1
mock_img.exif_software = None
mock_img.exif_taken_at = None
mock_img.exif_gps_lat = 48.857
mock_img.exif_gps_lon = 2.295
mock_img.exif_altitude = 35.0
mock_img.exif_raw = {}
mock_img.has_gps = True
mock_img.ocr_text = "Bonjour monde"
mock_img.ocr_language = "fr"
mock_img.ocr_confidence = 0.95
mock_img.ocr_has_text = True
mock_img.ai_description = "Une belle photo"
mock_img.ai_tags = ["nature", "paysage"]
mock_img.ai_confidence = 0.92
mock_img.ai_model_used = "gemini-1.5-pro"
mock_img.ai_processed_at = None
mock_img.ai_prompt_tokens = 500
mock_img.ai_output_tokens = 200
mock_img.processing_error = None
mock_img.processing_started_at = None
mock_img.processing_done_at = None
detail = ImageDetail.from_orm_full(mock_img)
assert detail.id == 1
assert detail.exif.camera.make == "Canon"
assert detail.exif.gps.has_gps is True
assert detail.ocr.has_text is True
assert detail.ai.tags == ["nature", "paysage"]

View File

@ -0,0 +1,46 @@
import pytest
from fastapi import HTTPException
from app.services.storage import save_upload, delete_files, get_image_url
from unittest.mock import MagicMock, patch, AsyncMock
import io
@pytest.mark.asyncio
async def test_save_upload_unsupported_mime():
mock_file = MagicMock()
mock_file.content_type = "text/plain"
with pytest.raises(HTTPException) as exc:
await save_upload(mock_file, "client_id")
assert exc.value.status_code == 415
@pytest.mark.asyncio
@patch("app.services.storage.settings")
async def test_save_upload_too_large(mock_settings):
mock_settings.max_upload_bytes = 10
mock_settings.MAX_UPLOAD_SIZE_MB = 10
mock_file = MagicMock()
mock_file.content_type = "image/jpeg"
mock_file.read = AsyncMock(return_value=b"a" * 20)
with pytest.raises(HTTPException) as exc:
await save_upload(mock_file, "client_id")
assert exc.value.status_code == 413
def test_delete_files_exists(tmp_path):
f1 = tmp_path / "f1.txt"
f1.write_text("hello")
f2 = tmp_path / "f2.txt"
f2.write_text("world")
assert f1.exists()
assert f2.exists()
delete_files(str(f1), str(f2))
assert not f1.exists()
assert not f2.exists()
def test_get_image_url():
url = get_image_url("img.jpg", "client1")
assert "/static/uploads/client1/img.jpg" in url
thumb_url = get_image_url("img.jpg", "client1", thumb=True)
assert "/static/thumbnails/client1/img.jpg" in thumb_url

14
worker.py Normal file
View File

@ -0,0 +1,14 @@
"""
Entrypoint du worker ARQ traitement des images en arrière-plan.
Lancer avec : python worker.py
Le worker écoute les queues Redis 'standard' et 'premium' et traite
les tâches de pipeline image (EXIF OCR AI).
"""
import asyncio
from arq import run_worker
from app.workers.image_worker import WorkerSettings
if __name__ == "__main__":
asyncio.run(run_worker(WorkerSettings))