Sauvegarde de l'état (réparation)

This commit is contained in:
Reviflow Bot committed 2026-01-31 11:00:30 +01:00
commit 7c04fffe77
434 files changed
+83923

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
from enum import Enum
from sqlmodel import SQLModel, Field, Relationship
from typing import Optional, List
import uuid
from datetime import datetime
from pydantic import ConfigDict
class UserRole(str, Enum):
PARENT = "parent"
LEARNER = "learner"
class LearnerProfile(SQLModel, table=True):
__tablename__ = "learner_profiles"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
user_id: uuid.UUID = Field(foreign_key="users.id", index=True, unique=True) # Linked to the SPECIFIC learner user account
first_name: str
avatar_url: Optional[str] = Field(default=None)
# Gamification
streak_current: int = Field(default=0)
streak_max: int = Field(default=0)
last_activity_date: Optional[datetime] = Field(default=None)
xp: int = Field(default=0)
level: int = Field(default=1)
# Relationship back to the specific user account
user: "User" = Relationship(back_populates="learner_profile")
# Badges
# Badges
badges: List["LearnerBadge"] = Relationship(
back_populates="learner",
sa_relationship_kwargs={"lazy": "selectin"}
)
class LearnerBadge(SQLModel, table=True):
__tablename__ = "learner_badges"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
learner_id: uuid.UUID = Field(foreign_key="learner_profiles.id", index=True)
badge_code: str = Field(index=True) # e.g. "FIRST_STEPS", "MATH_CHAMP"
earned_at: datetime = Field(default_factory=datetime.utcnow)
learner: LearnerProfile = Relationship(back_populates="badges")
class User(SQLModel, table=True):
__tablename__ = "users"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
email: str = Field(unique=True, index=True)
username: Optional[str] = Field(default=None, unique=True, index=True)
hashed_password: str
is_active: bool = Field(default=True)
is_superuser: bool = Field(default=False)
is_verified: bool = Field(default=False)
# Additional fields
first_name: Optional[str] = Field(default=None)
role: UserRole = Field(default=UserRole.PARENT)
openrouter_api_key: Optional[str] = Field(default=None)
total_tokens_used: int = Field(default=0)
total_cost_usd: float = Field(default=0.0)
parental_pin: Optional[str] = Field(default=None)
parent_id: Optional[uuid.UUID] = Field(default=None, foreign_key="users.id", index=True)
# Relationships
learner_profile: Optional[LearnerProfile] = Relationship(
back_populates="user",
sa_relationship_kwargs={
"cascade": "all, delete-orphan",
"uselist": False,
"lazy": "selectin"
}
)
children: List["User"] = Relationship(
back_populates="parent",
sa_relationship_kwargs={
"cascade": "all, delete-orphan",
"lazy": "selectin",
"single_parent": True
}
)
parent: Optional["User"] = Relationship(
back_populates="children",
sa_relationship_kwargs={
"remote_side": "User.id",
"lazy": "selectin"
}
)
model_config = ConfigDict(arbitrary_types_allowed=True)
+265
View File
@@ -0,0 +1,265 @@
from fastapi import APIRouter, Depends, HTTPException
import httpx
from app.modules.auth.service import auth_backend, fastapi_users, current_active_user
from app.modules.auth.schemas import UserRead, UserCreate, UserUpdate
from app.modules.auth.models import User, UserRole, LearnerProfile
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy import select
from app.core.db import get_async_session
router = APIRouter()
# /auth/jwt/login
router.include_router(
fastapi_users.get_auth_router(auth_backend),
prefix="/jwt",
tags=["auth"],
)
# /auth/register
router.include_router(
fastapi_users.get_register_router(UserRead, UserCreate),
tags=["auth"],
)
# /users/me
router.include_router(
fastapi_users.get_users_router(UserRead, UserUpdate),
prefix="/users",
tags=["users"],
)
# API Key Validation
@router.get("/validate-api-key", tags=["auth"])
async def validate_api_key(user: User = Depends(current_active_user)):
"""Validate the user's OpenRouter API key by making a test request."""
if not user.openrouter_api_key:
return {"valid": False, "error": "No API key configured"}
try:
async with httpx.AsyncClient() as client:
response = await client.get(
"https://openrouter.ai/api/v1/models",
headers={"Authorization": f"Bearer {user.openrouter_api_key}"},
timeout=10.0
)
if response.status_code == 200:
return {"valid": True}
elif response.status_code == 401:
return {"valid": False, "error": "Invalid API key"}
else:
return {"valid": False, "error": f"API error: {response.status_code}"}
except httpx.TimeoutException:
return {"valid": False, "error": "Connection timeout"}
except Exception as e:
return {"valid": False, "error": str(e)}
@router.post("/verify-parental-gate", tags=["auth"])
async def verify_parental_gate(
payload: dict,
user: User = Depends(current_active_user),
user_manager = Depends(fastapi_users.get_user_manager)
):
"""Verify if the provided PIN or Password is correct to grant parental access."""
pin = payload.get("pin")
password = payload.get("password")
if pin:
if user.parental_pin:
valid, _ = user_manager.password_helper.verify_and_update(pin, user.parental_pin)
if valid:
return {"success": True}
return {"success": False, "error": "Invalid PIN"}
if password:
valid, _ = user_manager.password_helper.verify_and_update(password, user.hashed_password)
if valid:
return {"success": True}
return {"success": False, "error": "Invalid Password"}
raise HTTPException(status_code=400, detail="PIN or Password required")
@router.get("/children", response_model=list[UserRead], tags=["auth"])
async def list_children(
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_async_session)
):
"""List all children accounts linked to this parent."""
if user.role != UserRole.PARENT:
raise HTTPException(status_code=403, detail="Only parents can list children")
# Re-fetch user with relationships to avoid lazy-loading issues
statement = select(User).where(User.id == user.id).options(
selectinload(User.children)
)
result = await db.execute(statement)
full_user = result.scalar_one()
return full_user.children
@router.get("/profiles", response_model=list[dict], tags=["auth"])
async def list_profiles(
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_async_session)
):
"""
If parent: list all children's profiles.
If learner: return own profile.
"""
# Re-fetch user with relationships to avoid lazy-loading issues
statement = select(User).where(User.id == user.id).options(
selectinload(User.learner_profile),
selectinload(User.children).selectinload(User.learner_profile)
)
result = await db.execute(statement)
full_user = result.scalar_one()
if full_user.role == UserRole.LEARNER:
if not full_user.learner_profile:
return []
return [{
"id": str(full_user.learner_profile.id),
"first_name": full_user.learner_profile.first_name,
"avatar_url": full_user.learner_profile.avatar_url
}]
# Parent view: collect profiles from all children
profiles = []
for child in full_user.children:
if child.learner_profile:
profiles.append({
"id": str(child.learner_profile.id),
"first_name": child.learner_profile.first_name,
"avatar_url": child.learner_profile.avatar_url,
"username": child.username,
"xp": child.learner_profile.xp,
"level": child.learner_profile.level,
"streak_current": child.learner_profile.streak_current,
"streak_max": child.learner_profile.streak_max,
})
return profiles
@router.patch("/profiles/me", tags=["auth"])
async def update_my_profile(
profile_data: dict,
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_async_session)
):
"""Allow a learner to update their own profile (e.g. avatar)."""
if user.role != UserRole.LEARNER:
raise HTTPException(status_code=403, detail="Only learners can update their learner profile directly")
# Fetch profile with selectinload to ensure it's available
statement = select(User).where(User.id == user.id).options(selectinload(User.learner_profile))
result = await db.execute(statement)
full_user = result.scalar_one()
if not full_user.learner_profile:
raise HTTPException(status_code=404, detail="Learner profile not found")
profile = full_user.learner_profile
if "first_name" in profile_data:
profile.first_name = profile_data["first_name"]
if "avatar_url" in profile_data:
profile.avatar_url = profile_data["avatar_url"]
db.add(profile)
await db.commit()
await db.refresh(profile)
return {
"id": str(profile.id),
"first_name": profile.first_name,
"avatar_url": profile.avatar_url
}
@router.post("/select-profile/{learner_id}", tags=["auth"])
async def select_profile(
learner_id: str,
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_async_session)
):
"""Validate that the learner profile belongs to the user."""
statement = select(User).where(User.id == user.id).options(
selectinload(User.learner_profile),
selectinload(User.children).selectinload(User.learner_profile)
)
result = await db.execute(statement)
full_user = result.scalar_one()
profile = None
if full_user.role == UserRole.LEARNER:
if full_user.learner_profile and str(full_user.learner_profile.id) == learner_id:
profile = full_user.learner_profile
else:
# Parent view: check if any child has this profile
for child in full_user.children:
if child.learner_profile and str(child.learner_profile.id) == learner_id:
profile = child.learner_profile
break
if not profile:
raise HTTPException(status_code=404, detail="Profile not found or access denied")
return {"success": True, "profile": {
"id": str(profile.id),
"first_name": profile.first_name,
"avatar_url": profile.avatar_url
}}
@router.post("/profiles", tags=["auth"])
async def create_child_account(
profile_data: dict,
user: User = Depends(current_active_user),
user_manager = Depends(fastapi_users.get_user_manager),
db: AsyncSession = Depends(get_async_session)
):
"""Create a new learner user and their associated profile."""
print(f"DEBUG: create_child_account hit by user {user.email}")
print(f"DEBUG: profile_data: {profile_data}")
if user.role != UserRole.PARENT:
raise HTTPException(status_code=403, detail="Only parents can create child accounts")
username = profile_data.get("username")
if not username:
raise HTTPException(status_code=400, detail="Username is required for child account")
# Create the user entity for the child
try:
child_user = await user_manager.create(
UserCreate(
email=f"{username}@reviflow.app",
password=profile_data.get("password", "1234"), # Default PIN if not provided
username=username,
first_name=profile_data.get("first_name"),
role=UserRole.LEARNER,
parent_id=user.id
)
)
# Create the learner profile
new_profile = LearnerProfile(
user_id=child_user.id,
first_name=profile_data.get("first_name", username),
avatar_url=profile_data.get("avatar_url")
)
db.add(new_profile)
await db.commit()
await db.refresh(new_profile)
return {"success": True, "user": {
"id": str(child_user.id),
"username": child_user.username,
"profile_id": str(new_profile.id)
}}
except Exception as e:
await db.rollback()
import traceback
traceback.print_exc()
detail = str(e)
if hasattr(e, "reason"): detail = e.reason
print(f"DEBUG: Child creation failed: {detail}")
raise HTTPException(status_code=400, detail=f"Échec de la création du compte enfant : {detail}")
+69
View File
@@ -0,0 +1,69 @@
import uuid
from typing import Optional, List
from datetime import datetime
from fastapi_users import schemas
from pydantic import BaseModel, Field
from .models import UserRole
# Learner Profile Schemas
class LearnerProfileBase(BaseModel):
first_name: str = Field(..., min_length=1)
avatar_url: Optional[str] = None
class LearnerProfileCreate(LearnerProfileBase):
pass
class LearnerProfileUpdate(BaseModel):
first_name: Optional[str] = None
avatar_url: Optional[str] = None
class BadgeRead(BaseModel):
id: uuid.UUID
badge_code: str
earned_at: datetime
class Config:
from_attributes = True
class LearnerProfileRead(LearnerProfileBase):
id: uuid.UUID
user_id: uuid.UUID
streak_current: int = 0
streak_max: int = 0
xp: int = 0
level: int = 1
badges: List[BadgeRead] = []
class Config:
from_attributes = True
# User Schemas
class UserRead(schemas.BaseUser[uuid.UUID]):
first_name: Optional[str] = None
username: Optional[str] = None
role: UserRole
openrouter_api_key: Optional[str] = None
total_tokens_used: int = 0
total_cost_usd: float = 0.0
learner_profile: Optional[LearnerProfileRead] = None
parent_id: Optional[uuid.UUID] = None
parental_pin: Optional[str] = None
@property
def has_parental_pin(self) -> bool:
return self.parental_pin is not None
class UserCreate(schemas.BaseUserCreate):
first_name: Optional[str] = None
username: Optional[str] = None
role: UserRole = UserRole.PARENT
parent_id: Optional[uuid.UUID] = None
class UserUpdate(schemas.BaseUserUpdate):
first_name: Optional[str] = None
username: Optional[str] = None
openrouter_api_key: Optional[str] = None
role: Optional[UserRole] = None
parent_id: Optional[uuid.UUID] = None
parental_pin: Optional[str] = None
+104
View File
@@ -0,0 +1,104 @@
import uuid
from typing import Optional
from fastapi import Depends, Request
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin
from fastapi_users.authentication import AuthenticationBackend, BearerTransport, JWTStrategy
from fastapi_users.db import SQLAlchemyUserDatabase
from app.config import settings
from app.core.db import get_async_session
from app.modules.auth.models import User, UserRole
# 1. Database Adaptor
class UserDatabase(SQLAlchemyUserDatabase):
async def get_by_username(self, username: str) -> Optional[User]:
from sqlalchemy import select
statement = select(User).where(User.username == username)
result = await self.session.execute(statement)
return result.scalar_one_or_none()
async def get_user_db(session=Depends(get_async_session)):
yield UserDatabase(session, User)
# 2. User Manager (Business Logic)
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
reset_password_token_secret = settings.SECRET_KEY
verification_token_secret = settings.SECRET_KEY
async def on_after_register(self, user: User, request: Optional[Request] = None):
print(f"User {user.id} has registered as {user.role}.")
# If learner, ensure they have a profile
if user.role == UserRole.LEARNER:
# This will be handled in the custom creation endpoint mostly,
# but good to have a safeguard
pass
async def validate_password(self, password: str, user: Optional[User] = None) -> None:
"""Relax validation to allow 4-digit PINs."""
if len(password) < 4:
from fastapi_users.exceptions import InvalidPasswordException
raise InvalidPasswordException("Password must be at least 4 characters long")
async def authenticate(self, credentials):
"""Override authenticate to check both email and username."""
if isinstance(credentials, dict):
username = credentials.get("username")
password = credentials.get("password")
else:
username = getattr(credentials, "username", None)
password = getattr(credentials, "password", None)
if not username or not password:
return None
# Try finding by email (Standard)
try:
user = await self.get_by_email(username)
if user and self.password_helper.verify_and_update(password, user.hashed_password)[0]:
return user
except Exception:
pass
# Try finding by username (Learner)
try:
# We use a custom query for username as BaseUserManager doesn't have get_by_username
user = await self.user_db.get_by_username(username)
if user and self.password_helper.verify_and_update(password, user.hashed_password)[0]:
return user
except Exception:
pass
return None
async def on_after_update(self, user: User, update_dict: dict, request: Optional[Request] = None):
if "parental_pin" in update_dict and update_dict["parental_pin"]:
# We don't hash it here because FastAPI Users doesn't automatically call a hook BEFORE update for custom fields easily without overriding update()
pass
async def update(self, user_update, user, safe=True, request=None):
# Hash parental_pin if it's being updated
if hasattr(user_update, "parental_pin") and user_update.parental_pin is not None:
user_update.parental_pin = self.password_helper.hash(user_update.parental_pin)
return await super().update(user_update, user, safe=safe, request=request)
async def get_user_manager(user_db=Depends(get_user_db)):
yield UserManager(user_db)
# 3. Authentication Backend (Cookie/JWT)
bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
def get_jwt_strategy() -> JWTStrategy:
return JWTStrategy(secret=settings.SECRET_KEY, lifetime_seconds=3600*24) # 24h Session
auth_backend = AuthenticationBackend(
name="jwt",
transport=bearer_transport,
get_strategy=get_jwt_strategy,
)
# 4. FastAPI Users Instance
fastapi_users = FastAPIUsers[User, uuid.UUID](
get_user_manager,
[auth_backend],
)
current_active_user = fastapi_users.current_user(active=True)