mirror of
https://github.com/R0m1k3/Reviflow.git
synced 2026-10-19 21:18:13 +02:00
Sauvegarde de l'état (réparation)
This commit is contained in:
commit
7c04fffe77
434 files changed
+83923
No files matched your search
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user