Initial commit: API Anime Tracker avec authentification et synchronisation
This commit is contained in:
56
.gitignore
vendored
Normal file
56
.gitignore
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual Environment
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Installer les dépendances système
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copier les fichiers de dépendances
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Installer les dépendances Python
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copier le code de l'application
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Exposer le port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Commande pour démarrer l'application
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
||||||
162
README.md
Normal file
162
README.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# API Anime Tracker
|
||||||
|
|
||||||
|
API de synchronisation pour l'extension Chrome Anime Tracker, permettant de synchroniser l'historique des animés sur plusieurs appareils.
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
- ✅ Authentification JWT (inscription, connexion)
|
||||||
|
- ✅ Synchronisation des animés entre appareils
|
||||||
|
- ✅ CRUD complet pour les animés
|
||||||
|
- ✅ Endpoint de synchronisation en masse
|
||||||
|
- ✅ Base de données PostgreSQL (service externe)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Variables d'environnement
|
||||||
|
|
||||||
|
Créez un fichier `.env` à partir de `env.example` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Éditez `.env` et configurez :
|
||||||
|
|
||||||
|
- `SECRET_KEY`: Clé secrète pour JWT (minimum 32 caractères, générer une clé aléatoire)
|
||||||
|
- `DATABASE_URL`: URL de connexion PostgreSQL (format: `postgresql://user:password@host:port/database`)
|
||||||
|
|
||||||
|
### Exemple de configuration
|
||||||
|
|
||||||
|
```env
|
||||||
|
SECRET_KEY=votre-cle-secrete-tres-longue-et-aleatoire-minimum-32-caracteres
|
||||||
|
DATABASE_URL=postgresql://username:password@postgres.holo795.fr:5432/anime_tracker
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation avec Docker
|
||||||
|
|
||||||
|
### 1. Lancer avec Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
L'API sera accessible sur `https://anime-tracker.holo795.fr` (ou le port configuré)
|
||||||
|
|
||||||
|
### 2. Vérifier le statut
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose ps
|
||||||
|
docker-compose logs -f api
|
||||||
|
```
|
||||||
|
|
||||||
|
## Développement local (sans Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Créer un environnement virtuel
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # Sur Windows: venv\Scripts\activate
|
||||||
|
|
||||||
|
# Installer les dépendances
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Lancer l'API
|
||||||
|
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation API
|
||||||
|
|
||||||
|
Une fois l'API lancée, accédez à :
|
||||||
|
- **Swagger UI**: https://anime-tracker.holo795.fr/docs
|
||||||
|
- **ReDoc**: https://anime-tracker.holo795.fr/redoc
|
||||||
|
|
||||||
|
## Endpoints principaux
|
||||||
|
|
||||||
|
### Authentification
|
||||||
|
|
||||||
|
- `POST /api/auth/register` - Inscription
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "mon_username",
|
||||||
|
"email": "email@example.com",
|
||||||
|
"password": "motdepasse123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `POST /api/auth/login` - Connexion
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email_or_username": "email@example.com",
|
||||||
|
"password": "motdepasse123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `GET /api/auth/me` - Informations utilisateur (nécessite authentification)
|
||||||
|
|
||||||
|
### Animés
|
||||||
|
|
||||||
|
- `GET /api/animes` - Liste des animés (nécessite authentification)
|
||||||
|
- `POST /api/animes` - Créer/mettre à jour un animé (nécessite authentification)
|
||||||
|
- `PUT /api/animes/{anime_id}` - Mettre à jour un animé (nécessite authentification)
|
||||||
|
- `DELETE /api/animes/{anime_id}` - Supprimer un animé (nécessite authentification)
|
||||||
|
- `POST /api/animes/sync` - Synchroniser plusieurs animés (nécessite authentification)
|
||||||
|
|
||||||
|
### Santé
|
||||||
|
|
||||||
|
- `GET /api/health` - Vérification de l'état
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
### Déploiement
|
||||||
|
|
||||||
|
1. Configurez les variables d'environnement sur votre serveur
|
||||||
|
2. Assurez-vous que PostgreSQL est accessible depuis le conteneur Docker
|
||||||
|
3. Utilisez un reverse proxy (nginx) avec HTTPS
|
||||||
|
4. Configurez les certificats SSL
|
||||||
|
|
||||||
|
### Sécurité
|
||||||
|
|
||||||
|
- ✅ Utilisez une `SECRET_KEY` forte et unique
|
||||||
|
- ✅ Utilisez HTTPS en production
|
||||||
|
- ✅ Configurez CORS pour limiter les origines autorisées
|
||||||
|
- ✅ Utilisez un mot de passe fort pour PostgreSQL
|
||||||
|
- ✅ Limitez les connexions réseau à PostgreSQL
|
||||||
|
|
||||||
|
### Exemple de configuration nginx
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name anime-tracker.holo795.fr;
|
||||||
|
|
||||||
|
ssl_certificate /path/to/cert.pem;
|
||||||
|
ssl_certificate_key /path/to/key.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Base de données
|
||||||
|
|
||||||
|
### Création de la base de données PostgreSQL
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE DATABASE anime_tracker;
|
||||||
|
CREATE USER anime_user WITH PASSWORD 'votre_mot_de_passe';
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE anime_tracker TO anime_user;
|
||||||
|
```
|
||||||
|
|
||||||
|
Les tables seront créées automatiquement au premier démarrage de l'API.
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
Si vous devez migrer depuis SQLite vers PostgreSQL :
|
||||||
|
|
||||||
|
1. Exportez les données depuis SQLite
|
||||||
|
2. Importez-les dans PostgreSQL
|
||||||
|
3. Mettez à jour `DATABASE_URL` dans `.env`
|
||||||
94
auth.py
Normal file
94
auth.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"""
|
||||||
|
Fonctions d'authentification et de sécurité
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models import User
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Configuration JWT
|
||||||
|
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production-min-32-chars")
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = 30 * 24 * 60 # 30 jours
|
||||||
|
|
||||||
|
# Configuration du hachage de mot de passe
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""Vérifier un mot de passe"""
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
"""Hacher un mot de passe"""
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
|
"""Créer un token JWT"""
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(token: str) -> Optional[dict]:
|
||||||
|
"""Vérifier et décoder un token JWT"""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
return payload
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
) -> User:
|
||||||
|
"""Obtenir l'utilisateur actuel à partir du token JWT"""
|
||||||
|
token = credentials.credentials
|
||||||
|
payload = verify_token(token)
|
||||||
|
|
||||||
|
if payload is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Token invalide ou expiré",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id: str = payload.get("sub")
|
||||||
|
if user_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Token invalide",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.id == int(user_id)).first()
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Utilisateur non trouvé",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
52
database.py
Normal file
52
database.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""
|
||||||
|
Configuration de la base de données
|
||||||
|
"""
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# URL de la base de données (PostgreSQL en production)
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://user:password@localhost:5432/anime_tracker")
|
||||||
|
|
||||||
|
# Configuration du moteur SQLAlchemy
|
||||||
|
if DATABASE_URL.startswith("sqlite"):
|
||||||
|
# SQLite pour développement local
|
||||||
|
engine = create_engine(
|
||||||
|
DATABASE_URL,
|
||||||
|
connect_args={"check_same_thread": False}
|
||||||
|
)
|
||||||
|
elif DATABASE_URL.startswith("postgresql"):
|
||||||
|
# PostgreSQL avec pool de connexions
|
||||||
|
engine = create_engine(
|
||||||
|
DATABASE_URL,
|
||||||
|
pool_pre_ping=True, # Vérifier les connexions avant utilisation
|
||||||
|
pool_recycle=3600, # Recycler les connexions après 1 heure
|
||||||
|
pool_size=10, # Taille du pool de connexions
|
||||||
|
max_overflow=20 # Nombre maximum de connexions supplémentaires
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
engine = create_engine(DATABASE_URL)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""Dépendance pour obtenir une session de base de données"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""Initialiser la base de données (créer les tables)"""
|
||||||
|
from models import User, Anime
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
18
docker-compose.yml
Normal file
18
docker-compose.yml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
container_name: anime_tracker_api
|
||||||
|
ports:
|
||||||
|
- "8000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=${DATABASE_URL:-postgresql://user:password@host:5432/anime_tracker}
|
||||||
|
- SECRET_KEY=${SECRET_KEY:-change-this-secret-key-in-production-min-32-characters-long}
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
8
env.example
Normal file
8
env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Clé secrète pour JWT (générer une clé aléatoire de 32+ caractères)
|
||||||
|
SECRET_KEY=your-secret-key-change-in-production-min-32-chars
|
||||||
|
|
||||||
|
# URL de la base de données PostgreSQL
|
||||||
|
# Format: postgresql://user:password@host:port/database
|
||||||
|
# Exemple avec service externe:
|
||||||
|
DATABASE_URL=postgresql://username:password@postgres.holo795.fr:5432/anime_tracker
|
||||||
|
|
||||||
264
main.py
Normal file
264
main.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
"""
|
||||||
|
API Anime Tracker - Synchronisation multi-appareils
|
||||||
|
"""
|
||||||
|
from fastapi import FastAPI, Depends, HTTPException, status
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, List
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from database import get_db, init_db
|
||||||
|
from models import User, Anime
|
||||||
|
from schemas import (
|
||||||
|
UserCreate, UserLogin, UserResponse, Token,
|
||||||
|
AnimeCreate, AnimeUpdate, AnimeResponse
|
||||||
|
)
|
||||||
|
from auth import (
|
||||||
|
verify_password, get_password_hash,
|
||||||
|
create_access_token, verify_token,
|
||||||
|
get_current_user
|
||||||
|
)
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Anime Tracker API",
|
||||||
|
description="API de synchronisation pour l'extension Anime Tracker",
|
||||||
|
version="1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS pour permettre les requêtes depuis l'extension Chrome
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # En production, spécifier les domaines autorisés
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
# Initialiser la base de données au démarrage
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== AUTHENTIFICATION ====================
|
||||||
|
|
||||||
|
@app.post("/api/auth/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||||
|
"""Inscription d'un nouvel utilisateur"""
|
||||||
|
# Vérifier si l'utilisateur existe déjà
|
||||||
|
existing_user = db.query(User).filter(User.email == user_data.email).first()
|
||||||
|
if existing_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Un utilisateur avec cet email existe déjà"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Vérifier si le nom d'utilisateur existe déjà
|
||||||
|
existing_username = db.query(User).filter(User.username == user_data.username).first()
|
||||||
|
if existing_username:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Ce nom d'utilisateur est déjà pris"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Créer le nouvel utilisateur
|
||||||
|
hashed_password = get_password_hash(user_data.password)
|
||||||
|
new_user = User(
|
||||||
|
username=user_data.username,
|
||||||
|
email=user_data.email,
|
||||||
|
hashed_password=hashed_password
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(new_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_user)
|
||||||
|
|
||||||
|
return UserResponse(
|
||||||
|
id=new_user.id,
|
||||||
|
username=new_user.username,
|
||||||
|
email=new_user.email,
|
||||||
|
created_at=new_user.created_at
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/login", response_model=Token)
|
||||||
|
async def login(credentials: UserLogin, db: Session = Depends(get_db)):
|
||||||
|
"""Connexion d'un utilisateur"""
|
||||||
|
# Trouver l'utilisateur par email ou username
|
||||||
|
user = db.query(User).filter(
|
||||||
|
(User.email == credentials.email_or_username) |
|
||||||
|
(User.username == credentials.email_or_username)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not user or not verify_password(credentials.password, user.hashed_password):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Email/username ou mot de passe incorrect",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Créer le token JWT
|
||||||
|
access_token = create_access_token(data={"sub": str(user.id)})
|
||||||
|
|
||||||
|
return Token(access_token=access_token, token_type="bearer")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/auth/me", response_model=UserResponse)
|
||||||
|
async def get_current_user_info(current_user: User = Depends(get_current_user)):
|
||||||
|
"""Récupérer les informations de l'utilisateur connecté"""
|
||||||
|
return UserResponse(
|
||||||
|
id=current_user.id,
|
||||||
|
username=current_user.username,
|
||||||
|
email=current_user.email,
|
||||||
|
created_at=current_user.created_at
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== ANIMES ====================
|
||||||
|
|
||||||
|
@app.get("/api/animes", response_model=List[AnimeResponse])
|
||||||
|
async def get_animes(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Récupérer tous les animés de l'utilisateur"""
|
||||||
|
animes = db.query(Anime).filter(Anime.user_id == current_user.id).all()
|
||||||
|
return [AnimeResponse.from_orm(anime) for anime in animes]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/animes", response_model=AnimeResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_anime(
|
||||||
|
anime_data: AnimeCreate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Créer ou mettre à jour un animé"""
|
||||||
|
# Vérifier si l'animé existe déjà pour cet utilisateur
|
||||||
|
existing_anime = db.query(Anime).filter(
|
||||||
|
Anime.user_id == current_user.id,
|
||||||
|
Anime.anime_id == anime_data.anime_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_anime:
|
||||||
|
# Mettre à jour l'animé existant
|
||||||
|
for key, value in anime_data.dict(exclude_unset=True).items():
|
||||||
|
setattr(existing_anime, key, value)
|
||||||
|
existing_anime.last_updated = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(existing_anime)
|
||||||
|
return AnimeResponse.from_orm(existing_anime)
|
||||||
|
else:
|
||||||
|
# Créer un nouvel animé
|
||||||
|
new_anime = Anime(
|
||||||
|
user_id=current_user.id,
|
||||||
|
**anime_data.dict()
|
||||||
|
)
|
||||||
|
db.add(new_anime)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_anime)
|
||||||
|
return AnimeResponse.from_orm(new_anime)
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/animes/{anime_id}", response_model=AnimeResponse)
|
||||||
|
async def update_anime(
|
||||||
|
anime_id: str,
|
||||||
|
anime_data: AnimeUpdate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Mettre à jour un animé spécifique"""
|
||||||
|
anime = db.query(Anime).filter(
|
||||||
|
Anime.user_id == current_user.id,
|
||||||
|
Anime.anime_id == anime_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not anime:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Animé non trouvé"
|
||||||
|
)
|
||||||
|
|
||||||
|
for key, value in anime_data.dict(exclude_unset=True).items():
|
||||||
|
setattr(anime, key, value)
|
||||||
|
|
||||||
|
anime.last_updated = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(anime)
|
||||||
|
|
||||||
|
return AnimeResponse.from_orm(anime)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/animes/{anime_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_anime(
|
||||||
|
anime_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Supprimer un animé"""
|
||||||
|
anime = db.query(Anime).filter(
|
||||||
|
Anime.user_id == current_user.id,
|
||||||
|
Anime.anime_id == anime_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not anime:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Animé non trouvé"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.delete(anime)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/animes/sync", response_model=List[AnimeResponse])
|
||||||
|
async def sync_animes(
|
||||||
|
animes_data: List[AnimeCreate],
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Synchroniser plusieurs animés en une seule requête"""
|
||||||
|
synced_animes = []
|
||||||
|
|
||||||
|
for anime_data in animes_data:
|
||||||
|
existing_anime = db.query(Anime).filter(
|
||||||
|
Anime.user_id == current_user.id,
|
||||||
|
Anime.anime_id == anime_data.anime_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_anime:
|
||||||
|
# Mettre à jour
|
||||||
|
for key, value in anime_data.dict(exclude_unset=True).items():
|
||||||
|
setattr(existing_anime, key, value)
|
||||||
|
existing_anime.last_updated = datetime.utcnow()
|
||||||
|
else:
|
||||||
|
# Créer
|
||||||
|
new_anime = Anime(
|
||||||
|
user_id=current_user.id,
|
||||||
|
**anime_data.dict()
|
||||||
|
)
|
||||||
|
db.add(new_anime)
|
||||||
|
existing_anime = new_anime
|
||||||
|
|
||||||
|
synced_animes.append(existing_anime)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
for anime in synced_animes:
|
||||||
|
db.refresh(anime)
|
||||||
|
|
||||||
|
return [AnimeResponse.from_orm(anime) for anime in synced_animes]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health_check():
|
||||||
|
"""Vérification de l'état de l'API"""
|
||||||
|
return {"status": "ok", "message": "API Anime Tracker is running"}
|
||||||
|
|
||||||
42
models.py
Normal file
42
models.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
Modèles de base de données
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Text, Float, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||||
|
email = Column(String(100), unique=True, index=True, nullable=False)
|
||||||
|
hashed_password = Column(String(255), nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
animes = relationship("Anime", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class Anime(Base):
|
||||||
|
__tablename__ = "animes"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
anime_id = Column(String(100), nullable=False, index=True) # ID unique de l'animé (généré par l'extension)
|
||||||
|
title = Column(String(255), nullable=False)
|
||||||
|
current_episode = Column(Integer, default=1)
|
||||||
|
total_episodes = Column(Integer, nullable=True)
|
||||||
|
last_url = Column(Text, nullable=True)
|
||||||
|
source = Column(String(50), nullable=True) # 'crunchyroll' ou 'voiranime'
|
||||||
|
status = Column(String(20), default='en_cours') # 'en_cours' ou 'termine'
|
||||||
|
comment = Column(Text, nullable=True)
|
||||||
|
rating = Column(Float, default=0.0)
|
||||||
|
anilist_id = Column(Integer, nullable=True)
|
||||||
|
cover_image = Column(Text, nullable=True)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
last_updated = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="animes")
|
||||||
|
|
||||||
11
requirements.txt
Normal file
11
requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
sqlalchemy==2.0.23
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
pydantic==2.5.0
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
python-multipart==0.0.6
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
|
||||||
89
schemas.py
Normal file
89
schemas.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""
|
||||||
|
Schémas Pydantic pour la validation des données
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== USER SCHEMAS ====================
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
username: str = Field(..., min_length=3, max_length=50)
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(..., min_length=6)
|
||||||
|
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
email_or_username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== AUTH SCHEMAS ====================
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== ANIME SCHEMAS ====================
|
||||||
|
|
||||||
|
class AnimeCreate(BaseModel):
|
||||||
|
anime_id: str
|
||||||
|
title: str
|
||||||
|
current_episode: int = 1
|
||||||
|
total_episodes: Optional[int] = None
|
||||||
|
last_url: Optional[str] = None
|
||||||
|
source: Optional[str] = None
|
||||||
|
status: str = "en_cours"
|
||||||
|
comment: Optional[str] = None
|
||||||
|
rating: float = 0.0
|
||||||
|
anilist_id: Optional[int] = None
|
||||||
|
cover_image: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AnimeUpdate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
current_episode: Optional[int] = None
|
||||||
|
total_episodes: Optional[int] = None
|
||||||
|
last_url: Optional[str] = None
|
||||||
|
source: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
comment: Optional[str] = None
|
||||||
|
rating: Optional[float] = None
|
||||||
|
anilist_id: Optional[int] = None
|
||||||
|
cover_image: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AnimeResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
anime_id: str
|
||||||
|
title: str
|
||||||
|
current_episode: int
|
||||||
|
total_episodes: Optional[int]
|
||||||
|
last_url: Optional[str]
|
||||||
|
source: Optional[str]
|
||||||
|
status: str
|
||||||
|
comment: Optional[str]
|
||||||
|
rating: float
|
||||||
|
anilist_id: Optional[int]
|
||||||
|
cover_image: Optional[str]
|
||||||
|
description: Optional[str]
|
||||||
|
last_updated: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
Reference in New Issue
Block a user