Initial commit
This commit is contained in:
30
backend/app/api/v1/__init__.py
Normal file
30
backend/app/api/v1/__init__.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Routes API v1 de l'application."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .predictions import router as predictions_router
|
||||
from .users import router as users_router
|
||||
from .backtesting import router as backtesting_router
|
||||
from .user_predictions import router as user_predictions_router
|
||||
from .badges import router as badges_router
|
||||
from .leaderboard import router as leaderboard_router
|
||||
# #region agent log
|
||||
def write_debug_log(hypothesisId: str, location: str, message: str, data: dict = None):
|
||||
"""Écrit un log NDJSON pour le debug."""
|
||||
import json
|
||||
from datetime import datetime
|
||||
log_entry = {
|
||||
"sessionId": "debug-session",
|
||||
"runId": "run1",
|
||||
"hypothesisId": hypothesisId,
|
||||
"location": location,
|
||||
"message": message,
|
||||
"data": data or {},
|
||||
"timestamp": datetime.now().timestamp() * 1000
|
||||
}
|
||||
with open(r"d:\\dev_new_pc\\chartbastan\\.cursor\\debug.log", "a") as f:
|
||||
f.write(json.dumps(log_entry) + "\n")
|
||||
write_debug_log("C", "__init__.py:20", "API v1 routers initialized without main v1 router", {"routers": ["users", "auth", "predictions", "backtesting", "leaderboard", "badges", "user_predictions"]})
|
||||
# #endregion
|
||||
|
||||
__all__ = ["predictions_router", "users_router", "backtesting_router", "user_predictions_router", "badges_router", "leaderboard_router", "auth_router"]
|
||||
261
backend/app/api/v1/auth.py
Normal file
261
backend/app/api/v1/auth.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
Authentication API Endpoints.
|
||||
|
||||
This module provides endpoints for user authentication including
|
||||
login, registration, and logout.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.badge import Badge, UserBadge
|
||||
from app.schemas.auth import (
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
AuthResponse,
|
||||
ErrorResponse
|
||||
)
|
||||
|
||||
# #region agent log
|
||||
def write_debug_log(hypothesisId: str, location: str, message: str, data: dict = None):
|
||||
"""Écrit un log NDJSON pour le debug."""
|
||||
import json
|
||||
log_entry = {
|
||||
"sessionId": "debug-session",
|
||||
"runId": "run1",
|
||||
"hypothesisId": hypothesisId,
|
||||
"location": location,
|
||||
"message": message,
|
||||
"data": data or {},
|
||||
"timestamp": datetime.now().timestamp() * 1000
|
||||
}
|
||||
with open(r"d:\\dev_new_pc\\chartbastan\\.cursor\\debug.log", "a") as f:
|
||||
f.write(json.dumps(log_entry) + "\n")
|
||||
# #endregion
|
||||
|
||||
# Configuration du hashage de mot de passe
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
router = APIRouter(tags=["authentication"])
|
||||
|
||||
# #region agent log
|
||||
write_debug_log("C", "auth.py:29", "Auth router initialized", {"prefix": "/api/v1/auth", "tags": ["authentication"]})
|
||||
# #endregion
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Vérifie un mot de passe en clair contre le hash"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Génère un hash sécurisé pour un mot de passe"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def generate_referral_code() -> str:
|
||||
"""Génère un code de parrainage unique de 8 caractères"""
|
||||
import secrets
|
||||
import string
|
||||
alphabet = string.ascii_uppercase + string.digits
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(8))
|
||||
|
||||
|
||||
@router.post("/login", response_model=AuthResponse, responses={401: {"model": ErrorResponse}})
|
||||
def login_user(
|
||||
request: LoginRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Connecter un utilisateur avec email et mot de passe.
|
||||
|
||||
Args:
|
||||
request: Email et mot de passe de l'utilisateur
|
||||
db: Session de base de données
|
||||
|
||||
Returns:
|
||||
AuthResponse avec token et données utilisateur
|
||||
|
||||
Raises:
|
||||
401: Si email ou mot de passe incorrect
|
||||
500: Si erreur serveur
|
||||
"""
|
||||
# #region agent log
|
||||
write_debug_log("D", "auth.py:48", "Login endpoint called", {"email": request.email})
|
||||
# #endregion
|
||||
|
||||
user = db.query(User).filter(User.email == request.email).first()
|
||||
|
||||
# #region agent log
|
||||
write_debug_log("D", "auth.py:67", "User lookup", {"email": request.email, "user_found": user is not None})
|
||||
# #endregion
|
||||
|
||||
# Vérifier si l'utilisateur existe
|
||||
if not user:
|
||||
# #region agent log
|
||||
write_debug_log("D", "auth.py:70", "User not found", {"email": request.email})
|
||||
# #endregion
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Email ou mot de passe incorrect"
|
||||
)
|
||||
|
||||
# Vérifier le mot de passe
|
||||
if not user.password_hash:
|
||||
# #region agent log
|
||||
write_debug_log("D", "auth.py:81", "No password hash", {"email": request.email, "user_id": user.id})
|
||||
# #endregion
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Email ou mot de passe incorrect"
|
||||
)
|
||||
|
||||
password_valid = verify_password(request.password, user.password_hash)
|
||||
# #region agent log
|
||||
write_debug_log("D", "auth.py:91", "Password verification", {"email": request.email, "user_id": user.id, "password_valid": password_valid})
|
||||
# #endregion
|
||||
|
||||
if not password_valid:
|
||||
# #region agent log
|
||||
write_debug_log("D", "auth.py:96", "Invalid password", {"email": request.email, "user_id": user.id})
|
||||
# #endregion
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Email ou mot de passe incorrect"
|
||||
)
|
||||
|
||||
# Retourner les données utilisateur (sans le hash de mot de passe)
|
||||
# #region agent log
|
||||
write_debug_log("D", "auth.py:107", "Login successful", {"email": request.email, "user_id": user.id})
|
||||
# #endregion
|
||||
return {
|
||||
"data": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"is_premium": user.is_premium,
|
||||
"referral_code": user.referral_code,
|
||||
"created_at": user.created_at,
|
||||
},
|
||||
"meta": {
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"version": "v1"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
|
||||
def register_user(
|
||||
request: RegisterRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Inscrire un nouvel utilisateur.
|
||||
|
||||
Args:
|
||||
request: Email, mot de passe, nom optionnel et code de parrainage
|
||||
db: Session de base de données
|
||||
|
||||
Returns:
|
||||
AuthResponse avec données utilisateur
|
||||
|
||||
Raises:
|
||||
400: Si validation échoue
|
||||
409: Si email déjà utilisé
|
||||
500: Si erreur serveur
|
||||
"""
|
||||
# Vérifier si l'email existe déjà
|
||||
existing_user = db.query(User).filter(User.email == request.email).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Cet email est déjà utilisé"
|
||||
)
|
||||
|
||||
# Valider le code de parrainage si fourni
|
||||
referrer: Optional[User] = None
|
||||
if request.referral_code:
|
||||
referrer = db.query(User).filter(
|
||||
User.referral_code == request.referral_code
|
||||
).first()
|
||||
if not referrer:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Code de parrainage invalide"
|
||||
)
|
||||
|
||||
# Générer un code de parrainage unique
|
||||
referral_code = generate_referral_code()
|
||||
while db.query(User).filter(User.referral_code == referral_code).first():
|
||||
referral_code = generate_referral_code()
|
||||
|
||||
# Hasher le mot de passe
|
||||
password_hash = get_password_hash(request.password)
|
||||
|
||||
# Créer l'utilisateur
|
||||
new_user = User(
|
||||
email=request.email,
|
||||
password_hash=password_hash,
|
||||
name=request.name,
|
||||
is_premium=False,
|
||||
referral_code=referral_code,
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
# Attribuer le badge de bienvenue (première connexion)
|
||||
welcome_badge = db.query(Badge).filter(
|
||||
Badge.badge_id == "first_login"
|
||||
).first()
|
||||
|
||||
if welcome_badge:
|
||||
user_badge = UserBadge(
|
||||
user_id=new_user.id,
|
||||
badge_id=welcome_badge.id,
|
||||
unlocked_at=datetime.utcnow()
|
||||
)
|
||||
db.add(user_badge)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"id": new_user.id,
|
||||
"email": new_user.email,
|
||||
"name": new_user.name,
|
||||
"is_premium": new_user.is_premium,
|
||||
"referral_code": new_user.referral_code,
|
||||
"created_at": new_user.created_at,
|
||||
},
|
||||
"meta": {
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"version": "v1"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout_user():
|
||||
"""
|
||||
Déconnecter l'utilisateur.
|
||||
|
||||
Note: Le backend utilise des cookies côté client.
|
||||
Cette endpoint est disponible pour compatibilité future avec JWT.
|
||||
"""
|
||||
return {
|
||||
"data": {
|
||||
"message": "Déconnexion réussie"
|
||||
},
|
||||
"meta": {
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"version": "v1"
|
||||
}
|
||||
}
|
||||
177
backend/app/api/v1/backtesting.py
Normal file
177
backend/app/api/v1/backtesting.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Backtesting API Endpoints.
|
||||
|
||||
This module provides API endpoints for running backtesting
|
||||
on historical match data and exporting results.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.services.backtesting_service import BacktestingService
|
||||
from app.schemas.backtesting import (
|
||||
BacktestingRequest,
|
||||
BacktestingResponse,
|
||||
BacktestingErrorResponse
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/backtesting", tags=["backtesting"])
|
||||
|
||||
|
||||
@router.post("/run", response_model=BacktestingResponse, responses={400: {"model": BacktestingErrorResponse}})
|
||||
def run_backtesting(
|
||||
request: BacktestingRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Run backtesting on historical matches.
|
||||
|
||||
Analyzes historical match predictions against actual results to calculate
|
||||
accuracy metrics and validate prediction system performance.
|
||||
|
||||
- **leagues**: Optional list of leagues to filter by (e.g., ['Ligue 1', 'Premier League'])
|
||||
- **start_date**: Optional start date for filtering matches (ISO 8601 format)
|
||||
- **end_date**: Optional end date for filtering matches (ISO 8601 format)
|
||||
- **export_format**: Optional export format ('json', 'csv', 'html')
|
||||
|
||||
Returns:
|
||||
Comprehensive backtesting report including:
|
||||
- Total matches analyzed
|
||||
- Correct/incorrect predictions
|
||||
- Overall accuracy percentage
|
||||
- Validation status (VALIDATED, REVISION_REQUIRED, BELOW_TARGET)
|
||||
- Detailed results per match
|
||||
- Metrics breakdown by league
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"leagues": ["Ligue 1", "Premier League"],
|
||||
"start_date": "2025-01-01T00:00:00Z",
|
||||
"end_date": "2025-12-31T23:59:59Z",
|
||||
"export_format": "html"
|
||||
}
|
||||
```
|
||||
|
||||
Raises:
|
||||
400: If no matches found with specified filters
|
||||
500: If internal server error occurs
|
||||
"""
|
||||
try:
|
||||
logger.info("Backtesting request received")
|
||||
|
||||
# Parse dates if provided
|
||||
start_date = None
|
||||
end_date = None
|
||||
|
||||
if request.start_date:
|
||||
try:
|
||||
start_date = datetime.fromisoformat(request.start_date.replace('Z', '+00:00'))
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid start_date format. Use ISO 8601 format: {str(e)}"
|
||||
)
|
||||
|
||||
if request.end_date:
|
||||
try:
|
||||
end_date = datetime.fromisoformat(request.end_date.replace('Z', '+00:00'))
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid end_date format. Use ISO 8601 format: {str(e)}"
|
||||
)
|
||||
|
||||
# Initialize backtesting service
|
||||
backtesting_service = BacktestingService(db)
|
||||
|
||||
# Run backtesting
|
||||
result = backtesting_service.run_backtesting(
|
||||
leagues=request.leagues,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Backtesting completed: {result['total_matches']} matches, "
|
||||
f"{result['accuracy']:.2f}% accuracy"
|
||||
)
|
||||
|
||||
# Export results if format specified
|
||||
if request.export_format:
|
||||
try:
|
||||
exported = backtesting_service.export_results(result, request.export_format)
|
||||
result['export'] = {
|
||||
'format': request.export_format,
|
||||
'data': exported
|
||||
}
|
||||
except ValueError as e:
|
||||
logger.warning(f"Export failed: {str(e)}")
|
||||
# Continue without export, just warn
|
||||
|
||||
# Wrap response with metadata
|
||||
response = {
|
||||
'data': result,
|
||||
'meta': {
|
||||
'timestamp': result.get('timestamp'),
|
||||
'version': 'v1'
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Backtesting value error: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Backtesting error: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Internal server error during backtesting: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def get_backtesting_status():
|
||||
"""
|
||||
Get backtesting service status.
|
||||
|
||||
Returns information about the backtesting system configuration
|
||||
and validation thresholds.
|
||||
|
||||
Returns:
|
||||
Status information including:
|
||||
- Available leagues
|
||||
- Validation thresholds
|
||||
- Service health status
|
||||
"""
|
||||
from app.ml.backtesting import (
|
||||
ACCURACY_VALIDATED_THRESHOLD,
|
||||
ACCURACY_ALERT_THRESHOLD
|
||||
)
|
||||
|
||||
return {
|
||||
'data': {
|
||||
'status': 'operational',
|
||||
'validation_thresholds': {
|
||||
'validated_threshold': ACCURACY_VALIDATED_THRESHOLD,
|
||||
'alert_threshold': ACCURACY_ALERT_THRESHOLD
|
||||
},
|
||||
'supported_export_formats': ['json', 'csv', 'html'],
|
||||
'available_filters': {
|
||||
'leagues': True,
|
||||
'date_range': True
|
||||
}
|
||||
},
|
||||
'meta': {
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'version': 'v1'
|
||||
}
|
||||
}
|
||||
166
backend/app/api/v1/badges.py
Normal file
166
backend/app/api/v1/badges.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
API endpoints pour les badges
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.badge import Badge, UserBadge
|
||||
from app.models.user import User
|
||||
from app.schemas.badge import (
|
||||
BadgeResponse,
|
||||
BadgeListResponse,
|
||||
BadgeCheckResponse,
|
||||
UserBadgeResponse,
|
||||
UserBadgeListResponse,
|
||||
BadgeUnlockRequest,
|
||||
)
|
||||
from app.services.badge_service import BadgeService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=BadgeListResponse)
|
||||
def get_all_badges(
|
||||
db: Session = Depends(get_db),
|
||||
user_id: Optional[int] = None,
|
||||
):
|
||||
"""
|
||||
Récupère tous les badges disponibles
|
||||
- Indique les badges débloqués par l'utilisateur si user_id est fourni
|
||||
- Retourne les critères de débloquage
|
||||
"""
|
||||
# Récupérer tous les badges
|
||||
badges = db.query(Badge).all()
|
||||
|
||||
# Si user_id est fourni, récupérer les badges débloqués
|
||||
unlocked_badge_ids = set()
|
||||
if user_id:
|
||||
unlocked_badges = db.query(UserBadge).filter(UserBadge.user_id == user_id).all()
|
||||
unlocked_badge_ids = set(ub.badge_id for ub in unlocked_badges)
|
||||
|
||||
# Formater la réponse
|
||||
badge_responses = []
|
||||
for badge in badges:
|
||||
badge_responses.append(
|
||||
BadgeResponse(
|
||||
id=badge.id,
|
||||
badgeId=badge.badge_id,
|
||||
name=badge.name,
|
||||
description=badge.description,
|
||||
icon=badge.icon,
|
||||
category=badge.category,
|
||||
criteriaType=badge.criteria_type,
|
||||
criteriaValue=badge.criteria_value,
|
||||
criteriaDescription=badge.criteria_description,
|
||||
rarity=badge.rarity,
|
||||
points=badge.points,
|
||||
createdAt=badge.created_at.isoformat(),
|
||||
unlocked=badge.badge_id in unlocked_badge_ids if user_id else None,
|
||||
)
|
||||
)
|
||||
|
||||
return BadgeListResponse(
|
||||
data=badge_responses,
|
||||
meta={
|
||||
"count": len(badge_responses),
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"version": "v1"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/check", response_model=BadgeCheckResponse)
|
||||
def check_badges(
|
||||
request: BadgeUnlockRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Vérifie et débloque les nouveaux badges pour un utilisateur
|
||||
- Vérifie les critères de badges de l'utilisateur
|
||||
- Débloque les nouveaux badges atteints
|
||||
- Retourne les badges débloqués
|
||||
- Envoie les notifications
|
||||
"""
|
||||
# Vérifier que l'utilisateur existe
|
||||
user = db.query(User).filter(User.id == request.userId).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Utilisateur non trouvé"
|
||||
)
|
||||
|
||||
# Utiliser le service pour vérifier et débloquer les badges
|
||||
badge_service = BadgeService(db)
|
||||
result = badge_service.check_and_unlock_badges(request.userId)
|
||||
|
||||
return BadgeCheckResponse(
|
||||
data={
|
||||
"new_badges": result["new_badges"],
|
||||
"total_badges": result["total_badges"],
|
||||
"message": result["message"],
|
||||
},
|
||||
meta={
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"version": "v1"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users/{user_id}", response_model=UserBadgeListResponse)
|
||||
def get_user_badges(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Récupère tous les badges débloqués par un utilisateur
|
||||
"""
|
||||
# Vérifier que l'utilisateur existe
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Utilisateur non trouvé"
|
||||
)
|
||||
|
||||
# Récupérer les badges débloqués
|
||||
user_badges = db.query(UserBadge).filter(UserBadge.user_id == user_id).all()
|
||||
|
||||
# Formater la réponse
|
||||
badge_responses = []
|
||||
for user_badge in user_badges:
|
||||
badge = db.query(Badge).filter(Badge.id == user_badge.badge_id).first()
|
||||
if badge:
|
||||
badge_responses.append(
|
||||
UserBadgeResponse(
|
||||
id=user_badge.id,
|
||||
userId=user_badge.user_id,
|
||||
badgeId=user_badge.badge_id,
|
||||
unlockedAt=user_badge.unlocked_at.isoformat(),
|
||||
badge={
|
||||
"id": badge.id,
|
||||
"badgeId": badge.badge_id,
|
||||
"name": badge.name,
|
||||
"description": badge.description,
|
||||
"icon": badge.icon,
|
||||
"category": badge.category,
|
||||
"criteriaType": badge.criteria_type,
|
||||
"criteriaValue": badge.criteria_value,
|
||||
"criteriaDescription": badge.criteria_description,
|
||||
"rarity": badge.rarity,
|
||||
"points": badge.points,
|
||||
"createdAt": badge.created_at.isoformat(),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
return UserBadgeListResponse(
|
||||
data={"badges": badge_responses},
|
||||
meta={
|
||||
"count": len(badge_responses),
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"version": "v1"
|
||||
}
|
||||
)
|
||||
142
backend/app/api/v1/leaderboard.py
Normal file
142
backend/app/api/v1/leaderboard.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Leaderboard API Routes.
|
||||
|
||||
This module provides REST endpoints for retrieving user rankings
|
||||
and leaderboards based on prediction accuracy.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.services.leaderboard_service import LeaderboardService
|
||||
from app.schemas.leaderboard import LeaderboardResponse, LeaderboardEntry, PersonalRankData
|
||||
|
||||
router = APIRouter(prefix="/api/v1/leaderboard", tags=["leaderboard"])
|
||||
|
||||
|
||||
@router.get("", response_model=LeaderboardResponse)
|
||||
def get_leaderboard(
|
||||
limit: int = Query(100, ge=1, le=100, description="Maximum number of users to return (max 100)"),
|
||||
user_id: Optional[int] = Query(None, description="Current user ID to include personal rank data"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get leaderboard with top 100 users sorted by accuracy.
|
||||
|
||||
This endpoint retrieves the top users based on their prediction accuracy
|
||||
and includes personal rank data if user_id is provided.
|
||||
|
||||
Ranking criteria:
|
||||
- Primary: Accuracy percentage (higher is better)
|
||||
- Secondary: Number of predictions viewed (more is better, used as tie-breaker)
|
||||
|
||||
Args:
|
||||
limit: Maximum number of users to return (1-100, default: 100)
|
||||
user_id: Optional current user ID to include personal rank data
|
||||
db: Database session (injected)
|
||||
|
||||
Returns:
|
||||
Leaderboard with top users and optional personal rank data
|
||||
|
||||
Raises:
|
||||
404: If user_id provided but user doesn't exist
|
||||
|
||||
Example Request:
|
||||
GET /api/v1/leaderboard
|
||||
GET /api/v1/leaderboard?user_id=1
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"user_id": 1,
|
||||
"username": "JohnDoe",
|
||||
"accuracy": 95.5,
|
||||
"predictions_count": 100
|
||||
},
|
||||
{
|
||||
"user_id": 2,
|
||||
"username": "JaneSmith",
|
||||
"accuracy": 90.0,
|
||||
"predictions_count": 85
|
||||
}
|
||||
],
|
||||
"personal_data": {
|
||||
"rank": 42,
|
||||
"accuracy": 75.5,
|
||||
"predictions_count": 25
|
||||
},
|
||||
"meta": {
|
||||
"total": 2,
|
||||
"limit": 100,
|
||||
"timestamp": "2026-01-18T10:30:00Z",
|
||||
"version": "v1"
|
||||
}
|
||||
}
|
||||
"""
|
||||
service = LeaderboardService(db)
|
||||
leaderboard = service.get_leaderboard(limit=limit)
|
||||
|
||||
# Get personal rank if user_id provided
|
||||
personal_data = None
|
||||
if user_id:
|
||||
personal_rank = service.get_personal_rank(user_id)
|
||||
if personal_rank:
|
||||
personal_data = PersonalRankData(**personal_rank)
|
||||
|
||||
# Format leaderboard entries
|
||||
entries = [LeaderboardEntry(**entry) for entry in leaderboard]
|
||||
|
||||
return {
|
||||
"data": entries,
|
||||
"personal_data": personal_data,
|
||||
"meta": {
|
||||
"total": len(entries),
|
||||
"limit": limit,
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
"version": "v1"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/personal/{user_id}", response_model=PersonalRankData)
|
||||
def get_personal_rank(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get personal rank data for a specific user.
|
||||
|
||||
Args:
|
||||
user_id: ID of the user
|
||||
db: Database session (injected)
|
||||
|
||||
Returns:
|
||||
Personal rank data with rank, accuracy, and predictions count
|
||||
|
||||
Raises:
|
||||
404: If user doesn't exist or has no completed predictions
|
||||
|
||||
Example Request:
|
||||
GET /api/v1/leaderboard/personal/1
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"rank": 42,
|
||||
"accuracy": 75.5,
|
||||
"predictions_count": 25
|
||||
}
|
||||
"""
|
||||
service = LeaderboardService(db)
|
||||
personal_rank = service.get_personal_rank(user_id)
|
||||
|
||||
if not personal_rank:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User {user_id} not found or has no completed predictions"
|
||||
)
|
||||
|
||||
return PersonalRankData(**personal_rank)
|
||||
425
backend/app/api/v1/predictions.py
Normal file
425
backend/app/api/v1/predictions.py
Normal file
@@ -0,0 +1,425 @@
|
||||
"""
|
||||
Prediction API Routes.
|
||||
|
||||
This module provides REST endpoints for creating and retrieving match predictions.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.services.prediction_service import PredictionService
|
||||
from app.schemas.prediction import PredictionListResponse, PredictionResponse
|
||||
|
||||
router = APIRouter(prefix="/api/v1/predictions", tags=["predictions"])
|
||||
|
||||
|
||||
@router.get("", response_model=PredictionListResponse)
|
||||
def get_predictions(
|
||||
limit: int = Query(20, ge=1, le=100, description="Maximum number of predictions to return (max 100)"),
|
||||
offset: int = Query(0, ge=0, description="Number of predictions to skip"),
|
||||
team_id: Optional[int] = Query(None, description="Filter by team ID"),
|
||||
league: Optional[str] = Query(None, description="Filter by league name (case-insensitive)"),
|
||||
date_min: Optional[datetime] = Query(None, description="Filter for matches after this date (ISO 8601)"),
|
||||
date_max: Optional[datetime] = Query(None, description="Filter for matches before this date (ISO 8601)"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get all predictions with pagination and filters.
|
||||
|
||||
This endpoint retrieves predictions joined with match data, applies optional filters,
|
||||
and returns paginated results sorted by match date (upcoming matches first).
|
||||
|
||||
Args:
|
||||
limit: Maximum number of predictions to return (1-100, default: 20)
|
||||
offset: Number of predictions to skip (default: 0)
|
||||
team_id: Optional filter by team ID (matches where team is home or away)
|
||||
league: Optional filter by league name (case-insensitive partial match)
|
||||
date_min: Optional filter for matches after this date
|
||||
date_max: Optional filter for matches before this date
|
||||
db: Database session (injected)
|
||||
|
||||
Returns:
|
||||
Paginated list of predictions with match details and metadata
|
||||
|
||||
Example Requests:
|
||||
GET /api/v1/predictions
|
||||
GET /api/v1/predictions?limit=10&offset=0
|
||||
GET /api/v1/predictions?league=Ligue%201
|
||||
GET /api/v1/predictions?team_id=1&limit=5
|
||||
GET /api/v1/predictions?date_min=2026-01-15T00:00:00Z&date_max=2026-01-20T23:59:59Z
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"match_id": 1,
|
||||
"match": {
|
||||
"id": 1,
|
||||
"home_team": "PSG",
|
||||
"away_team": "Olympique de Marseille",
|
||||
"date": "2026-01-18T20:00:00Z",
|
||||
"league": "Ligue 1",
|
||||
"status": "scheduled"
|
||||
},
|
||||
"energy_score": "high",
|
||||
"confidence": "65.0%",
|
||||
"predicted_winner": "PSG",
|
||||
"created_at": "2026-01-17T12:00:00Z"
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"total": 45,
|
||||
"limit": 20,
|
||||
"offset": 0,
|
||||
"timestamp": "2026-01-17T14:30:00Z",
|
||||
"version": "v1"
|
||||
}
|
||||
}
|
||||
"""
|
||||
service = PredictionService(db)
|
||||
predictions, total = service.get_predictions_with_pagination(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
team_id=team_id,
|
||||
league=league,
|
||||
date_min=date_min,
|
||||
date_max=date_max
|
||||
)
|
||||
|
||||
# Build response with match details
|
||||
prediction_responses = []
|
||||
for prediction in predictions:
|
||||
match = prediction.match
|
||||
prediction_responses.append({
|
||||
"id": prediction.id,
|
||||
"match_id": prediction.match_id,
|
||||
"match": {
|
||||
"id": match.id,
|
||||
"home_team": match.home_team,
|
||||
"away_team": match.away_team,
|
||||
"date": match.date.isoformat() if match.date else None,
|
||||
"league": match.league,
|
||||
"status": match.status
|
||||
},
|
||||
"energy_score": prediction.energy_score,
|
||||
"confidence": prediction.confidence,
|
||||
"predicted_winner": prediction.predicted_winner,
|
||||
"created_at": prediction.created_at.isoformat() if prediction.created_at else None
|
||||
})
|
||||
|
||||
return {
|
||||
"data": prediction_responses,
|
||||
"meta": {
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
"version": "v1"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/match/{match_id}")
|
||||
def get_prediction_by_match_id(
|
||||
match_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get prediction details for a specific match.
|
||||
|
||||
This endpoint retrieves the latest prediction for a match and includes
|
||||
full match details, energy score information, and historical predictions.
|
||||
|
||||
Args:
|
||||
match_id: ID of the match
|
||||
db: Database session (injected)
|
||||
|
||||
Returns:
|
||||
Prediction with full details including match info and history
|
||||
|
||||
Raises:
|
||||
404: If match or prediction not found
|
||||
|
||||
Example Request:
|
||||
GET /api/v1/predictions/match/1
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"id": 3,
|
||||
"match_id": 1,
|
||||
"match": {
|
||||
"id": 1,
|
||||
"home_team": "PSG",
|
||||
"away_team": "Olympique de Marseille",
|
||||
"date": "2026-01-18T20:00:00Z",
|
||||
"league": "Ligue 1",
|
||||
"status": "scheduled",
|
||||
"actual_winner": null
|
||||
},
|
||||
"energy_score": "high",
|
||||
"confidence": "70.5%",
|
||||
"predicted_winner": "PSG",
|
||||
"created_at": "2026-01-17T14:00:00Z",
|
||||
"history": [
|
||||
{
|
||||
"id": 3,
|
||||
"energy_score": "high",
|
||||
"confidence": "70.5%",
|
||||
"predicted_winner": "PSG",
|
||||
"created_at": "2026-01-17T14:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"energy_score": "medium",
|
||||
"confidence": "60.0%",
|
||||
"predicted_winner": "PSG",
|
||||
"created_at": "2026-01-17T12:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
service = PredictionService(db)
|
||||
prediction_details = service.get_prediction_with_details(match_id)
|
||||
|
||||
if not prediction_details:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"No predictions found for match {match_id}"
|
||||
)
|
||||
|
||||
return prediction_details
|
||||
|
||||
|
||||
@router.post("/matches/{match_id}/predict", response_model=PredictionResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_prediction(
|
||||
match_id: int,
|
||||
home_energy: float,
|
||||
away_energy: float,
|
||||
energy_score_label: str | None = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a prediction for a specific match.
|
||||
|
||||
This endpoint calculates a prediction based on energy scores for both teams
|
||||
and stores it in the database.
|
||||
|
||||
Args:
|
||||
match_id: ID of the match to predict
|
||||
home_energy: Energy score of the home team (0.0+)
|
||||
away_energy: Energy score of the away team (0.0+)
|
||||
energy_score_label: Optional label for energy score (e.g., "high", "medium", "low")
|
||||
db: Database session (injected)
|
||||
|
||||
Returns:
|
||||
Created prediction object
|
||||
|
||||
Raises:
|
||||
404: If match doesn't exist
|
||||
400: If energy scores are invalid
|
||||
422: If validation fails
|
||||
|
||||
Example Request:
|
||||
POST /api/v1/predictions/matches/1/predict?home_energy=65.0&away_energy=45.0
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"id": 1,
|
||||
"match_id": 1,
|
||||
"energy_score": "high",
|
||||
"confidence": "40.0%",
|
||||
"predicted_winner": "PSG",
|
||||
"created_at": "2026-01-17T12:00:00Z"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
service = PredictionService(db)
|
||||
prediction = service.create_prediction_for_match(
|
||||
match_id=match_id,
|
||||
home_energy=home_energy,
|
||||
away_energy=away_energy,
|
||||
energy_score_label=energy_score_label
|
||||
)
|
||||
return prediction.to_dict()
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create prediction: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{prediction_id}", response_model=PredictionResponse)
|
||||
def get_prediction(
|
||||
prediction_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get a prediction by its ID.
|
||||
|
||||
Args:
|
||||
prediction_id: ID of the prediction to retrieve
|
||||
db: Database session (injected)
|
||||
|
||||
Returns:
|
||||
Prediction object
|
||||
|
||||
Raises:
|
||||
404: If prediction doesn't exist
|
||||
|
||||
Example Request:
|
||||
GET /api/v1/predictions/1
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"id": 1,
|
||||
"match_id": 1,
|
||||
"energy_score": "high",
|
||||
"confidence": "40.0%",
|
||||
"predicted_winner": "PSG",
|
||||
"created_at": "2026-01-17T12:00:00Z"
|
||||
}
|
||||
"""
|
||||
service = PredictionService(db)
|
||||
prediction = service.get_prediction_by_id(prediction_id)
|
||||
|
||||
if not prediction:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Prediction with id {prediction_id} not found"
|
||||
)
|
||||
|
||||
return prediction.to_dict()
|
||||
|
||||
|
||||
@router.get("/matches/{match_id}", response_model=PredictionListResponse)
|
||||
def get_predictions_for_match(
|
||||
match_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get all predictions for a specific match.
|
||||
|
||||
Args:
|
||||
match_id: ID of the match
|
||||
db: Database session (injected)
|
||||
|
||||
Returns:
|
||||
List of predictions for the match
|
||||
|
||||
Example Request:
|
||||
GET /api/v1/predictions/matches/1
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"match_id": 1,
|
||||
"energy_score": "high",
|
||||
"confidence": "40.0%",
|
||||
"predicted_winner": "PSG",
|
||||
"created_at": "2026-01-17T12:00:00Z"
|
||||
}
|
||||
],
|
||||
"count": 1,
|
||||
"meta": {}
|
||||
}
|
||||
"""
|
||||
service = PredictionService(db)
|
||||
predictions = service.get_predictions_for_match(match_id)
|
||||
|
||||
return {
|
||||
"data": [pred.to_dict() for pred in predictions],
|
||||
"count": len(predictions),
|
||||
"meta": {
|
||||
"match_id": match_id
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/matches/{match_id}/latest", response_model=PredictionResponse)
|
||||
def get_latest_prediction_for_match(
|
||||
match_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get the most recent prediction for a specific match.
|
||||
|
||||
Args:
|
||||
match_id: ID of the match
|
||||
db: Database session (injected)
|
||||
|
||||
Returns:
|
||||
Latest prediction object
|
||||
|
||||
Raises:
|
||||
404: If no predictions exist for the match
|
||||
|
||||
Example Request:
|
||||
GET /api/v1/predictions/matches/1/latest
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"id": 3,
|
||||
"match_id": 1,
|
||||
"energy_score": "high",
|
||||
"confidence": "45.0%",
|
||||
"predicted_winner": "PSG",
|
||||
"created_at": "2026-01-17T14:00:00Z"
|
||||
}
|
||||
"""
|
||||
service = PredictionService(db)
|
||||
prediction = service.get_latest_prediction_for_match(match_id)
|
||||
|
||||
if not prediction:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"No predictions found for match {match_id}"
|
||||
)
|
||||
|
||||
return prediction.to_dict()
|
||||
|
||||
|
||||
@router.delete("/{prediction_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_prediction(
|
||||
prediction_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Delete a prediction by its ID.
|
||||
|
||||
Args:
|
||||
prediction_id: ID of the prediction to delete
|
||||
db: Database session (injected)
|
||||
|
||||
Returns:
|
||||
No content (204)
|
||||
|
||||
Raises:
|
||||
404: If prediction doesn't exist
|
||||
|
||||
Example Request:
|
||||
DELETE /api/v1/predictions/1
|
||||
|
||||
Example Response:
|
||||
(HTTP 204 No Content)
|
||||
"""
|
||||
service = PredictionService(db)
|
||||
deleted = service.delete_prediction(prediction_id)
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Prediction with id {prediction_id} not found"
|
||||
)
|
||||
|
||||
return None
|
||||
241
backend/app/api/v1/user_predictions.py
Normal file
241
backend/app/api/v1/user_predictions.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
User Prediction API Routes.
|
||||
|
||||
This module provides REST endpoints for tracking user predictions
|
||||
and retrieving user statistics.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.services.user_prediction_service import UserPredictionService
|
||||
from app.schemas.user_prediction import (
|
||||
UserPredictionResponse,
|
||||
UserPredictionListResponse,
|
||||
UserStatsResponse
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/user-predictions", tags=["user-predictions"])
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
def record_prediction_view(
|
||||
user_id: int,
|
||||
prediction_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Record that a user viewed a prediction.
|
||||
|
||||
This endpoint tracks when a user views a prediction for ROI and accuracy calculations.
|
||||
Duplicate views are ignored (unique constraint on user_id + prediction_id).
|
||||
|
||||
Args:
|
||||
user_id: ID of the user
|
||||
prediction_id: ID of the prediction viewed
|
||||
db: Database session (injected)
|
||||
|
||||
Returns:
|
||||
Created user prediction record
|
||||
|
||||
Raises:
|
||||
404: If user or prediction doesn't exist
|
||||
422: If validation fails
|
||||
|
||||
Example Request:
|
||||
POST /api/v1/user-predictions?user_id=1&prediction_id=5
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"id": 1,
|
||||
"user_id": 1,
|
||||
"prediction_id": 5,
|
||||
"viewed_at": "2026-01-18T10:00:00Z",
|
||||
"was_correct": null
|
||||
}
|
||||
"""
|
||||
try:
|
||||
service = UserPredictionService(db)
|
||||
user_prediction = service.record_prediction_view(user_id, prediction_id)
|
||||
return user_prediction.to_dict()
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to record prediction view: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/history/{user_id}", response_model=UserPredictionListResponse)
|
||||
def get_prediction_history(
|
||||
user_id: int,
|
||||
limit: int = Query(50, ge=1, le=100, description="Maximum number of records to return (max 100)"),
|
||||
offset: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get a user's prediction viewing history.
|
||||
|
||||
This endpoint retrieves all predictions a user has viewed, sorted by most recent.
|
||||
Includes full prediction and match details.
|
||||
|
||||
Args:
|
||||
user_id: ID of the user
|
||||
limit: Maximum number of records to return (1-100, default: 50)
|
||||
offset: Number of records to skip (default: 0)
|
||||
db: Database session (injected)
|
||||
|
||||
Returns:
|
||||
Paginated list of user predictions with full details
|
||||
|
||||
Example Request:
|
||||
GET /api/v1/user-predictions/history/1?limit=10&offset=0
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 5,
|
||||
"user_id": 1,
|
||||
"prediction_id": 10,
|
||||
"viewed_at": "2026-01-18T10:00:00Z",
|
||||
"was_correct": true,
|
||||
"prediction": {
|
||||
"id": 10,
|
||||
"match_id": 3,
|
||||
"energy_score": "high",
|
||||
"confidence": "75.0%",
|
||||
"predicted_winner": "PSG",
|
||||
"created_at": "2026-01-17T12:00:00Z"
|
||||
},
|
||||
"match": {
|
||||
"id": 3,
|
||||
"home_team": "PSG",
|
||||
"away_team": "Marseille",
|
||||
"date": "2026-01-18T20:00:00Z",
|
||||
"league": "Ligue 1",
|
||||
"status": "completed",
|
||||
"actual_winner": "PSG"
|
||||
}
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"total": 25,
|
||||
"limit": 10,
|
||||
"offset": 0,
|
||||
"timestamp": "2026-01-18T15:30:00Z"
|
||||
}
|
||||
}
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
service = UserPredictionService(db)
|
||||
predictions, total = service.get_user_prediction_history(
|
||||
user_id=user_id,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
return {
|
||||
"data": predictions,
|
||||
"meta": {
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats/{user_id}", response_model=UserStatsResponse)
|
||||
def get_user_statistics(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get user statistics including accuracy and ROI.
|
||||
|
||||
This endpoint calculates:
|
||||
- Total predictions viewed
|
||||
- Number of correct/incorrect predictions
|
||||
- Accuracy rate percentage
|
||||
- ROI (Return on Investment) in EUR
|
||||
|
||||
Args:
|
||||
user_id: ID of the user
|
||||
db: Database session (injected)
|
||||
|
||||
Returns:
|
||||
User statistics
|
||||
|
||||
Raises:
|
||||
404: If user doesn't exist
|
||||
|
||||
Example Request:
|
||||
GET /api/v1/user-predictions/stats/1
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"total_predictions_viewed": 25,
|
||||
"correct_predictions": 18,
|
||||
"incorrect_predictions": 5,
|
||||
"accuracy_rate": 78.3,
|
||||
"roi": 1550.0
|
||||
}
|
||||
"""
|
||||
service = UserPredictionService(db)
|
||||
stats = service.get_user_stats(user_id)
|
||||
|
||||
return UserStatsResponse(**stats)
|
||||
|
||||
|
||||
@router.put("/result/{prediction_id}")
|
||||
def update_prediction_result(
|
||||
prediction_id: int,
|
||||
actual_winner: Optional[str] = Query(None, description="Actual winner: home, away, draw, or null"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update prediction result when match completes.
|
||||
|
||||
This endpoint is called when a match finishes to update all user
|
||||
predictions that referenced this prediction.
|
||||
|
||||
Args:
|
||||
prediction_id: ID of the prediction
|
||||
actual_winner: Actual winner of the match (home/away/draw or null)
|
||||
db: Database session (injected)
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
|
||||
Example Request:
|
||||
PUT /api/v1/user-predictions/result/10?actual_winner=home
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"message": "Prediction results updated successfully",
|
||||
"prediction_id": 10,
|
||||
"actual_winner": "home"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
service = UserPredictionService(db)
|
||||
service.update_prediction_result(prediction_id, actual_winner)
|
||||
|
||||
return {
|
||||
"message": "Prediction results updated successfully",
|
||||
"prediction_id": prediction_id,
|
||||
"actual_winner": actual_winner
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to update prediction result: {str(e)}"
|
||||
)
|
||||
153
backend/app/api/v1/users.py
Normal file
153
backend/app/api/v1/users.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Routes API pour les utilisateurs."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from passlib.context import CryptContext
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserCreate, UserResponse, UserLoginRequest
|
||||
|
||||
# Configuration du hashage de mot de passe
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Vérifie un mot de passe en clair contre le hash"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Génère un hash sécurisé pour un mot de passe"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
@router.post("/", response_model=UserResponse)
|
||||
def create_user(user: UserCreate, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Créer un nouvel utilisateur (inscription).
|
||||
|
||||
Args:
|
||||
user: Données utilisateur à créer.
|
||||
db: Session de base de données.
|
||||
|
||||
Returns:
|
||||
UserResponse: L'utilisateur créé.
|
||||
|
||||
Raises:
|
||||
400: Si validation échoue
|
||||
409: Si email déjà utilisé
|
||||
"""
|
||||
# Vérifier si l'email existe déjà
|
||||
existing_user = db.query(User).filter(User.email == user.email).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Cet email est déjà utilisé"
|
||||
)
|
||||
|
||||
# Hasher le mot de passe
|
||||
password_hash = get_password_hash(user.password)
|
||||
|
||||
# Générer un code de parrainage unique
|
||||
import secrets
|
||||
import string
|
||||
referral_code = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(8))
|
||||
while db.query(User).filter(User.referral_code == referral_code).first():
|
||||
referral_code = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(8))
|
||||
|
||||
# Créer l'utilisateur avec le mot de passe hashé
|
||||
new_user = User(
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
password_hash=password_hash,
|
||||
is_premium=False,
|
||||
referral_code=referral_code
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
return UserResponse(
|
||||
id=new_user.id,
|
||||
email=new_user.email,
|
||||
name=new_user.name,
|
||||
created_at=new_user.created_at,
|
||||
updated_at=new_user.updated_at
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=UserResponse)
|
||||
def login_user(user: UserLoginRequest, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Connecter un utilisateur.
|
||||
|
||||
Args:
|
||||
user: Email et mot de passe de l'utilisateur.
|
||||
db: Session de base de données.
|
||||
|
||||
Returns:
|
||||
UserResponse: L'utilisateur connecté.
|
||||
|
||||
Raises:
|
||||
401: Si email ou mot de passe incorrect
|
||||
"""
|
||||
# Vérifier si l'utilisateur existe
|
||||
db_user = db.query(User).filter(User.email == user.email).first()
|
||||
if not db_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Email ou mot de passe incorrect"
|
||||
)
|
||||
|
||||
# Vérifier le mot de passe
|
||||
if not verify_password(user.password, db_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Email ou mot de passe incorrect"
|
||||
)
|
||||
|
||||
return UserResponse(
|
||||
id=db_user.id,
|
||||
email=db_user.email,
|
||||
name=db_user.name,
|
||||
created_at=db_user.created_at,
|
||||
updated_at=db_user.updated_at
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[UserResponse])
|
||||
def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Récupérer la liste des utilisateurs.
|
||||
|
||||
Args:
|
||||
skip: Nombre d'éléments à sauter.
|
||||
limit: Nombre maximum d'éléments à retourner.
|
||||
db: Session de base de données.
|
||||
|
||||
Returns:
|
||||
List[UserResponse]: Liste des utilisateurs.
|
||||
"""
|
||||
users = db.query(User).offset(skip).limit(limit).all()
|
||||
return users
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
def read_user(user_id: int, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Récupérer un utilisateur par son ID.
|
||||
|
||||
Args:
|
||||
user_id: ID de l'utilisateur.
|
||||
db: Session de base de données.
|
||||
|
||||
Returns:
|
||||
UserResponse: L'utilisateur trouvé.
|
||||
"""
|
||||
db_user = db.query(User).filter(User.id == user_id).first()
|
||||
if db_user is None:
|
||||
raise HTTPException(status_code=404, detail="Utilisateur non trouvé")
|
||||
return db_user
|
||||
Reference in New Issue
Block a user