Add comprehensive test suite for image processing and related services
- Implement tests for database generator to ensure proper session handling. - Create tests for EXIF extraction and conversion functions. - Add tests for image-related endpoints, ensuring proper data retrieval and isolation between clients. - Develop tests for OCR functionality, including language detection and text extraction. - Introduce tests for the image processing pipeline, covering success and failure scenarios. - Validate rate limiting functionality and ensure independent counters for different clients. - Implement scraper tests to verify HTML content fetching and error handling. - Add unit tests for various services, including storage and filename generation. - Establish worker entry point for ARQ to handle background image processing tasks.
This commit is contained in:
commit
cc99fea20a
72
.env.example
Normal file
72
.env.example
Normal 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
114
.github/workflows/ci.yml
vendored
Normal 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
39
.gitignore
vendored
Normal 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
28
.pre-commit-config.yaml
Normal 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
242
API_GUIDE.md
Normal 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
27
Dockerfile
Normal 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
57
Makefile
Normal 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
403
README.md
Normal 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
38
alembic.ini
Normal 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
53
alembic/env.py
Normal 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
6
api_key.md
Normal 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
1
app/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Imago
|
||||||
121
app/config.py
Normal file
121
app/config.py
Normal 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
77
app/database.py
Normal 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.",
|
||||||
|
})
|
||||||
3
app/dependencies/__init__.py
Normal file
3
app/dependencies/__init__.py
Normal 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
115
app/dependencies/auth.py
Normal 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
58
app/logging_config.py
Normal 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
310
app/main.py
Normal 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
62
app/metrics.py
Normal 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",
|
||||||
|
)
|
||||||
65
app/middleware/__init__.py
Normal file
65
app/middleware/__init__.py
Normal 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)
|
||||||
41
app/middleware/logging_middleware.py
Normal file
41
app/middleware/logging_middleware.py
Normal 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
|
||||||
3
app/middleware/rate_limit.py
Normal file
3
app/middleware/rate_limit.py
Normal 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
4
app/models/__init__.py
Normal 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
70
app/models/client.py
Normal 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
97
app/models/image.py
Normal 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
6
app/routers/__init__.py
Normal 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
102
app/routers/ai.py
Normal 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
198
app/routers/auth.py
Normal 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
68
app/routers/files.py
Normal 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
500
app/routers/images.py
Normal 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
228
app/schemas/__init__.py
Normal 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
67
app/schemas/auth.py
Normal 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
3
app/services/__init__.py
Normal 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
417
app/services/ai_vision.py
Normal 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
|
||||||
|
|
||||||
173
app/services/exif_service.py
Normal file
173
app/services/exif_service.py
Normal 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
107
app/services/ocr_service.py
Normal 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
207
app/services/pipeline.py
Normal 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
70
app/services/scraper.py
Normal 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
123
app/services/storage.py
Normal 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}"
|
||||||
227
app/services/storage_backend.py
Normal file
227
app/services/storage_backend.py
Normal 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
1
app/workers/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Workers package — ARQ task queue
|
||||||
191
app/workers/image_worker.py
Normal file
191
app/workers/image_worker.py
Normal 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"]
|
||||||
28
app/workers/redis_client.py
Normal file
28
app/workers/redis_client.py
Normal 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
13
debug_numpy.py
Normal 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}")
|
||||||
281
doc/PROMPT_PHASE1_claude-opus.md
Normal file
281
doc/PROMPT_PHASE1_claude-opus.md
Normal 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>
|
||||||
765
doc/PROMPT_PHASE2_claude-opus.md
Normal file
765
doc/PROMPT_PHASE2_claude-opus.md
Normal 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>
|
||||||
855
doc/PROMPT_PHASE3_claude-opus.md
Normal file
855
doc/PROMPT_PHASE3_claude-opus.md
Normal 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>
|
||||||
BIN
doc/rapport-amelioration-imago.docx
Normal file
BIN
doc/rapport-amelioration-imago.docx
Normal file
Binary file not shown.
735
doc/rapport-amelioration-imago.md
Normal file
735
doc/rapport-amelioration-imago.md
Normal 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 | 3–5 j |
|
||||||
|
| Gestion multi-clients (tenants) | Isolation des données | 🔴 Critique | 5–7 j |
|
||||||
|
| Pipeline asynchrone robuste | Fiabilité du traitement AI | 🟠 Haute | 4–5 j |
|
||||||
|
| Stockage & CDN | Performance, scalabilité | 🟠 Haute | 3–4 j |
|
||||||
|
| WebSockets & temps réel | Expérience client | 🟡 Moyenne | 2–3 j |
|
||||||
|
| Observabilité & Monitoring | Production-ready | 🟡 Moyenne | 3–4 j |
|
||||||
|
| API versioning & SDK clients | Contrat API stable | 🟡 Moyenne | 2–3 j |
|
||||||
|
| Tests & CI/CD | Qualité et maintenabilité | 🟠 Haute | 4–5 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 1–3)
|
||||||
|
|
||||||
|
| # | Livrable | Axe | Effort | Priorité |
|
||||||
|
|:---:|---|:---:|:---:|:---:|
|
||||||
|
| 1.1 | Authentification API Keys + JWT avec scopes | Axe 1 | 3–5 j | 🔴 Critique |
|
||||||
|
| 1.2 | Modèle clients + isolation des données | Axe 2 | 5–7 j | 🔴 Critique |
|
||||||
|
| 1.3 | Rate limiting par client et par endpoint | Axe 1 | 1–2 j | 🟠 Haute |
|
||||||
|
| 1.4 | Tests d'intégration auth + multi-tenants | Axe 8 | 2–3 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 4–6)
|
||||||
|
|
||||||
|
| # | Livrable | Axe | Effort | Priorité |
|
||||||
|
|:---:|---|:---:|:---:|:---:|
|
||||||
|
| 2.1 | Migration BackgroundTasks → ARQ + Redis | Axe 3 | 4–5 j | 🟠 Haute |
|
||||||
|
| 2.2 | Abstraction StorageBackend + support MinIO/S3 | Axe 4 | 3–4 j | 🟠 Haute |
|
||||||
|
| 2.3 | URLs signées pour accès aux fichiers | Axe 4 | 1–2 j | 🟠 Haute |
|
||||||
|
| 2.4 | Logs structurés (structlog) + métriques Prometheus | Axe 6 | 3–4 j | 🟡 Moyenne |
|
||||||
|
| 2.5 | CI/CD complet avec GitHub Actions | Axe 8 | 2–3 j | 🟠 Haute |
|
||||||
|
|
||||||
|
**Résultat de la phase 2 :** pipeline robuste avec retry, stockage abstrait, observabilité opérationnelle.
|
||||||
|
|
||||||
|
### Phase 3 — Expérience développeur (Semaines 7–10)
|
||||||
|
|
||||||
|
| # | Livrable | Axe | Effort | Priorité |
|
||||||
|
|:---:|---|:---:|:---:|:---:|
|
||||||
|
| 3.1 | WebSocket pipeline temps réel | Axe 5 | 2–3 j | 🟡 Moyenne |
|
||||||
|
| 3.2 | API versioning `/api/v1/` + politique de dépréciation | Axe 7 | 1–2 j | 🟡 Moyenne |
|
||||||
|
| 3.3 | SDK Python généré + publié sur PyPI | Axe 7 | 3–4 j | 🟡 Moyenne |
|
||||||
|
| 3.4 | Dashboard admin (quotas, métriques, clients) | Axe 6 | 4–5 j | 🟢 Faible |
|
||||||
|
| 3.5 | Intégration Shaarli complète + documentation | Axe 7 | 2–3 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
66
docker-compose.yml
Normal 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
23
list_models.py
Normal 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
70
pyproject.toml
Normal 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
8
pytest.ini
Normal 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
17
requirements-dev.txt
Normal 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
53
requirements.txt
Normal 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
14
run.py
Normal 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
95
test_coverage.txt
Normal 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
11
test_imports.py
Normal 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
17
test_ocr.py
Normal 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
57
test_output.txt
Normal 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
0
tests/__init__.py
Normal file
179
tests/conftest.py
Normal file
179
tests/conftest.py
Normal 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
57
tests/test_ai_extended.py
Normal 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"
|
||||||
62
tests/test_ai_vision_extended.py
Normal file
62
tests/test_ai_vision_extended.py
Normal 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"]
|
||||||
94
tests/test_ai_vision_internals.py
Normal file
94
tests/test_ai_vision_internals.py
Normal 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
254
tests/test_auth.py
Normal 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
|
||||||
45
tests/test_database_extended.py
Normal file
45
tests/test_database_extended.py
Normal 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__()
|
||||||
54
tests/test_db_generator.py
Normal file
54
tests/test_db_generator.py
Normal 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)
|
||||||
57
tests/test_exif_extended.py
Normal file
57
tests/test_exif_extended.py
Normal 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
|
||||||
130
tests/test_images_extended.py
Normal file
130
tests/test_images_extended.py
Normal 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
209
tests/test_isolation.py
Normal 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"
|
||||||
25
tests/test_main_extended.py
Normal file
25
tests/test_main_extended.py
Normal 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
|
||||||
34
tests/test_middleware_extended.py
Normal file
34
tests/test_middleware_extended.py
Normal 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"
|
||||||
41
tests/test_ocr_extended.py
Normal file
41
tests/test_ocr_extended.py
Normal 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
|
||||||
106
tests/test_pipeline_extended.py
Normal file
106
tests/test_pipeline_extended.py
Normal 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
107
tests/test_rate_limit.py
Normal 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
|
||||||
45
tests/test_scraper_extended.py
Normal file
45
tests/test_scraper_extended.py
Normal 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
121
tests/test_services.py
Normal 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"]
|
||||||
46
tests/test_storage_extended.py
Normal file
46
tests/test_storage_extended.py
Normal 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
14
worker.py
Normal 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))
|
||||||
Loading…
x
Reference in New Issue
Block a user