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,11 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
class Settings(BaseSettings):
|
||||
PROJECT_NAME: str = "Reviflow"
|
||||
SECRET_KEY: str = "SECRET_KEY_CHANGE_ME_IN_PROD"
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./reviflow.db"
|
||||
OPENROUTER_API_KEY: str = ""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_ignore_empty=True)
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,15 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlmodel import SQLModel
|
||||
from app.config import settings
|
||||
|
||||
engine = create_async_engine(settings.DATABASE_URL)
|
||||
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
async def create_db_and_tables():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session_maker() as session:
|
||||
yield session
|
||||
@@ -0,0 +1,79 @@
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
import os
|
||||
|
||||
from app.modules.auth.router import router as auth_router
|
||||
from app.modules.ingest.router import router as ingest_router
|
||||
from app.modules.quiz.router import router as quiz_router
|
||||
from app.core.db import create_db_and_tables
|
||||
|
||||
app = FastAPI(title="Reviflow API")
|
||||
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
print(f"DEBUG REQ: {request.method} {request.url.path}")
|
||||
response = await call_next(request)
|
||||
print(f"DEBUG RES: {request.method} {request.url.path} -> {response.status_code}")
|
||||
return response
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
await create_db_and_tables()
|
||||
# Debug: Check static files location
|
||||
static_path = os.path.join(os.path.dirname(__file__), "..", "static")
|
||||
print(f"Startup: Static dir check -> {os.path.abspath(static_path)} (Exists: {os.path.exists(static_path)})")
|
||||
|
||||
app.include_router(auth_router, prefix="/api/auth")
|
||||
app.include_router(ingest_router, prefix="/api/ingest")
|
||||
app.include_router(quiz_router, prefix="/api/quiz")
|
||||
|
||||
# Health Check
|
||||
@app.get("/api/health")
|
||||
def health_check():
|
||||
return {"status": "ok", "service": "reviflow-backend"}
|
||||
|
||||
# Mount Static Files (React) - Only if directory exists (Production/Monolith mode)
|
||||
static_dir = os.path.join(os.path.dirname(__file__), "..", "static")
|
||||
if os.path.exists(static_dir):
|
||||
# Mount assets directory for JS, CSS, images
|
||||
assets_dir = os.path.join(static_dir, "assets")
|
||||
if os.path.exists(assets_dir):
|
||||
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
||||
|
||||
# Serve static files (favicon, etc.)
|
||||
@app.get("/vite.svg")
|
||||
async def vite_svg():
|
||||
return FileResponse(os.path.join(static_dir, "vite.svg"))
|
||||
|
||||
@app.get("/favicon.ico")
|
||||
async def favicon():
|
||||
# Fallback to vite.svg if no favicon.ico exists
|
||||
favicon_path = os.path.join(static_dir, "favicon.ico")
|
||||
if os.path.exists(favicon_path):
|
||||
return FileResponse(favicon_path)
|
||||
return FileResponse(os.path.join(static_dir, "vite.svg"), media_type="image/svg+xml")
|
||||
|
||||
# SPA Catch-all: Serve index.html for all non-API routes (React Router)
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(request: Request, full_path: str):
|
||||
# Don't catch API routes or missing static assets
|
||||
# We check for common static directories to avoid serving index.html for missing assets
|
||||
static_prefixes = ["api/", "assets/", "static/"]
|
||||
if any(full_path.startswith(prefix) for prefix in static_prefixes):
|
||||
return {"error": "Not found"}, 404
|
||||
|
||||
# Check if file exists in static directory (e.g. logo.png, favicon.png)
|
||||
# This allows serving root-level static files that are not in /assets
|
||||
file_path = os.path.join(static_dir, full_path)
|
||||
if os.path.isfile(file_path):
|
||||
return FileResponse(file_path)
|
||||
|
||||
# Also check for file extensions to avoid catching missing .png, .js, etc.
|
||||
# If it has an extension and wasn't found above, it's a 404.
|
||||
if "." in full_path.split("/")[-1]:
|
||||
return {"error": "Not found"}, 404
|
||||
|
||||
return FileResponse(os.path.join(static_dir, "index.html"))
|
||||
else:
|
||||
print(f"Warning: Static directory {static_dir} not found. Running in API-only mode.")
|
||||
@@ -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)
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Ingest module router for image analysis."""
|
||||
import json
|
||||
import asyncio
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from app.modules.ingest.schemas import AnalyzeRequest, AnalyzeResponse, AnalyzeError
|
||||
from app.modules.ingest.service import analyze_documents
|
||||
from app.modules.auth.service import current_active_user
|
||||
from typing import Optional
|
||||
from app.modules.auth.models import User, UserRole
|
||||
|
||||
from app.core.db import get_async_session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
async def get_effective_api_key(user: User, db: AsyncSession) -> Optional[str]:
|
||||
"""Retrieve the API key from user, parent, or global settings."""
|
||||
# 1. User specific key
|
||||
if user.openrouter_api_key:
|
||||
return user.openrouter_api_key
|
||||
|
||||
# 2. Parent's key (if learner)
|
||||
if user.parent_id:
|
||||
statement = select(User).where(User.id == user.parent_id)
|
||||
result = await db.execute(statement)
|
||||
parent = result.scalar_one_or_none()
|
||||
if parent and parent.openrouter_api_key:
|
||||
return parent.openrouter_api_key
|
||||
|
||||
# 3. Global settings key
|
||||
if settings.OPENROUTER_API_KEY:
|
||||
return settings.OPENROUTER_API_KEY
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/analyze",
|
||||
response_model=AnalyzeResponse,
|
||||
responses={
|
||||
400: {"model": AnalyzeError, "description": "Invalid request"},
|
||||
401: {"model": AnalyzeError, "description": "No API key configured"},
|
||||
500: {"model": AnalyzeError, "description": "AI service error"}
|
||||
}
|
||||
)
|
||||
async def analyze_image_endpoint(
|
||||
request: AnalyzeRequest,
|
||||
user: User = Depends(current_active_user),
|
||||
db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
"""
|
||||
Analyze an image using AI Vision to extract lesson content.
|
||||
|
||||
Requires a valid OpenRouter API key configured in user settings.
|
||||
"""
|
||||
# Determine which API key to use
|
||||
api_key = await get_effective_api_key(user, db)
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="No OpenRouter API key configured. Please add your API key in Settings or contact your administrator."
|
||||
)
|
||||
|
||||
if not request.images_base64:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No images provided"
|
||||
)
|
||||
|
||||
try:
|
||||
result, math_safety_triggered = await analyze_documents(
|
||||
request.images_base64,
|
||||
api_key
|
||||
)
|
||||
|
||||
# Update usage
|
||||
usage = result.get("usage", {})
|
||||
user.total_tokens_used += usage.get("total_tokens", 0)
|
||||
# Approximate cost (0.1$ / 1M tokens for Flash)
|
||||
user.total_cost_usd += usage.get("total_tokens", 0) * 0.0000001
|
||||
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
|
||||
return AnalyzeResponse(**result)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"AI analysis failed: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/analyze-stream")
|
||||
async def analyze_image_stream(
|
||||
request: AnalyzeRequest,
|
||||
user: User = Depends(current_active_user),
|
||||
db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
"""
|
||||
Analyze an image with Server-Sent Events for progress updates.
|
||||
|
||||
Streams progress events: uploading -> reading -> analyzing -> complete
|
||||
"""
|
||||
# Determine which API key to use
|
||||
api_key = await get_effective_api_key(user, db)
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="No OpenRouter API key configured. Please add your API key in Settings or contact your administrator."
|
||||
)
|
||||
|
||||
if not request.images_base64:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No images provided"
|
||||
)
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
# Step 1: Uploading
|
||||
yield f"data: {json.dumps({'step': 'uploading', 'message': 'Téléchargement...', 'progress': 10})}\n\n"
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Step 2: Reading (OCR)
|
||||
msg = {'step': 'reading', 'message': "Lecture des documents...", 'progress': 30}
|
||||
yield f"data: {json.dumps(msg)}\n\n"
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Step 3: Analyzing
|
||||
yield f"data: {json.dumps({'step': 'analyzing', 'message': 'Analyse IA en cours...', 'progress': 50})}\n\n"
|
||||
|
||||
# Actually perform the analysis
|
||||
result, math_safety_triggered = await analyze_documents(
|
||||
request.images_base64,
|
||||
api_key
|
||||
)
|
||||
|
||||
# Update usage
|
||||
usage = result.get("usage", {})
|
||||
user.total_tokens_used += usage.get("total_tokens", 0)
|
||||
user.total_cost_usd += usage.get("total_tokens", 0) * 0.0000001
|
||||
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
|
||||
# Step 4: Synthesizing
|
||||
yield f"data: {json.dumps({'step': 'synthesizing', 'message': 'Génération de la synthèse...', 'progress': 80})}\n\n"
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Step 5: Complete
|
||||
yield f"data: {json.dumps({'step': 'complete', 'message': 'Terminé!', 'progress': 100, 'result': result})}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_msg = f"Error in analyze_image_stream: {str(e)}\n{traceback.format_exc()}"
|
||||
print(error_msg) # Log to console
|
||||
yield f"data: {json.dumps({'step': 'error', 'message': str(e), 'progress': 0})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no"
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
|
||||
class AnalyzeRequest(BaseModel):
|
||||
images_base64: list[str] # List of base64 encoded image data
|
||||
|
||||
class AnalyzeResponse(BaseModel):
|
||||
title: str
|
||||
subject: str
|
||||
raw_text: str
|
||||
synthesis: str
|
||||
study_tips: List[str] = []
|
||||
is_math_content: bool = False
|
||||
math_safety_triggered: bool = False
|
||||
usage: Optional[dict] = None
|
||||
|
||||
class AnalyzeError(BaseModel):
|
||||
error: str
|
||||
detail: Optional[str] = None
|
||||
@@ -0,0 +1,163 @@
|
||||
"""AI Vision Service using OpenRouter API."""
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from typing import Tuple
|
||||
import httpx
|
||||
|
||||
# Constants
|
||||
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||
DEFAULT_MODEL = "google/gemini-2.5-flash" # Verified OpenRouter ID
|
||||
|
||||
# Math Safety keywords
|
||||
MATH_KEYWORDS = [
|
||||
"équation", "equation", "calcul", "calculate", "résoudre", "solve",
|
||||
"formule", "formula", "mathématique", "mathematics", "algèbre", "algebra",
|
||||
"géométrie", "geometry", "dérivée", "derivative", "intégrale", "integral",
|
||||
"∫", "∑", "∏", "√", "π", "θ", "∆", "∂"
|
||||
]
|
||||
|
||||
SYSTEM_PROMPT = """You are an educational content analyzer for French students.
|
||||
Analyze the provided image of a lesson/course material and extract:
|
||||
|
||||
1. **title**: A concise title for this lesson (max 10 words)
|
||||
2. **subject**: The academic subject (e.g., "Histoire", "Sciences", "Français", "Mathématiques")
|
||||
3. **raw_text**: The complete text extracted from the image, preserving structure
|
||||
4. **synthesis**: A clear, student-friendly summary of the key concepts (3-5 bullet points)
|
||||
|
||||
⚠️ MATH SAFETY RULE: If this content contains math problems, equations, or exercises:
|
||||
- Extract the text/equations as-is in raw_text
|
||||
- In synthesis, only EXPLAIN the concepts, DO NOT solve any problems
|
||||
- Set is_math_content to true
|
||||
|
||||
Respond in JSON format with this exact structure:
|
||||
{
|
||||
"title": "string",
|
||||
"subject": "string",
|
||||
"raw_text": "string",
|
||||
"synthesis": "string",
|
||||
"study_tips": ["string", "string", "string"],
|
||||
"is_math_content": boolean
|
||||
}
|
||||
|
||||
Always respond in French."""
|
||||
|
||||
|
||||
|
||||
async def _analyze_batch(batch_images: list[str], api_key: str) -> dict:
|
||||
"""Helper to analyze a batch of images."""
|
||||
content_payload = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Analyze these lesson documents and extract the structured content."
|
||||
}
|
||||
]
|
||||
|
||||
for img_b64 in batch_images:
|
||||
# Clean base64 if it has data URL prefix
|
||||
if "base64," in img_b64:
|
||||
img_b64 = img_b64.split("base64,")[1]
|
||||
|
||||
content_payload.append({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{img_b64}"
|
||||
}
|
||||
})
|
||||
|
||||
payload = {
|
||||
"model": DEFAULT_MODEL,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": SYSTEM_PROMPT
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": content_payload
|
||||
}
|
||||
],
|
||||
"response_format": {"type": "json_object"},
|
||||
"max_tokens": 8192
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://reviflow.app",
|
||||
"X-Title": "Reviflow"
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
OPENROUTER_API_URL,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=120.0
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
error_detail = response.text
|
||||
raise Exception(f"OpenRouter API error ({response.status_code}): {error_detail}")
|
||||
|
||||
result = response.json()
|
||||
content = result["choices"][0]["message"]["content"]
|
||||
|
||||
try:
|
||||
analysis = json.loads(content)
|
||||
return analysis
|
||||
except json.JSONDecodeError:
|
||||
json_match = re.search(r'```json\s*(.*?)\s*```', content, re.DOTALL)
|
||||
if json_match:
|
||||
return json.loads(json_match.group(1))
|
||||
else:
|
||||
print(f"FAILED CONTENT PREVIEW: {content[:500]}...")
|
||||
raise Exception("Failed to parse AI response as JSON")
|
||||
|
||||
async def analyze_documents(images_base64: list[str], api_key: str) -> Tuple[dict, bool]:
|
||||
"""
|
||||
Analyze multiple images using OpenRouter Vision API with batching.
|
||||
"""
|
||||
BATCH_SIZE = 5
|
||||
batches = [images_base64[i:i + BATCH_SIZE] for i in range(0, len(images_base64), BATCH_SIZE)]
|
||||
|
||||
full_analysis = {
|
||||
"title": "Sans titre",
|
||||
"subject": "Général",
|
||||
"raw_text": "",
|
||||
"synthesis": "",
|
||||
"is_math_content": False,
|
||||
"usage": {}
|
||||
}
|
||||
|
||||
print(f"Processing {len(images_base64)} images in {len(batches)} batches...")
|
||||
|
||||
for index, batch in enumerate(batches):
|
||||
print(f"Analyzing batch {index + 1}/{len(batches)}...")
|
||||
try:
|
||||
batch_result = await _analyze_batch(batch, api_key)
|
||||
|
||||
# For the first batch, take title and subject
|
||||
if index == 0:
|
||||
full_analysis["title"] = batch_result.get("title", "Sans titre")
|
||||
full_analysis["subject"] = batch_result.get("subject", "Général")
|
||||
|
||||
# Accumulate text and synthesis
|
||||
chunk_text = batch_result.get("raw_text", "")
|
||||
chunk_synthesis = batch_result.get("synthesis", "")
|
||||
|
||||
full_analysis["raw_text"] += f"\n\n--- Partie {index + 1} ---\n" + chunk_text
|
||||
full_analysis["synthesis"] += f"\n" + chunk_synthesis
|
||||
|
||||
# Check for math (if any batch has math, whole doc is math)
|
||||
if batch_result.get("is_math_content", False) or \
|
||||
any(k in chunk_text.lower() for k in MATH_KEYWORDS):
|
||||
full_analysis["is_math_content"] = True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing batch {index + 1}: {str(e)}")
|
||||
# Continue with what we have, or could fail?
|
||||
# Ideally fail to warn user, but for now lets re-raise to be safe
|
||||
raise e
|
||||
|
||||
return full_analysis, full_analysis["is_math_content"]
|
||||
@@ -0,0 +1,48 @@
|
||||
from sqlmodel import SQLModel, Field
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
class Score(SQLModel, table=True):
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
user_id: uuid.UUID = Field(index=True)
|
||||
topic: str
|
||||
score: int
|
||||
total_questions: int
|
||||
learner_id: Optional[uuid.UUID] = Field(default=None, index=True)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
revision_id: Optional[uuid.UUID] = Field(default=None, index=True)
|
||||
|
||||
class RemediationQueue(SQLModel, table=True):
|
||||
__tablename__ = "remediation_queue"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
learner_id: uuid.UUID = Field(index=True)
|
||||
original_content: str # Context or snippet
|
||||
question: str
|
||||
wrong_answer: str
|
||||
correct_answer: str
|
||||
topic: str
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
status: str = Field(default="PENDING") # PENDING, REVIEWED, MASTERED
|
||||
revision_id: Optional[uuid.UUID] = Field(default=None, index=True)
|
||||
|
||||
class Revision(SQLModel, table=True):
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
learner_id: Optional[uuid.UUID] = Field(index=True)
|
||||
topic: str
|
||||
subject: Optional[str] = None # Added for categorization
|
||||
text_content: str # The full lesson text
|
||||
synthesis: Optional[str] = None # AI Summary
|
||||
study_tips: Optional[str] = None # JSON string of tips list
|
||||
quiz_data: Optional[str] = None # JSON string of current/last quiz
|
||||
progress_state: Optional[str] = None # JSON string: {index, answers, score}
|
||||
|
||||
# Session / Series Management
|
||||
status: str = Field(default="NEW") # NEW, IN_PROGRESS, COMPLETED
|
||||
current_series: int = Field(default=1)
|
||||
completed_series: int = Field(default=0) # Track how many parts are finished
|
||||
total_series: int = Field(default=1)
|
||||
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
@@ -0,0 +1,852 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
import json as import_json
|
||||
from datetime import datetime
|
||||
from app.core.db import get_async_session
|
||||
from app.modules.auth.service import current_active_user
|
||||
from app.modules.auth.models import User
|
||||
from app.modules.quiz.schemas import QuizRequest, QuizResponse, ScoreCreate, ScoreResponse
|
||||
from pydantic import BaseModel, Field as PydanticField
|
||||
from typing import Any
|
||||
from app.modules.quiz.models import Score
|
||||
from app.modules.quiz.service import generate_quiz
|
||||
from sqlmodel import select, delete
|
||||
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
async def get_effective_api_key(user: User, db: AsyncSession) -> Optional[str]:
|
||||
"""Retrieve the API key from user, parent, or global settings."""
|
||||
# 1. User specific key
|
||||
if user.openrouter_api_key:
|
||||
return user.openrouter_api_key
|
||||
|
||||
# 2. Parent's key (if learner)
|
||||
if user.parent_id:
|
||||
# Use simple select for Parent
|
||||
statement = select(User).where(User.id == user.parent_id)
|
||||
result = await db.execute(statement)
|
||||
parent = result.scalar_one_or_none()
|
||||
if parent and parent.openrouter_api_key:
|
||||
return parent.openrouter_api_key
|
||||
|
||||
# 3. Global settings key
|
||||
if settings.OPENROUTER_API_KEY:
|
||||
return settings.OPENROUTER_API_KEY
|
||||
|
||||
return None
|
||||
|
||||
@router.post("/generate", response_model=QuizResponse)
|
||||
async def generate_quiz_endpoint(
|
||||
request: QuizRequest,
|
||||
user: User = Depends(current_active_user),
|
||||
db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
"""Generates a quiz from the provided text content."""
|
||||
# Determine which API key to use
|
||||
api_key = await get_effective_api_key(user, db)
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="No OpenRouter API key configured. Please add your API key in Settings or contact your administrator."
|
||||
)
|
||||
|
||||
try:
|
||||
data = await generate_quiz(request.text_content, api_key, request.difficulty)
|
||||
|
||||
# Save Revision
|
||||
from app.modules.quiz.models import Revision
|
||||
revision = Revision(
|
||||
learner_id=request.learner_id, # Optional
|
||||
topic=request.title if request.title else data["quiz"]["topic"],
|
||||
subject=request.subject,
|
||||
text_content=request.text_content,
|
||||
synthesis=request.synthesis,
|
||||
study_tips=import_json.dumps(request.study_tips) if request.study_tips else None,
|
||||
quiz_data=import_json.dumps(data["quiz"]), # Save as JSON string
|
||||
created_at=datetime.utcnow(),
|
||||
total_series=data.get("meta", {}).get("total_series", 1)
|
||||
)
|
||||
db.add(revision)
|
||||
|
||||
# Update usage
|
||||
usage = data.get("usage", {})
|
||||
user.total_tokens_used += usage.get("total_tokens", 0)
|
||||
user.total_cost_usd += usage.get("total_tokens", 0) * 0.0000001
|
||||
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(revision)
|
||||
|
||||
response_quiz = data["quiz"]
|
||||
response_quiz["revision_id"] = revision.id # Add revision_id to response
|
||||
response_quiz["series_info"] = {
|
||||
"current": 1,
|
||||
"total": revision.total_series
|
||||
}
|
||||
|
||||
return response_quiz
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"Error in generate_quiz_endpoint: {str(e)}\n{traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/score", response_model=ScoreResponse)
|
||||
async def save_score(
|
||||
score_data: ScoreCreate,
|
||||
user: User = Depends(current_active_user),
|
||||
db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
"""Saves a quiz score."""
|
||||
from app.modules.quiz.models import RemediationQueue
|
||||
|
||||
score = Score(
|
||||
user_id=user.id,
|
||||
topic=score_data.topic,
|
||||
score=score_data.score,
|
||||
total_questions=score_data.total_questions,
|
||||
learner_id=score_data.learner_id,
|
||||
revision_id=score_data.revision_id # Link to revision
|
||||
)
|
||||
db.add(score)
|
||||
|
||||
# --- Streak Logic ---
|
||||
if score_data.learner_id:
|
||||
from app.modules.auth.models import LearnerProfile
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
learner = await db.get(LearnerProfile, score_data.learner_id)
|
||||
if learner:
|
||||
today = datetime.utcnow().date()
|
||||
last_activity = learner.last_activity_date.date() if learner.last_activity_date else None
|
||||
|
||||
if last_activity == today:
|
||||
pass # Already active today
|
||||
elif last_activity == today - timedelta(days=1):
|
||||
learner.streak_current += 1
|
||||
else:
|
||||
learner.streak_current = 1 # Reset or first time
|
||||
|
||||
if learner.streak_current > learner.streak_max:
|
||||
learner.streak_max = learner.streak_current
|
||||
|
||||
# XP and Level Logic
|
||||
# 1 XP per point scored? Or fixed amount per quiz?
|
||||
# Let's say XP = Score achieved.
|
||||
xp_gained = score_data.score
|
||||
learner.xp += xp_gained
|
||||
|
||||
# Level Formula: Level = 1 + floor(sqrt(XP / 50))
|
||||
# Level 1: 0-49 XP
|
||||
# Level 2: 50-199 XP
|
||||
# Level 3: 200-449 XP
|
||||
# ...
|
||||
import math
|
||||
new_level = 1 + math.floor(math.sqrt(learner.xp / 50))
|
||||
if new_level > learner.level:
|
||||
# Level Up! We could notify frontend here too via new_badges or similar
|
||||
learner.level = int(new_level)
|
||||
|
||||
learner.last_activity_date = datetime.utcnow()
|
||||
db.add(learner)
|
||||
|
||||
# --- Badge Logic ---
|
||||
# Define badge constants
|
||||
BADGE_FIRST_STEPS = "FIRST_STEPS" # First quiz completed
|
||||
BADGE_NIGHT_OWL = "NIGHT_OWL" # Quiz after 8 PM (20:00)
|
||||
BADGE_MATH_CHAMP = "MATH_CHAMP" # Score > 80% in Math
|
||||
BADGE_ON_FIRE = "ON_FIRE" # Streak >= 3
|
||||
|
||||
# Get existing badges
|
||||
from app.modules.auth.models import LearnerBadge
|
||||
result = await db.execute(select(LearnerBadge.badge_code).where(LearnerBadge.learner_id == learner.id))
|
||||
existing_badges = result.scalars().all()
|
||||
|
||||
new_badges_list = []
|
||||
|
||||
async def award_badge(code: str):
|
||||
if code not in existing_badges:
|
||||
badge = LearnerBadge(learner_id=learner.id, badge_code=code)
|
||||
db.add(badge)
|
||||
new_badges_list.append(code)
|
||||
|
||||
# Check conditions
|
||||
await award_badge(BADGE_FIRST_STEPS)
|
||||
|
||||
if datetime.utcnow().hour >= 20: # Simple logic for Night Owl (UTC based for now, ideally TZ aware)
|
||||
await award_badge(BADGE_NIGHT_OWL)
|
||||
|
||||
if "math" in score_data.topic.lower() and (score_data.score / score_data.total_questions) >= 0.8:
|
||||
await award_badge(BADGE_MATH_CHAMP)
|
||||
|
||||
if learner.streak_current >= 3:
|
||||
await award_badge(BADGE_ON_FIRE)
|
||||
# -------------------
|
||||
|
||||
# --- Remediation Logic ---
|
||||
# 1. If this IS a remediation quiz (detected by topic), close old pending errors
|
||||
# Robust check: case insensitive, check for 'remedia'
|
||||
topic_lower = score_data.topic.lower()
|
||||
if "remedia" in topic_lower:
|
||||
# Mark pending items for this revision/topic as REVIEWED
|
||||
# We assume that taking the quiz counts as reviewing them.
|
||||
# If they fail again, the code below (step 2) will add new items.
|
||||
update_stmt = select(RemediationQueue).where(
|
||||
RemediationQueue.learner_id == learner.id,
|
||||
RemediationQueue.status == "PENDING"
|
||||
)
|
||||
if score_data.revision_id:
|
||||
update_stmt = update_stmt.where(RemediationQueue.revision_id == score_data.revision_id)
|
||||
|
||||
# Fetch and update
|
||||
pending_results = await db.execute(update_stmt)
|
||||
pending_items = pending_results.scalars().all()
|
||||
for item in pending_items:
|
||||
item.status = "REVIEWED"
|
||||
db.add(item)
|
||||
|
||||
# 2. Add NEW errors to queue
|
||||
if score_data.details:
|
||||
for detail in score_data.details:
|
||||
if not detail.is_correct:
|
||||
# Add to remediation queue
|
||||
remediation_item = RemediationQueue(
|
||||
learner_id=learner.id,
|
||||
original_content=detail.original_content or score_data.topic, # Use topic if context missing
|
||||
question=detail.question,
|
||||
wrong_answer=detail.user_answer,
|
||||
correct_answer=detail.correct_answer,
|
||||
topic=score_data.topic,
|
||||
revision_id=score_data.revision_id # Link error to specific revision
|
||||
)
|
||||
db.add(remediation_item)
|
||||
# -------------------------
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(score)
|
||||
|
||||
# --- Series Status Update ---
|
||||
if score_data.revision_id:
|
||||
from app.modules.quiz.models import Revision
|
||||
revision = await db.get(Revision, score_data.revision_id)
|
||||
if revision:
|
||||
# Mark current series as completed
|
||||
if revision.current_series > revision.completed_series:
|
||||
revision.completed_series = revision.current_series
|
||||
|
||||
# Check if this was the last series
|
||||
if revision.completed_series >= revision.total_series:
|
||||
revision.status = "COMPLETED"
|
||||
|
||||
db.add(revision)
|
||||
await db.commit()
|
||||
|
||||
# Fix: Clear progress state as the series is finished
|
||||
# This prevents the specific bug where "Resuming" loads the old progress instead of showing the ResultCard
|
||||
if revision.progress_state:
|
||||
revision.progress_state = None
|
||||
db.add(revision)
|
||||
await db.commit()
|
||||
# ----------------------------
|
||||
# ----------------------------
|
||||
|
||||
# Prepare response
|
||||
response = ScoreResponse(
|
||||
id=score.id,
|
||||
topic=score.topic,
|
||||
score=score.score,
|
||||
total_questions=score.total_questions,
|
||||
created_at=score.created_at,
|
||||
learner_id=score.learner_id,
|
||||
new_badges=new_badges_list if score_data.learner_id else [],
|
||||
revision_id=score.revision_id
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
class ProgressUpdate(BaseModel):
|
||||
revision_id: uuid.UUID
|
||||
current_index: int
|
||||
answers: List[Any]
|
||||
score: int
|
||||
|
||||
@router.post("/progress/save")
|
||||
async def save_progress(
|
||||
data: ProgressUpdate,
|
||||
user: User = Depends(current_active_user),
|
||||
db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
"""Saves the current progress of a revision quiz."""
|
||||
from app.modules.quiz.models import Revision
|
||||
revision = await db.get(Revision, data.revision_id)
|
||||
if not revision:
|
||||
raise HTTPException(status_code=404, detail="Revision not found")
|
||||
|
||||
# Save state as JSON
|
||||
state = {
|
||||
"current_index": data.current_index,
|
||||
"answers": data.answers,
|
||||
"score": data.score,
|
||||
"timestamp": str(datetime.utcnow())
|
||||
}
|
||||
|
||||
revision.progress_state = import_json.dumps(state)
|
||||
revision.status = "IN_PROGRESS"
|
||||
|
||||
db.add(revision)
|
||||
await db.commit()
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
@router.post("/next-series")
|
||||
async def start_next_series(
|
||||
revision_id: uuid.UUID = Body(..., embed=True),
|
||||
user: User = Depends(current_active_user),
|
||||
db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
"""Generates and loads the NEXT series of questions for a revision."""
|
||||
api_key = await get_effective_api_key(user, db)
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=401, detail="No API Key")
|
||||
|
||||
from app.modules.quiz.models import Revision
|
||||
revision = await db.get(Revision, revision_id)
|
||||
if not revision:
|
||||
raise HTTPException(status_code=404, detail="Revision not found")
|
||||
|
||||
if revision.current_series >= revision.total_series:
|
||||
raise HTTPException(status_code=400, detail="Already at the last series.")
|
||||
|
||||
# Increment Series
|
||||
next_series = revision.current_series + 1
|
||||
|
||||
try:
|
||||
# Generate new quiz for the next series
|
||||
data = await generate_quiz(
|
||||
revision.text_content,
|
||||
api_key,
|
||||
series_index=next_series
|
||||
)
|
||||
|
||||
# Update Revision
|
||||
revision.current_series = next_series
|
||||
revision.quiz_data = import_json.dumps(data["quiz"])
|
||||
revision.progress_state = None # Clear previous progress
|
||||
revision.status = "IN_PROGRESS"
|
||||
|
||||
db.add(revision)
|
||||
await db.commit()
|
||||
await db.refresh(revision)
|
||||
|
||||
# Return new quiz
|
||||
response_quiz = data["quiz"]
|
||||
response_quiz["revision_id"] = revision.id
|
||||
# Add meta for frontend to know series state
|
||||
response_quiz["series_info"] = {
|
||||
"current": next_series,
|
||||
"total": revision.total_series
|
||||
}
|
||||
|
||||
return response_quiz
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"Error in next_series: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to generate next series.")
|
||||
|
||||
@router.get("/review/{revision_id}")
|
||||
async def get_revision(
|
||||
revision_id: uuid.UUID,
|
||||
user: User = Depends(current_active_user),
|
||||
db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
"""Retrieves a full revision (content + quiz) by ID."""
|
||||
from app.modules.quiz.models import Revision
|
||||
revision = await db.get(Revision, revision_id)
|
||||
if not revision:
|
||||
raise HTTPException(status_code=404, detail="Revision not found")
|
||||
|
||||
return revision
|
||||
|
||||
@router.get("/revisions")
|
||||
async def list_revisions(
|
||||
learner_id: Optional[uuid.UUID] = None,
|
||||
user: User = Depends(current_active_user),
|
||||
db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
"""Lists all revisions for a learner, with pending error counts."""
|
||||
from app.modules.quiz.models import Revision, RemediationQueue
|
||||
from sqlalchemy import func
|
||||
|
||||
# 1. Base query for revisions
|
||||
stmt = select(Revision).where(Revision.learner_id == learner_id).order_by(Revision.created_at.desc())
|
||||
result = await db.execute(stmt)
|
||||
revisions = result.scalars().all()
|
||||
|
||||
# 2. Add pending error counts
|
||||
# Fetch all pending error counts in one go for efficiency
|
||||
remed_stmt = select(
|
||||
RemediationQueue.revision_id,
|
||||
func.count(RemediationQueue.id)
|
||||
).where(
|
||||
RemediationQueue.learner_id == learner_id,
|
||||
RemediationQueue.status == "PENDING",
|
||||
RemediationQueue.revision_id.isnot(None)
|
||||
).group_by(RemediationQueue.revision_id)
|
||||
|
||||
remed_result = await db.execute(remed_stmt)
|
||||
remed_map = {row[0]: row[1] for row in remed_result.all()}
|
||||
|
||||
final_list = []
|
||||
for rev in revisions:
|
||||
rev_dict = rev.model_dump() # SQLModel uses model_dump() in newer versions or dict()
|
||||
# For safety across versions
|
||||
if hasattr(rev, "model_dump"):
|
||||
rev_dict = rev.model_dump()
|
||||
else:
|
||||
rev_dict = rev.dict()
|
||||
|
||||
rev_dict['pending_errors'] = remed_map.get(rev.id, 0)
|
||||
# Ensure dates are serialized if needed, but FastAPI handles this
|
||||
final_list.append(rev_dict)
|
||||
|
||||
return final_list
|
||||
|
||||
@router.get("/history", response_model=List[ScoreResponse])
|
||||
async def get_history(
|
||||
learner_id: Optional[uuid.UUID] = None,
|
||||
user: User = Depends(current_active_user),
|
||||
db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
"""Retrieves user score history."""
|
||||
# Filter by user_id AND learner_id (if provided or None for main profile)
|
||||
statement = select(Score).where(
|
||||
Score.user_id == user.id,
|
||||
Score.learner_id == learner_id
|
||||
).order_by(Score.created_at.desc())
|
||||
|
||||
result = await db.execute(statement)
|
||||
return result.scalars().all()
|
||||
|
||||
@router.get("/remediation/count")
|
||||
async def get_remediation_count(
|
||||
learner_id: uuid.UUID,
|
||||
user: User = Depends(current_active_user),
|
||||
db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
"""Returns number of pending remediation items."""
|
||||
from app.modules.quiz.models import RemediationQueue
|
||||
# Verify learner belongs to user (or is user) - simple check
|
||||
# In full app we'd check if learner_id in user.learner_profiles
|
||||
|
||||
statement = select(RemediationQueue).where(
|
||||
RemediationQueue.learner_id == learner_id,
|
||||
RemediationQueue.status == "PENDING"
|
||||
)
|
||||
result = await db.execute(statement)
|
||||
items = result.scalars().all()
|
||||
return {"count": len(items)}
|
||||
|
||||
@router.post("/remediation/generate", response_model=QuizResponse)
|
||||
async def generate_remediation_quiz(
|
||||
learner_id: uuid.UUID = Body(..., embed=True),
|
||||
revision_id: Optional[uuid.UUID] = Body(None, embed=True),
|
||||
user: User = Depends(current_active_user),
|
||||
db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
"""Generates a quiz based on pending errors."""
|
||||
# Determine which API key to use
|
||||
api_key = await get_effective_api_key(user, db)
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="No OpenRouter API key configured. Please add your API key in Settings or contact your administrator."
|
||||
)
|
||||
|
||||
from app.modules.quiz.models import RemediationQueue
|
||||
|
||||
# 1. Fetch pending items
|
||||
conditions = [
|
||||
RemediationQueue.learner_id == learner_id,
|
||||
RemediationQueue.status == "PENDING"
|
||||
]
|
||||
if revision_id:
|
||||
conditions.append(RemediationQueue.revision_id == revision_id)
|
||||
|
||||
# Fetch a pool of recent errors (e.g. 20) then randomize selection
|
||||
statement = select(RemediationQueue).where(*conditions).order_by(RemediationQueue.created_at.desc()).limit(20)
|
||||
|
||||
result = await db.execute(statement)
|
||||
items = result.scalars().all()
|
||||
|
||||
# Convert to list to shuffle
|
||||
items = list(items)
|
||||
import random
|
||||
random.shuffle(items)
|
||||
|
||||
# Review ALL fetched items (up to limit), not just 5.
|
||||
# items = items[:5]
|
||||
|
||||
if len(items) < 1:
|
||||
raise HTTPException(status_code=400, detail="Not enough errors to generate a quiz.")
|
||||
|
||||
# 2. Build context
|
||||
remediation_context = [
|
||||
{
|
||||
"question": item.question,
|
||||
"wrong_answer": item.wrong_answer,
|
||||
"correct_answer": item.correct_answer,
|
||||
"context": item.original_content
|
||||
} for item in items
|
||||
]
|
||||
|
||||
# 3. Fetch source text if Revision ID is explicitly provided
|
||||
source_text = None
|
||||
if revision_id:
|
||||
from app.modules.quiz.models import Revision
|
||||
rev = await db.get(Revision, revision_id)
|
||||
if rev:
|
||||
source_text = rev.text_content
|
||||
|
||||
# 4. Generate Quiz
|
||||
try:
|
||||
# We need to expose a generic generate function or specific one
|
||||
from app.modules.quiz.service import generate_remediation_quiz_service
|
||||
data = await generate_remediation_quiz_service(remediation_context, api_key, source_text=source_text)
|
||||
|
||||
# Update usage
|
||||
usage = data.get("usage", {})
|
||||
user.total_tokens_used += usage.get("total_tokens", 0)
|
||||
user.total_cost_usd += usage.get("total_tokens", 0) * 0.0000001
|
||||
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
|
||||
quiz_content = data["quiz"]
|
||||
|
||||
# Force topic to indicate remediation for reliable detection downstream
|
||||
if "remedia" not in quiz_content.get("topic", "").lower():
|
||||
original_topic = quiz_content.get("topic", "Révision")
|
||||
quiz_content["topic"] = f"{original_topic} (Remediation)"
|
||||
|
||||
# Inject revision_id if present so it persists through the quiz lifecycle
|
||||
if revision_id:
|
||||
quiz_content["revision_id"] = str(revision_id)
|
||||
|
||||
return quiz_content
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"Error in generate_remediation_quiz: {str(e)}\n{traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/stats/mastery")
|
||||
async def get_mastery_stats(
|
||||
learner_id: uuid.UUID,
|
||||
user: User = Depends(current_active_user),
|
||||
db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
"""Calculates mastery level per topic."""
|
||||
from app.modules.quiz.models import RemediationQueue
|
||||
|
||||
# 1. Get all scores
|
||||
scores_stmt = select(Score).where(Score.learner_id == learner_id)
|
||||
result = await db.execute(scores_stmt)
|
||||
scores = result.scalars().all()
|
||||
|
||||
if not scores:
|
||||
return []
|
||||
|
||||
# 2. Group by Normalized Topic
|
||||
# We want to merge "Maths" and "Maths (Remediation)" into "Maths"
|
||||
# And we want to prioritize RECENT scores.
|
||||
|
||||
topic_map = {}
|
||||
|
||||
for s in scores:
|
||||
# Normalize topic
|
||||
clean_topic = s.topic.replace(" (Remediation)", "").replace(" (Remédiation)", "").strip()
|
||||
|
||||
if clean_topic not in topic_map:
|
||||
topic_map[clean_topic] = []
|
||||
|
||||
topic_map[clean_topic].append(s)
|
||||
|
||||
# 3. Calculate Mastery per Topic using Weighted Average of last 5 attempts
|
||||
mastery_list = []
|
||||
|
||||
# Pre-fetch errors to avoid N+1 queries
|
||||
errors_stmt = select(RemediationQueue).where(
|
||||
RemediationQueue.learner_id == learner_id,
|
||||
RemediationQueue.status == "PENDING"
|
||||
)
|
||||
result = await db.execute(errors_stmt)
|
||||
errors = result.scalars().all()
|
||||
|
||||
error_counts = {}
|
||||
for e in errors:
|
||||
# Also clean topic for errors if needed, or rely on exact match?
|
||||
# Ideally errors should also be grouped by clean topic.
|
||||
t_key = e.topic.replace(" (Remediation)", "").replace(" (Remédiation)", "").strip()
|
||||
error_counts[t_key] = error_counts.get(t_key, 0) + 1
|
||||
|
||||
for topic, topic_scores in topic_map.items():
|
||||
# Sort by date asc (oldest first)
|
||||
topic_scores.sort(key=lambda x: x.created_at)
|
||||
|
||||
# Take last 5 scores
|
||||
recent_scores = topic_scores[-5:]
|
||||
|
||||
# Calculate Weighted Average
|
||||
# Example: [50, 60, 70] -> (50*1 + 60*2 + 70*3) / (1+2+3)
|
||||
total_weight = 0
|
||||
weighted_sum = 0
|
||||
|
||||
for i, s in enumerate(recent_scores):
|
||||
weight = i + 1
|
||||
# Percentage for this quiz
|
||||
pct = (s.score / s.total_questions) * 100 if s.total_questions > 0 else 0
|
||||
weighted_sum += pct * weight
|
||||
total_weight += weight
|
||||
|
||||
base_mastery = weighted_sum / total_weight if total_weight > 0 else 0
|
||||
|
||||
# Apply penalty for pending errors
|
||||
pending_errors = error_counts.get(topic, 0)
|
||||
penalty = pending_errors * 3 # Reduced penalty from 5 to 3
|
||||
|
||||
final_mastery = max(0, min(100, base_mastery - penalty))
|
||||
|
||||
status = "LEARNING"
|
||||
if final_mastery >= 80:
|
||||
status = "MASTERED"
|
||||
elif final_mastery >= 50:
|
||||
status = "REVIEWING"
|
||||
|
||||
last_activity = topic_scores[-1].created_at
|
||||
|
||||
# Find the latest revision for this topic to get synthesis and tips
|
||||
latest_revision = None
|
||||
# topic_scores are already sorted by date asc, so let's find latest revision
|
||||
# Actually, let's find the latest revision object directly.
|
||||
# Revision might have a different topic name? No, normalization should match.
|
||||
from app.modules.quiz.models import Revision
|
||||
rev_stmt = select(Revision).where(
|
||||
Revision.learner_id == learner_id,
|
||||
Revision.topic == topic
|
||||
).order_by(Revision.created_at.desc()).limit(1)
|
||||
rev_result = await db.execute(rev_stmt)
|
||||
latest_revision = rev_result.scalar_one_or_none()
|
||||
|
||||
mastery_list.append({
|
||||
"topic": topic,
|
||||
"mastery_score": int(final_mastery),
|
||||
"quizzes_count": len(topic_scores),
|
||||
"pending_errors": pending_errors,
|
||||
"status": status,
|
||||
"last_activity": last_activity,
|
||||
"synthesis": latest_revision.synthesis if latest_revision else None,
|
||||
"study_tips": latest_revision.study_tips if latest_revision else None
|
||||
})
|
||||
|
||||
# Sort by last activity
|
||||
mastery_list.sort(key=lambda x: x['last_activity'], reverse=True)
|
||||
|
||||
return mastery_list
|
||||
|
||||
@router.get("/stats/activity")
|
||||
async def get_activity_stats(
|
||||
learner_id: Optional[uuid.UUID] = None,
|
||||
user: User = Depends(current_active_user),
|
||||
db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
"""
|
||||
Returns aggregated activity stats:
|
||||
- Summary: Total minutes today, this week.
|
||||
- History: Daily breakdown of activities (Revisions + Quizzes).
|
||||
"""
|
||||
from app.modules.quiz.models import Revision
|
||||
from datetime import timedelta
|
||||
|
||||
# helper to format date
|
||||
def to_date_str(dt):
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
|
||||
# Fetch Revisions
|
||||
revisions_stmt = select(Revision).where(Revision.learner_id == learner_id).order_by(Revision.created_at.desc())
|
||||
revisions = (await db.execute(revisions_stmt)).scalars().all()
|
||||
|
||||
# Fetch Scores (Quizzes)
|
||||
scores_stmt = select(Score).where(Score.learner_id == learner_id).order_by(Score.created_at.desc())
|
||||
scores = (await db.execute(scores_stmt)).scalars().all()
|
||||
|
||||
# Fetch Pending Remediation Counts per Revision
|
||||
from app.modules.quiz.models import RemediationQueue
|
||||
from sqlalchemy import func
|
||||
|
||||
remediation_stmt = select(
|
||||
RemediationQueue.revision_id,
|
||||
func.count(RemediationQueue.id)
|
||||
).where(
|
||||
RemediationQueue.learner_id == learner_id,
|
||||
RemediationQueue.status == "PENDING",
|
||||
RemediationQueue.revision_id.isnot(None)
|
||||
).group_by(RemediationQueue.revision_id)
|
||||
|
||||
remediation_counts_result = await db.execute(remediation_stmt)
|
||||
remediation_map = {row[0]: row[1] for row in remediation_counts_result.all()}
|
||||
|
||||
activities = []
|
||||
|
||||
# Assumption:
|
||||
# - 1 Revision = 5 minutes
|
||||
# - 1 Quiz (Score) = 3 minutes
|
||||
TIME_PER_REVISION = 5
|
||||
TIME_PER_QUIZ = 3
|
||||
|
||||
for r in revisions:
|
||||
pending_errors = remediation_map.get(r.id, 0)
|
||||
activities.append({
|
||||
"type": "REVISION",
|
||||
"id": r.id,
|
||||
"topic": r.topic,
|
||||
"subject": r.subject, # Add subject
|
||||
"created_at": r.created_at,
|
||||
"minutes": TIME_PER_REVISION,
|
||||
"details": "Révision de cours",
|
||||
"pending_errors": pending_errors, # Add pending errors count
|
||||
"current_series": r.current_series,
|
||||
"total_series": r.total_series,
|
||||
"completed_series": r.completed_series,
|
||||
"status": r.status # Add status
|
||||
})
|
||||
|
||||
for s in scores:
|
||||
activities.append({
|
||||
"type": "QUIZ",
|
||||
"id": s.id,
|
||||
"revision_id": s.revision_id,
|
||||
"topic": s.topic,
|
||||
"created_at": s.created_at,
|
||||
"minutes": TIME_PER_QUIZ,
|
||||
"details": f"Quiz ({s.score}/{s.total_questions})"
|
||||
})
|
||||
|
||||
# Sort all by date desc
|
||||
activities.sort(key=lambda x: x["created_at"], reverse=True)
|
||||
|
||||
# Group by Date
|
||||
history = {}
|
||||
today_str = to_date_str(datetime.utcnow())
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
|
||||
summary = {
|
||||
"today_minutes": 0,
|
||||
"week_minutes": 0,
|
||||
"total_quizzes": len(scores),
|
||||
"total_revisions": len(revisions)
|
||||
}
|
||||
|
||||
for act in activities:
|
||||
date_key = to_date_str(act["created_at"])
|
||||
if date_key not in history:
|
||||
history[date_key] = {
|
||||
"date": date_key,
|
||||
"total_minutes": 0,
|
||||
"items": []
|
||||
}
|
||||
|
||||
history[date_key]["items"].append(act)
|
||||
history[date_key]["total_minutes"] += act["minutes"]
|
||||
|
||||
# Summary Aggregation
|
||||
if date_key == today_str:
|
||||
summary["today_minutes"] += act["minutes"]
|
||||
|
||||
if act["created_at"] >= week_ago:
|
||||
summary["week_minutes"] += act["minutes"]
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
"history": list(history.values())
|
||||
}
|
||||
|
||||
@router.post("/reset", response_model=QuizResponse)
|
||||
async def reset_revision(
|
||||
revision_id: uuid.UUID = Body(..., embed=True),
|
||||
user: User = Depends(current_active_user),
|
||||
db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
"""Resets a revision to Series 1 to allow starting over."""
|
||||
api_key = await get_effective_api_key(user, db)
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=401, detail="No API Key")
|
||||
|
||||
from app.modules.quiz.models import Revision
|
||||
revision = await db.get(Revision, revision_id)
|
||||
if not revision:
|
||||
raise HTTPException(status_code=404, detail="Revision not found")
|
||||
|
||||
# Reset State
|
||||
revision.current_series = 1
|
||||
revision.completed_series = 0
|
||||
revision.status = "IN_PROGRESS"
|
||||
revision.progress_state = None
|
||||
|
||||
try:
|
||||
# Generate new quiz for Series 1
|
||||
data = await generate_quiz(
|
||||
revision.text_content,
|
||||
api_key,
|
||||
series_index=1
|
||||
)
|
||||
|
||||
revision.quiz_data = import_json.dumps(data["quiz"])
|
||||
revision.updated_at = datetime.utcnow()
|
||||
|
||||
db.add(revision)
|
||||
await db.commit()
|
||||
await db.refresh(revision)
|
||||
|
||||
response_quiz = data["quiz"]
|
||||
response_quiz["revision_id"] = revision.id
|
||||
response_quiz["series_info"] = {
|
||||
"current": 1,
|
||||
"total": revision.total_series
|
||||
}
|
||||
|
||||
return response_quiz
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"Error in reset_revision: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to reset revision.")
|
||||
|
||||
@router.delete("/revision/{revision_id}")
|
||||
async def delete_revision(
|
||||
revision_id: uuid.UUID,
|
||||
user: User = Depends(current_active_user),
|
||||
db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
"""Deletes a revision and its related data (Scores, RemediationQueue)."""
|
||||
from app.modules.quiz.models import Revision, Score, RemediationQueue
|
||||
|
||||
revision = await db.get(Revision, revision_id)
|
||||
if not revision:
|
||||
raise HTTPException(status_code=404, detail="Revision not found")
|
||||
|
||||
# Cascade deletes
|
||||
stmt_remed = delete(RemediationQueue).where(RemediationQueue.revision_id == revision_id)
|
||||
await db.execute(stmt_remed)
|
||||
|
||||
stmt_score = delete(Score).where(Score.revision_id == revision_id)
|
||||
await db.execute(stmt_score)
|
||||
|
||||
await db.delete(revision)
|
||||
await db.commit()
|
||||
|
||||
return {"status": "success", "deleted_id": str(revision_id)}
|
||||
@@ -0,0 +1,50 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
class QuizRequest(BaseModel):
|
||||
text_content: str
|
||||
title: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
difficulty: str = "medium"
|
||||
learner_id: Optional[uuid.UUID] = None # To link revision
|
||||
synthesis: Optional[str] = None
|
||||
study_tips: Optional[List[str]] = None
|
||||
|
||||
class Question(BaseModel):
|
||||
id: int
|
||||
question: str
|
||||
options: List[str]
|
||||
correct_answer: int # Index of the correct option (0-3)
|
||||
explanation: str
|
||||
|
||||
class QuizResponse(BaseModel):
|
||||
topic: str
|
||||
questions: List[Question]
|
||||
revision_id: Optional[uuid.UUID] = None
|
||||
|
||||
class QuestionResult(BaseModel):
|
||||
question: str
|
||||
user_answer: str
|
||||
correct_answer: str
|
||||
is_correct: bool
|
||||
original_content: Optional[str] = None # To trace back to source text
|
||||
|
||||
class ScoreCreate(BaseModel):
|
||||
topic: str
|
||||
score: int
|
||||
total_questions: int
|
||||
learner_id: Optional[uuid.UUID] = None
|
||||
details: List[QuestionResult] = []
|
||||
revision_id: Optional[uuid.UUID] = None
|
||||
|
||||
class ScoreResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
topic: str
|
||||
score: int
|
||||
total_questions: int
|
||||
created_at: datetime
|
||||
learner_id: Optional[uuid.UUID] = None
|
||||
new_badges: List[str] = []
|
||||
revision_id: Optional[uuid.UUID] = None
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Quiz/Flashcard generation service."""
|
||||
import json
|
||||
import httpx
|
||||
from typing import Dict, Any, List
|
||||
from app.modules.ingest.service import OPENROUTER_API_URL, DEFAULT_MODEL
|
||||
|
||||
SYSTEM_PROMPT = """You are an expert French teacher.
|
||||
Create a multiple-choice quiz (QCM) based on the provided lesson text.
|
||||
|
||||
Guidelines:
|
||||
1. **Language**: French.
|
||||
2. **Topic**: Extract the main topic/subject of the text.
|
||||
3. **Target Audience**: Students.
|
||||
4. **Format**: {num_questions} questions.
|
||||
5. **Difficulty**: {difficulty}
|
||||
|
||||
Output strictly valid JSON with this structure. Do NOT include markdown formatting (like ```json), introduction, or conclusion. Just the raw JSON object.
|
||||
{{
|
||||
"topic": "string",
|
||||
"questions": [
|
||||
{{
|
||||
"id": 1,
|
||||
"question": "The question text",
|
||||
"options": ["Option A", "Option B", "Option C", "Option D"],
|
||||
"correct_answer": 0, // Index 0-3
|
||||
"explanation": "Brief explanation."
|
||||
}}
|
||||
]
|
||||
}}
|
||||
"""
|
||||
|
||||
async def generate_quiz(text_content: str, api_key: str, difficulty: str = "medium", series_index: int = 1) -> Dict[str, Any]:
|
||||
"""Generates a quiz from text content using LLM."""
|
||||
|
||||
# Determine number of questions based on text length
|
||||
# Smart Quiz Sizing based on "Information Density"
|
||||
# Heuristic: ~400 characters (approx 60-80 words) usually contain 1 indexable fact.
|
||||
length = len(text_content)
|
||||
|
||||
# 1. Estimate maximal unique questions possible
|
||||
density_factor = 400
|
||||
max_unique_questions = max(3, int(length / density_factor))
|
||||
|
||||
# 2. Determine session size (Cap at 15 to prevent cognitive overload)
|
||||
target_questions = min(max_unique_questions, 15)
|
||||
|
||||
# 3. Adjust for very short texts to avoid hallucinations/redundancy
|
||||
if length < 600:
|
||||
target_questions = 3
|
||||
elif length < 1200:
|
||||
target_questions = 5
|
||||
|
||||
num_questions = target_questions
|
||||
|
||||
# Calculate potential series (Party Mode "Revision Series" concept)
|
||||
# This is just internal logic for now, could be returned in metadata later
|
||||
total_series = (int(length / 300) // 15) + 1
|
||||
|
||||
print(f"Smart Sizing: Length={length} chars -> {num_questions} questions (Series estimate: {total_series})")
|
||||
|
||||
# Series Context Hint
|
||||
series_hint = ""
|
||||
if total_series > 1:
|
||||
series_hint = f"\n\nCONTEXT: This text is long and is split into {total_series} parts. This is Part {series_index}. Please focus your questions on the section of the text corresponding roughly to part {series_index}/{total_series}."
|
||||
|
||||
prompt = SYSTEM_PROMPT.format(difficulty=difficulty, num_questions=num_questions) + series_hint
|
||||
|
||||
payload = {
|
||||
"model": DEFAULT_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": prompt},
|
||||
{"role": "user", "content": f"Here is the lesson text:\n\n{text_content}"}
|
||||
],
|
||||
"response_format": {"type": "json_object"},
|
||||
"max_tokens": 4000
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://reviflow.app",
|
||||
"X-Title": "Reviflow"
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
OPENROUTER_API_URL,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=60.0
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"OpenRouter API error ({response.status_code}): {response.text}")
|
||||
|
||||
result = response.json()
|
||||
content = result["choices"][0]["message"]["content"]
|
||||
usage = result.get("usage", {})
|
||||
|
||||
try:
|
||||
return {
|
||||
"quiz": json.loads(content),
|
||||
"usage": usage,
|
||||
"meta": {"total_series": total_series}
|
||||
}
|
||||
except json.JSONDecodeError:
|
||||
print(f"JSON Parse Error - Raw Content: {content}")
|
||||
# Robust Fallback strategy
|
||||
import re
|
||||
cleaned = content
|
||||
|
||||
# 1. Try finding a markdown code block
|
||||
json_match = re.search(r'```json\s*(.*?)\s*```', content, re.DOTALL)
|
||||
if json_match:
|
||||
cleaned = json_match.group(1)
|
||||
else:
|
||||
# 2. Try finding the outermost JSON object
|
||||
# Find first '{'
|
||||
start = content.find('{')
|
||||
# Find last '}'
|
||||
end = content.rfind('}')
|
||||
|
||||
if start != -1 and end != -1:
|
||||
cleaned = content[start:end+1]
|
||||
|
||||
try:
|
||||
return {
|
||||
"quiz": json.loads(cleaned),
|
||||
"usage": usage,
|
||||
"meta": {"total_series": total_series}
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Deep Parse Failed: {e}")
|
||||
raise Exception("Failed to parse AI response as JSON. Content might be truncated or invalid.")
|
||||
|
||||
async def generate_remediation_quiz_service(context_items: List[Dict[str, Any]], api_key: str, source_text: str = None) -> Dict[str, Any]:
|
||||
"""Generates a remediation quiz based on errors."""
|
||||
|
||||
# Extract topics from context or questions to guide the LLM
|
||||
# This prevents it from thinking the topic is "Remediation"
|
||||
topics_context = list(set([item['context'] or "General" for item in context_items if item['context']]))
|
||||
joined_topics = ", ".join(topics_context)
|
||||
|
||||
errors_desc = "\n".join([
|
||||
f"- Failed Question: {item['question']}\n Student's Wrong Answer: {item['wrong_answer']}\n Correct Answer: {item['correct_answer']}\n Subject Context: {item['context']}"
|
||||
for item in context_items
|
||||
])
|
||||
|
||||
context_injection = ""
|
||||
if source_text:
|
||||
# Truncate source text if too long to save tokens, but keep enough for context
|
||||
# 1000 chars is usually enough for key concepts
|
||||
truncated_text = source_text[:3000]
|
||||
context_injection = f"\n\nREFERENCE LESSON TEXT:\n{truncated_text}\n\n"
|
||||
|
||||
REMEDIATION_SYSTEM_PROMPT = f"""You are an expert tutor in the following subject(s): {joined_topics}.
|
||||
Your Goal: Create a remedial quiz to help a student understand concepts they failed previously.
|
||||
|
||||
Input: A list of specific questions the student got wrong, including their wrong answer and the correct answer.
|
||||
|
||||
Task:
|
||||
1. Read the provided REFERENCE LESSON TEXT (if available) to understand the exact curriculum and context.
|
||||
2. Identify the underlying concept for EACH failed question.
|
||||
3. Generate a NEW multiple-choice question to test that SAME concept, but phrased differently or using a different example.
|
||||
4. You MUST generate exactly one new question for every failed question provided in the input list. If there are 10 errors, generate 10 new questions.
|
||||
5. The "topic" field in the JSON should be: "{joined_topics} (Remediation)".
|
||||
6. **CRITICAL**: The questions must be about the SUBJECT MATTER ({joined_topics}). Do NOT generate questions about "remediation", "learning strategies", or "translation".
|
||||
7. **Language**: output MUST be in French.
|
||||
8. Provide a clear, helpful "explanation" for the correct answer.
|
||||
|
||||
Output strictly valid JSON with this EXACT structure:
|
||||
{{
|
||||
"topic": "string",
|
||||
"questions": [
|
||||
{{
|
||||
"id": 1,
|
||||
"question": "New question text...",
|
||||
"options": ["Option A", "Option B", "Option C", "Option D"],
|
||||
"correct_answer": 0,
|
||||
"explanation": "Clear explanation of why this answer is right."
|
||||
}}
|
||||
]
|
||||
}}
|
||||
"""
|
||||
|
||||
payload = {
|
||||
"model": DEFAULT_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": REMEDIATION_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": f"{context_injection}The student made the following mistakes (Generate exactly {len(context_items)} questions):\n{errors_desc}"}
|
||||
],
|
||||
"response_format": {"type": "json_object"},
|
||||
"max_tokens": 8000
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://reviflow.app",
|
||||
"X-Title": "Reviflow"
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
OPENROUTER_API_URL,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=120.0
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"OpenRouter API error ({response.status_code}): {response.text}")
|
||||
|
||||
result = response.json()
|
||||
content = result["choices"][0]["message"]["content"]
|
||||
usage = result.get("usage", {})
|
||||
|
||||
# --- Robust JSON Parsing Strategy ---
|
||||
def try_parse_json(text):
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
# 1. Direct parse
|
||||
parsed = try_parse_json(content)
|
||||
|
||||
# 2. Markdown block extraction
|
||||
if not parsed:
|
||||
import re
|
||||
json_match = re.search(r'```json\s*(.*?)\s*```', content, re.DOTALL)
|
||||
if json_match:
|
||||
parsed = try_parse_json(json_match.group(1))
|
||||
|
||||
# 3. Brute-force substring extraction (find outer {})
|
||||
if not parsed:
|
||||
start = content.find('{')
|
||||
end = content.rfind('}')
|
||||
if start != -1 and end != -1:
|
||||
parsed = try_parse_json(content[start:end+1])
|
||||
|
||||
# 4. Repair Truncated JSON (Simple Auto-Close)
|
||||
if not parsed:
|
||||
# Heuristic: If it looks like it ended abruptly, try adding closures
|
||||
# Try adding "]}" or "}]}" or just "}"
|
||||
candidates = [content + "}", content + "]}", content + "}]}", content + '"}]}', content + '"]}}' ]
|
||||
for candidate in candidates:
|
||||
# Need to strip potential markdown markers if we are patching the raw content
|
||||
clean_candidate = candidate
|
||||
start = clean_candidate.find('{')
|
||||
if start != -1:
|
||||
clean_candidate = clean_candidate[start:]
|
||||
|
||||
parsed = try_parse_json(clean_candidate)
|
||||
if parsed:
|
||||
print("WARN: JSON was truncated but successfully repaired.")
|
||||
break
|
||||
|
||||
if parsed:
|
||||
return {
|
||||
"quiz": parsed,
|
||||
"usage": usage
|
||||
}
|
||||
|
||||
print(f"FAILED JSON CONTENT (First 500 chars): {content[:500]}...")
|
||||
print(f"FAILED JSON CONTENT (Last 500 chars): {content[-500:]}...")
|
||||
raise Exception("Failed to parse AI response as JSON (Malformed or Truncated)")
|
||||
@@ -0,0 +1,21 @@
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.append(os.getcwd())
|
||||
|
||||
from sqlalchemy import text
|
||||
from app.core.db import engine
|
||||
|
||||
async def check_learner_scores():
|
||||
learner_id = '21da4b0e-18d8-431c-bc62-b35e7677d981'
|
||||
async with engine.begin() as conn:
|
||||
print(f"Checking Score table for learner {learner_id}...")
|
||||
result = await conn.execute(text(f"SELECT id, topic, score, created_at FROM score WHERE learner_id = '{learner_id}'"))
|
||||
rows = result.fetchall()
|
||||
print(f"Found {len(rows)} scores.")
|
||||
for row in rows:
|
||||
print(f"Topic: '{row.topic}', Score: {row.score}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(check_learner_scores())
|
||||
@@ -0,0 +1,19 @@
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.append(os.getcwd())
|
||||
|
||||
from sqlalchemy import text
|
||||
from app.core.db import engine
|
||||
|
||||
async def check_db():
|
||||
async with engine.begin() as conn:
|
||||
print("Checking Score table...")
|
||||
result = await conn.execute(text("SELECT id, topic, score, learner_id, created_at FROM score ORDER BY created_at DESC LIMIT 5;"))
|
||||
rows = result.fetchall()
|
||||
for row in rows:
|
||||
print(f"ID: {row.id}, Topic: '{row.topic}', Score: {row.score}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(check_db())
|
||||
@@ -0,0 +1,44 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add parent directory to sys.path to allow imports
|
||||
sys.path.append(os.getcwd())
|
||||
|
||||
from app.core.db import async_session_maker
|
||||
from app.modules.auth.models import User, LearnerProfile, UserRole
|
||||
from sqlalchemy import select
|
||||
|
||||
async def check():
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
users_result = await session.execute(select(User))
|
||||
users = users_result.scalars().all()
|
||||
print('\n--- Users ---')
|
||||
for u in users:
|
||||
print(f"ID: {u.id} | Username: {u.username} | Email: {u.email} | Role: {u.role} | ParentID: {u.parent_id}")
|
||||
|
||||
profiles_result = await session.execute(select(LearnerProfile))
|
||||
profiles = profiles_result.scalars().all()
|
||||
print('\n--- Profiles ---')
|
||||
for p in profiles:
|
||||
print(f"ID: {p.id} | UserID: {p.user_id} | Name: {p.first_name}")
|
||||
|
||||
print('\n--- Relationship Check ---')
|
||||
for u in users:
|
||||
if u.role == UserRole.LEARNER:
|
||||
# Explicitly check for profile
|
||||
profile_res = await session.execute(select(LearnerProfile).where(LearnerProfile.user_id == u.id))
|
||||
profile = profile_res.scalar_one_or_none()
|
||||
if profile:
|
||||
print(f"Child {u.username} has profile {profile.first_name}")
|
||||
else:
|
||||
print(f"Child {u.username} MISSING PROFILE!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during check: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(check())
|
||||
@@ -0,0 +1,46 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add parent directory to sys.path to allow imports
|
||||
sys.path.append(os.getcwd())
|
||||
|
||||
from app.core.db import async_session_maker
|
||||
from app.modules.auth.models import User, LearnerProfile, UserRole
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
async def check():
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
# Re-fetch parent like the router does
|
||||
# Role user: 04015eb2-a78b-46ee-9337-f1bd94b8b951
|
||||
stmt = select(User).where(User.email == "michaelschal@gmail.com").options(
|
||||
selectinload(User.children).selectinload(User.learner_profile)
|
||||
)
|
||||
res = await session.execute(stmt)
|
||||
parent = res.scalar_one_or_none()
|
||||
|
||||
if not parent:
|
||||
print("Parent not found!")
|
||||
return
|
||||
|
||||
print(f"Parent: {parent.username} | Children count: {len(parent.children)}")
|
||||
for child in parent.children:
|
||||
print(f" - Child: {child.username} | Profile: {child.learner_profile.first_name if child.learner_profile else 'NONE'}")
|
||||
|
||||
# Direct check for children in DB
|
||||
from sqlalchemy import func
|
||||
children_res = await session.execute(select(User).where(User.parent_id == parent.id))
|
||||
db_children = children_res.scalars().all()
|
||||
print(f"Direct DB check - Children with parent_id={parent.id} in DB: {len(db_children)}")
|
||||
for c in db_children:
|
||||
print(f" - DB Child: {c.username} (ID: {c.id})")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(check())
|
||||
@@ -0,0 +1,26 @@
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
db_path = 'reviflow.db'
|
||||
if not os.path.exists(db_path):
|
||||
print(f"Database {db_path} not found")
|
||||
exit(1)
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check current values
|
||||
cursor.execute("SELECT DISTINCT role FROM users")
|
||||
roles = cursor.fetchall()
|
||||
print(f"Current roles in DB: {roles}")
|
||||
|
||||
# Update to uppercase to match Enum member names if that's what's causing the lookup error
|
||||
cursor.execute("UPDATE users SET role = 'PARENT' WHERE role = 'parent'")
|
||||
print(f"Updated {cursor.rowcount} 'parent' rows to 'PARENT'")
|
||||
|
||||
cursor.execute("UPDATE users SET role = 'LEARNER' WHERE role = 'learner'")
|
||||
print(f"Updated {cursor.rowcount} 'learner' rows to 'LEARNER'")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("Database update complete")
|
||||
@@ -0,0 +1,27 @@
|
||||
[tool.poetry]
|
||||
name = "reviflow-backend"
|
||||
version = "0.1.0"
|
||||
description = "Backend for Reviflow EdTech App"
|
||||
authors = ["Michael <michael@example.com>"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.11"
|
||||
fastapi = "^0.109.0"
|
||||
uvicorn = "^0.27.0"
|
||||
pydantic = "^2.6.0"
|
||||
pydantic-settings = "^2.1.0"
|
||||
sqlmodel = "^0.0.14"
|
||||
fastapi-users = "^13.0.0"
|
||||
fastapi-users-db-sqlalchemy = "^6.0.0"
|
||||
aiosqlite = "^0.19.0"
|
||||
asyncpg = "^0.29.0"
|
||||
python-multipart = "^0.0.9"
|
||||
openai = "^1.10.0" # For OpenRouter
|
||||
httpx = "^0.26.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = "^8.0.0"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
@@ -0,0 +1,68 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
import uuid
|
||||
|
||||
# Add parent directory to sys.path to allow imports
|
||||
sys.path.append(os.getcwd())
|
||||
|
||||
from app.core.db import async_session_maker
|
||||
from app.modules.auth.models import User, LearnerProfile, UserRole
|
||||
from app.modules.auth.service import UserManager, UserDatabase
|
||||
from app.modules.auth.schemas import UserCreate
|
||||
from fastapi_users.password import PasswordHelper
|
||||
|
||||
async def simulate_creation():
|
||||
async with async_session_maker() as session:
|
||||
user_db = UserDatabase(session, User)
|
||||
user_manager = UserManager(user_db)
|
||||
|
||||
# Find the parent user
|
||||
from sqlalchemy import select
|
||||
res = await session.execute(select(User).where(User.email == "michaelschal@gmail.com"))
|
||||
parent = res.scalar_one_or_none()
|
||||
|
||||
if not parent:
|
||||
print("Parent user not found!")
|
||||
return
|
||||
|
||||
print(f"Found parent: {parent.email} (ID: {parent.id})")
|
||||
|
||||
# Simulation data like in the frontend
|
||||
first_name = "Léo"
|
||||
username = first_name.lower().replace(" ", "")
|
||||
email = f"{username}@reviflow.app"
|
||||
|
||||
print(f"Attempting to create child: {username} with email {email}")
|
||||
|
||||
try:
|
||||
# Create user
|
||||
child_user = await user_manager.create(
|
||||
UserCreate(
|
||||
email=email,
|
||||
password="1234",
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
role=UserRole.LEARNER,
|
||||
parent_id=parent.id
|
||||
)
|
||||
)
|
||||
print(f"Child user created: {child_user.id}")
|
||||
|
||||
# Create profile
|
||||
new_profile = LearnerProfile(
|
||||
user_id=child_user.id,
|
||||
first_name=first_name,
|
||||
avatar_url="http://example.com/avatar.png"
|
||||
)
|
||||
session.add(new_profile)
|
||||
await session.commit()
|
||||
print("Profile created and committed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"FAILURE: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(simulate_creation())
|
||||
@@ -0,0 +1 @@
|
||||
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{top:0;bottom:0}.-bottom-10{bottom:-2.5rem}.-bottom-2{bottom:-.5rem}.-right-1{right:-.25rem}.-right-10{right:-2.5rem}.-right-2{right:-.5rem}.-toLine truncated
|
||||
@@ -0,0 +1,140 @@
|
||||
(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const o of document.querySelectorAll('link[rel="modulepreload"]'))a(o);new MutationObserver(o=>{for(const u of o)if(u.type==="childList")for(const c of u.addedNodes)c.tagName==="LINK"&&c.rel==="modulepreload"&&a(c)}).observe(document,{childList:!0,subtree:!0});function r(o){const u={};return o.integrity&&(u.integrity=o.integrity),o.referrerPolicy&&(u.referrerPolicy=o.referrerPolicy),o.crossOrigin==="use-credentials"?u.credentials="include":o.crossOrigin==="anonymous"?u.credentials="omit":u.credentials="same-origin",u}function a(o){if(o.ep)return;o.ep=!0;const u=r(o);fetch(o.href,u)}})();function zx(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var pd={exports:{}},Ks={};var V0;function l2(){if(V0)return Ks;V0=1;var e=Symbol.for("react.transitional.element"),n=Symbol.for("react.fragment");function r(a,o,u){var c=null;if(u!==void 0&&(c=""+u),o.key!==void 0&&(c=""+o.key),"key"in o){u={};for(var d in o)d!=="key"&&(u[d]=o[d])}else u=o;return o=u.ref,{$$typeof:e,type:a,key:c,ref:o!==void 0?o:null,props:u}}return Ks.Fragment=n,Ks.jsx=r,Ks.jsxs=r,Ks}var U0;function u2(){return U0||(U0=1,pd.exports=l2()),pd.exports}var v=u2(),gd={exports:{}},je={};var B0;function c2(){if(B0)return je;B0=1;var e=Symbol.for("react.transitional.element"),n=Symbol.for("react.portal"),r=Symbol.for("react.fragment"),a=Symbol.for("react.strict_mode"),o=Symbol.for("react.profiler"),u=Symbol.for("react.consumer"),c=Symbol.for("react.context"),d=Symbol.for("react.forward_ref"),m=Symbol.for("react.suspense"),p=Symbol.for("react.memo"),g=Symbol.for("react.lazy"),y=Symbol.for("react.activity"),x=Symbol.iterator;function E(w){return w===null||typeof w!="object"?null:(w=x&&w[x]||w["@@iterator"],typeof w=="function"?w:null)}var T={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},A=Object.assign,_={};function M(w,k,q){this.props=w,this.context=k,this.refs=_,this.updater=q||T}M.prototype.isReactComponent={},M.prototype.setState=function(w,k){if(typeof w!="object"&&typeof w!="function"&&w!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,w,k,"setState")},M.prototype.forceUpdate=function(w){this.updater.enqueueForceUpdate(this,w,"forceUpdate")};function z(){}z.prototype=M.prototype;function D(w,k,q){this.props=w,this.context=k,this.refs=_,this.updater=q||T}var F=D.prototype=new z;F.constructor=D,A(F,M.prototype),F.isPureReactComponent=!0;var Z=Array.isArray;function se(){}var ne={H:null,A:null,T:null,S:null},Y=Object.prototype.hasOwnProperty;function ge(w,k,q){var Q=q.ref;return{$$typeof:e,type:w,key:k,ref:Q!==void 0?Q:null,props:q}}function ve(w,k){return ge(w.type,k,w.props)}function Fe(w){return typeof w=="object"&&w!==null&&w.$$typeof===e}function ce(w){var k={"=":"=0",":":"=2"};return"$"+w.replace(/[=:]/g,function(q){return k[q]})}var Ee=/\/+/g;function Ce(w,k){return typeof w=="object"&&w!==null&&w.key!=null?ce(""+w.key):k.toString(36)}function Ne(w){switch(w.status){case"fulfilled":return w.value;case"rejected":throw w.reason;default:switch(typeof w.status=="string"?w.then(se,se):(w.status="pending",w.then(function(k){w.status==="pending"&&(w.status="fulfilled",w.value=k)},function(k){w.status==="pending"&&(w.status="rejected",w.reason=k)})),w.status){case"fulfilled":return w.value;case"rejected":throw w.reason}}throw w}function V(w,k,q,Q,ae){var ue=typeof w;(ue==="undefined"||ue==="boolean")&&(w=null);var ie=!1;if(w===null)ie=!0;else switch(ue){case"bigint":case"string":case"number":ie=!0;break;case"object":switch(w.$$typeof){case e:case n:ie=!0;break;case g:return ie=w._init,V(ie(w._payload),k,q,Q,ae)}}if(ie)return ae=ae(w),ie=Q===""?"."+Ce(w,0):Q,Z(ae)?(q="",ie!=null&&(q=ie.replace(Ee,"$&/")+"/"),V(ae,k,q,"",function(Le){return Le})):ae!=null&&(Fe(ae)&&(ae=ve(ae,q+(ae.key==null||w&&w.key===ae.key?"":(""+ae.key).replace(Ee,"$&/")+"/")+ie)),k.push(ae)),1;ie=0;var be=Q===""?".":Q+":";if(Z(w))for(var we=0;we<w.length;we++)Q=w[we],ue=be+Ce(Q,we),ie+=V(Q,k,q,ue,ae);else if(we=E(w),typeof we=="function")for(w=we.call(w),we=0;!(Q=w.next()).done;)Q=Q.value,ue=be+Ce(Q,we++),ie+=V(Q,k,q,ue,ae);else if(ue==="object"){if(typeof w.then=="function")return V(Ne(w),k,q,Q,ae);throw k=String(w),Error("Objects are not valid as a React child (found: "+(k==="[object Object]"?"object with keys {"+Object.keys(w).join(", ")+"}":k)+"). If you meant to render a collection of children, use an array instead.")}return ie}function J(w,k,q){if(w==null)return w;var Q=[],ae=0;return V(w,Q,"","",function(ue){return k.call(q,ue,ae++)}),Q}function K(w){if(w._status===-1){var k=w._result;k=k(),k.then(function(q){(w._status===0||w._status===-1)&&(w._status=1,w._result=q)},function(q){(w._status===0||w._status===-1)&&(w._status=2,w._result=q)})Line truncated
|
||||
at`)?" (<anonymous>)":-1<s.stack.indexOf("@")?"@unknown:0:0":""}return`
|
||||
`+$e+t+ut}var yt=!1;function Vt(t,i){if(!t||yt)return"";yt=!0;var s=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var l={DetermineComponentFrameRoot:function(){try{if(i){var ee=function(){throw Error()};if(Object.defineProperty(ee.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(ee,[])}catch($){var H=$}Reflect.construct(t,[],ee)}else{try{ee.call()}catch($){H=$}t.call(ee.prototype)}}else{try{throw Error()}catch($){H=$}(ee=t())&&typeof ee.catch=="function"&&ee.catch(function(){})}}catch($){if($&&H&&typeof $.stack=="string")return[$.stack,H.stack]}return[null,null]}};l.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var f=Object.getOwnPropertyDescriptor(l.DetermineComponentFrameRoot,"name");f&&f.configurable&&Object.defineProperty(l.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var h=l.DetermineComponentFrameRoot(),b=h[0],S=h[1];if(b&&S){var R=b.split(`
|
||||
`),P=S.split(`
|
||||
`);for(f=l=0;l<R.length&&!R[l].includes("DetermineComponentFrameRoot");)l++;for(;f<P.length&&!P[f].includes("DetermineComponentFrameRoot");)f++;if(l===R.length||f===P.length)for(l=R.length-1,f=P.length-1;1<=l&&0<=f&&R[l]!==P[f];)f--;for(;1<=l&&0<=f;l--,f--)if(R[l]!==P[f]){if(l!==1||f!==1)do if(l--,f--,0>f||R[l]!==P[f]){var I=`
|
||||
`+R[l].replace(" at new "," at ");return t.displayName&&I.includes("<anonymous>")&&(I=I.replace("<anonymous>",t.displayName)),I}while(1<=l&&0<=f);break}}}finally{yt=!1,Error.prepareStackTrace=s}return(s=t?t.displayName||t.name:"")?ze(s):""}function en(t,i){switch(t.tag){case 26:case 27:case 5:return ze(t.type);case 16:return ze("Lazy");case 13:return t.child!==i&&i!==null?ze("Suspense Fallback"):ze("Suspense");case 19:return ze("SuspenseList");case 0:case 15:return Vt(t.type,!1);case 11:return Vt(t.type.render,!1);case 1:return Vt(t.type,!0);case 31:return ze("Activity");default:return""}}function un(t){try{var i="",s=null;do i+=en(t,s),s=t,t=t.return;while(t);return i}catch(l){return`
|
||||
Error generating stack: `+l.message+`
|
||||
`+l.stack}}var jn=Object.prototype.hasOwnProperty,Ht=e.unstable_scheduleCallback,N=e.unstable_cancelCallback,B=e.unstable_shouldYield,X=e.unstable_requestPaint,oe=e.unstable_now,le=e.unstable_getCurrentPriorityLevel,re=e.unstable_ImmediatePriority,xe=e.unstable_UserBlockingPriority,Be=e.unstable_NormalPriority,qe=e.unstable_LowPriority,vt=e.unstable_IdlePriority,xn=e.log,zn=e.unstable_setDisableYieldValue,pi=null,Zt=null;function Dn(t){if(typeof xn=="function"&&zn(t),Zt&&typeof Zt.setStrictMode=="function")try{Zt.setStrictMode(pi,t)}catch{}}var tn=Math.clz32?Math.clz32:Lo,ko=Math.log,tc=Math.LN2;function Lo(t){return t>>>=0,t===0?32:31-(ko(t)/tc|0)|0}var Vo=256,Uo=262144,Bo=4194304;function xr(t){var i=t&42;if(i!==0)return i;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&261888;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Po(t,i,s){var l=t.pendingLanes;if(l===0)return 0;var f=0,h=t.suspendedLanes,b=t.pingedLanes;t=t.warmLanes;var S=l&134217727;return S!==0?(l=S&~h,l!==0?f=xr(l):(b&=S,b!==0?f=xr(b):s||(s=S&~t,s!==0&&(f=xr(s))))):(S=l&~h,S!==0?f=xr(S):b!==0?f=xr(b):s||(s=l&~t,s!==0&&(f=xr(s)))),f===0?0:i!==0&&i!==f&&(i&h)===0&&(h=f&-f,s=i&-i,h>=s||h===32&&(s&4194048)!==0)?i:f}function rs(t,i){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&i)===0}function KS(t,i){switch(t){case 1:case 2:case 4:case 8:case 64:return i+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return i+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Hm(){var t=Bo;return Bo<<=1,(Bo&62914560)===0&&(Bo=4194304),t}function nc(t){for(var i=[],s=0;31>s;s++)i.push(t);return i}function as(t,i){t.pendingLanes|=i,i!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function IS(t,i,s,l,f,h){var b=t.pendingLanes;t.pendingLanes=s,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=s,t.entangledLanes&=s,t.errorRecoveryDisabledLanes&=s,t.shellSuspendCounter=0;var S=t.entanglements,R=t.expirationTimes,P=t.hiddenUpdates;for(s=b&~s;0<s;){var I=31-tn(s),ee=1<<I;S[I]=0,R[I]=-1;var H=P[I];if(H!==null)for(P[I]=null,I=0;I<H.length;I++){var $=H[I];$!==null&&($.lane&=-536870913)}s&=~ee}l!==0&&Zm(t,l,0),h!==0&&f===0&&t.tag!==0&&(t.suspendedLanes|=h&~(b&~i))}function Zm(t,i,s){t.pendingLanes|=i,t.suspendedLanes&=~i;var l=31-tn(i);t.entangledLanes|=i,t.entanglements[l]=t.entanglements[l]|1073741824|s&261930}function Fm(t,i){var s=t.entangledLanes|=i;for(t=t.entanglements;s;){var l=31-tn(s),f=1<<l;f&i|t[l]&i&&(t[l]|=i),s&=~f}}function qm(t,i){var s=i&-i;return s=(s&42)!==0?1:ic(s),(s&(t.suspendedLanes|i))!==0?0:s}function ic(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function rc(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function $m(){var t=J.p;return t!==0?t:(t=window.event,t===void 0?32:j0(t.type))}function Gm(t,i){var s=J.p;try{return J.p=t,i()}finally{J.p=s}}var Hi=Math.random().toString(36).slice(2),Ft="__reactFiber$"+Hi,cn="__reactProps$"+Hi,Qr="__reactContainer$"+Hi,ac="__reactEvents$"+Hi,QS="__reactListeners$"+Hi,JS="__reactHandles$"+Hi,Ym="__reactResources$"+Hi,ss="__reactMarker$"+Hi;function sc(t){delete t[Ft],delete t[cn],delete t[ac],delete t[QS],delete t[JS]}function Jr(t){var i=t[Ft];if(i)return i;for(var s=t.parentNode;s;){if(i=s[Qr]||s[Ft]){if(s=i.alternate,i.child!==null||s!==null&&s.child!==null)for(t=m0(t);t!==null;){if(s=t[Ft])return s;t=m0(t)}return i}t=s,s=t.parentNode}return null}function Wr(t){if(t=t[Ft]||t[Qr]){var i=t.tag;if(i===5||i===6||i===13||i===31||i===26||i===27||i===3)return t}return null}function os(t){var i=t.tag;if(i===5||i===26||i===27||i===6)return t.stateNode;throw Error(a(33))}function ea(t){var i=t[Ym];return i||(i=t[Ym]={hoistableStyles:new Map,hoistableScripts:new Map}),i}function Ut(t){t[ss]=!0}var Xm=new Set,Km={};function wr(t,i){ta(t,i),ta(t+"Capture",i)}function ta(t,i){for(Km[t]=i,t=0;t<i.length;t++)Xm.add(i[t])}var WS=RegExp("^[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218
Line truncated
|
||||
`).replace(j_,"")}function r0(t,i){return i=i0(i),i0(t)===i}function We(t,i,s,l,f,h){switch(s){case"children":typeof l=="string"?i==="body"||i==="textarea"&&l===""||ia(t,l):(typeof l=="number"||typeof l=="bigint")&&i!=="body"&&ia(t,""+l);break;case"className":Zo(t,"class",l);break;case"tabIndex":Zo(t,"tabindex",l);break;case"dir":case"role":case"viewBox":case"width":case"height":Zo(t,s,l);break;case"style":rp(t,l,h);break;case"data":if(i!=="object"){Zo(t,"data",l);break}case"src":case"href":if(l===""&&(i!=="a"||s!=="href")){t.removeAttribute(s);break}if(l==null||typeof l=="function"||typeof l=="symbol"||typeof l=="boolean"){t.removeAttribute(s);break}l=qo(""+l),t.setAttribute(s,l);break;case"action":case"formAction":if(typeof l=="function"){t.setAttribute(s,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof h=="function"&&(s==="formAction"?(i!=="input"&&We(t,i,"name",f.name,f,null),We(t,i,"formEncType",f.formEncType,f,null),We(t,i,"formMethod",f.formMethod,f,null),We(t,i,"formTarget",f.formTarget,f,null)):(We(t,i,"encType",f.encType,f,null),We(t,i,"method",f.method,f,null),We(t,i,"target",f.target,f,null)));if(l==null||typeof l=="symbol"||typeof l=="boolean"){t.removeAttribute(s);break}l=qo(""+l),t.setAttribute(s,l);break;case"onClick":l!=null&&(t.onclick=yi);break;case"onScroll":l!=null&&Ue("scroll",t);break;case"onScrollEnd":l!=null&&Ue("scrollend",t);break;case"dangerouslySetInnerHTML":if(l!=null){if(typeof l!="object"||!("__html"in l))throw Error(a(61));if(s=l.__html,s!=null){if(f.children!=null)throw Error(a(60));t.innerHTML=s}}break;case"multiple":t.multiple=l&&typeof l!="function"&&typeof l!="symbol";break;case"muted":t.muted=l&&typeof l!="function"&&typeof l!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(l==null||typeof l=="function"||typeof l=="boolean"||typeof l=="symbol"){t.removeAttribute("xlink:href");break}s=qo(""+l),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",s);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":l!=null&&typeof l!="function"&&typeof l!="symbol"?t.setAttribute(s,""+l):t.removeAttribute(s);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":l&&typeof l!="function"&&typeof l!="symbol"?t.setAttribute(s,""):t.removeAttribute(s);break;case"capture":case"download":l===!0?t.setAttribute(s,""):l!==!1&&l!=null&&typeof l!="function"&&typeof l!="symbol"?t.setAttribute(s,l):t.removeAttribute(s);break;case"cols":case"rows":case"size":case"span":l!=null&&typeof l!="function"&&typeof l!="symbol"&&!isNaN(l)&&1<=l?t.setAttribute(s,l):t.removeAttribute(s);break;case"rowSpan":case"start":l==null||typeof l=="function"||typeof l=="symbol"||isNaN(l)?t.removeAttribute(s):t.setAttribute(s,l);break;case"popover":Ue("beforetoggle",t),Ue("toggle",t),Ho(t,"popover",l);break;case"xlinkActuate":gi(t,"http://www.w3.org/1999/xlink","xlink:actuate",l);break;case"xlinkArcrole":gi(t,"http://www.w3.org/1999/xlink","xlink:arcrole",l);break;case"xlinkRole":gi(t,"http://www.w3.org/1999/xlink","xlink:role",l);break;case"xlinkShow":gi(t,"http://www.w3.org/1999/xlink","xlink:show",l);break;case"xlinkTitle":gi(t,"http://www.w3.org/1999/xlink","xlink:title",l);break;case"xlinkType":gi(t,"http://www.w3.org/1999/xlink","xlink:type",l);break;case"xmlBase":gi(t,"http://www.w3.org/XML/1998/namespace","xml:base",l);break;case"xmlLang":gi(t,"http://www.w3.org/XML/1998/namespace","xml:lang",l);break;case"xmlSpace":gi(t,"http://www.w3.org/XML/1998/namespace","xml:space",l);break;case"is":Ho(t,"is",l);break;case"innerText":case"textContent":break;default:(!(2<s.length)||s[0]!=="o"&&s[0]!=="O"||s[1]!=="n"&&s[1]!=="N")&&(s=rE.get(s)||s,Ho(t,s,l))}}function Qf(t,i,s,l,f,h){switch(s){case"style":rp(t,l,h);break;case"dangerouslySetInnerHTML":if(l!=null){if(typeof l!="object"||!("__html"in l))throw Error(a(61));if(s=l.__html,s!=null){if(f.children!=null)throw Error(a(60));t.innerHTML=s}}break;case"children":typeof l=="string"?ia(t,l):(typeof l=="number"||typeof l=="bigint")&&ia(t,""+l);break;case"onScroll":l!=null&&Ue("scroll",t);break;case"onScrollEnd":l!=null&&Ue("scrollend",t);break;case"onClick":l!=null&&(t.onclick=yi);break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"inne
Line truncated
|
||||
|
||||
Please change the parent <Route path="${z}"> to <Route path="${z==="/"?"*":`${z}/*`}">.`)}let x=Pi(),E;if(n){let z=typeof n=="string"?Xa(n):n;ot(g==="/"||z.pathname?.startsWith(g),`When overriding the location using \`<Routes location>\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${g}" but pathname "${z.pathname}" was given in the \`location\` prop.`),E=z}else E=x;let T=E.pathname||"/",A=T;if(g!=="/"){let z=g.replace(/^\//,"").split("/");A="/"+T.replace(/^\//,"").split("/").slice(z.length).join("/")}let _=Dx(e,{pathname:A});ti(y||_!=null,`No routes matched location "${E.pathname}${E.search}${E.hash}" `),ti(_==null||_[_.length-1].route.element!==void 0||_[_.length-1].route.Component!==void 0||_[_.length-1].route.lazy!==void 0,`Matched leaf route at location "${E.pathname}${E.search}${E.hash}" does not have an element or Component. This means it will render an <Outlet /> with a null value by default resulting in an "empty" page.`);let M=aT(_&&_.map(z=>Object.assign({},z,{params:Object.assign({},m,z.params),pathname:Li([g,u.encodeLocation?u.encodeLocation(z.pathname.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:z.pathname]),pathnameBase:z.pathnameBase==="/"?g:Li([g,u.encodeLocation?u.encodeLocation(z.pathnameBase.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:z.pathnameBase])})),c,r,a,o);return n&&M?C.createElement(Eo.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",...E},navigationType:"POP"}},M):M}function tT(){let e=cT(),n=Z2(e)?`${e.status} ${e.statusText}`:e instanceof Error?e.message:JSON.stringify(e),r=e instanceof Error?e.stack:null,a="rgba(200,200,200, 0.5)",o={padding:"0.5rem",backgroundColor:a},u={padding:"2px 4px",backgroundColor:a},c=null;return console.error("Error handled by React Router default ErrorBoundary:",e),c=C.createElement(C.Fragment,null,C.createElement("p",null,"💿 Hey developer 👋"),C.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",C.createElement("code",{style:u},"ErrorBoundary")," or"," ",C.createElement("code",{style:u},"errorElement")," prop on your route.")),C.createElement(C.Fragment,null,C.createElement("h2",null,"Unexpected Application Error!"),C.createElement("h3",{style:{fontStyle:"italic"}},n),r?C.createElement("pre",{style:o},r):null,c)}var nT=C.createElement(tT,null),Fx=class extends C.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,n){return n.location!==e.location||n.revalidation!=="idle"&&e.revalidation==="idle"?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:e.error!==void 0?e.error:n.error,location:n.location,revalidation:e.revalidation||n.revalidation}}componentDidCatch(e,n){this.props.onError?this.props.onError(e,n):console.error("React Router caught the following error during render",e)}render(){let e=this.state.error;if(this.context&&typeof e=="object"&&e&&"digest"in e&&typeof e.digest=="string"){const r=Q2(e.digest);r&&(e=r)}let n=e!==void 0?C.createElement(di.Provider,{value:this.props.routeContext},C.createElement(Ph.Provider,{value:e,children:this.props.component})):this.props.children;return this.context?C.createElement(iT,{error:e},n):n}};Fx.contextType=$2;var Sd=new WeakMap;function iT({children:e,error:n}){let{basename:r}=C.useContext(Nn);if(typeof n=="object"&&n&&"digest"in n&&typeof n.digest=="string"){let a=I2(n.digest);if(a){let o=Sd.get(n);if(o)throw o;let u=Lx(a.location,r);if(kx&&!Sd.get(n))if(u.isExternal||a.reloadDocument)window.location.href=u.absoluteURL||u.to;else{const c=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(u.to,{replace:a.replace}));throw Sd.set(n,c),c}return C.createElement("meta",{httpEquiv:"refresh",content:`0;url=${u.absoluteURL||u.to}`})}}return e}function rT({routeContext:e,match:n,children:r}){let a=C.useContext(Ka);return a&&a.static&&a.staticContext&&(n.route.errorElement||n.route.ErrorBoundary)&&(a.staticContext._deepestRenderedBoundaryId=n.route.id),C.createElement(di.Provider,{value:e},r)}function aT(e,n=[],r=null,a=null,o=null){if(e==null){if(!r)return null;if(r.errors)e=r.matches;else if(n.length===0&&!r.initialized&&r.matches.length>0)e=r.matches;else return null}let u=e,c=r?.errors;if(c!=null){let g=u.findIndex(y=>y.route.id&&c?.[y.route.id]!==void 0);ot(g>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(c).join(",")}`),u=u.slice(0,Math.min(u.length,g+1))}let d=!1,m=-1;if(r)for(let g=0;g<u.length;g++){let y=u[g];if((y.route.HydrateFallback||y.route.hydrateFallbackElement)&&(m=g),y.route.id){let{loaderData:x,errors:E}=r,T=y.route.loader&&!x.hasOwnProperty(y.route.id)&&(!E||E[y.route.id]===void 0);if(y.route.lazy||T){d=!0,m>=0?u=u.slice(0,m+1):u=[u[0]];break}}}Line truncated
|
||||
`).forEach(function(c){o=c.indexOf(":"),r=c.substring(0,o).trim().toLowerCase(),a=c.substring(o+1).trim(),!(!r||n[r]&&JA[r])&&(r==="set-cookie"?n[r]?n[r].push(a):n[r]=[a]:n[r]=n[r]?n[r]+", "+a:a)}),n},av=Symbol("internals");function Js(e){return e&&String(e).trim().toLowerCase()}function hu(e){return e===!1||e==null?e:G.isArray(e)?e.map(hu):String(e)}function eC(e){const n=Object.create(null),r=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g;let a;for(;a=r.exec(e);)n[a[1]]=a[2];return n}const tC=e=>/^[-_a-zA-Z0-9^`|~,!#$%&'*+.]+$/.test(e.trim());function _d(e,n,r,a,o){if(G.isFunction(a))return a.call(this,n,r);if(o&&(n=r),!!G.isString(n)){if(G.isString(a))return n.indexOf(a)!==-1;if(G.isRegExp(a))return a.test(n)}}function nC(e){return e.trim().toLowerCase().replace(/([a-z\d])(\w*)/g,(n,r,a)=>r.toUpperCase()+a)}function iC(e,n){const r=G.toCamelCase(" "+n);["get","set","has"].forEach(a=>{Object.defineProperty(e,a+r,{value:function(o,u,c){return this[a].call(this,n,o,u,c)},configurable:!0})})}let bn=class{constructor(n){n&&this.set(n)}set(n,r,a){const o=this;function u(d,m,p){const g=Js(m);if(!g)throw new Error("header name must be a non-empty string");const y=G.findKey(o,g);(!y||o[y]===void 0||p===!0||p===void 0&&o[y]!==!1)&&(o[y||m]=hu(d))}const c=(d,m)=>G.forEach(d,(p,g)=>u(p,g,m));if(G.isPlainObject(n)||n instanceof this.constructor)c(n,r);else if(G.isString(n)&&(n=n.trim())&&!tC(n))c(WA(n),r);else if(G.isObject(n)&&G.isIterable(n)){let d={},m,p;for(const g of n){if(!G.isArray(g))throw TypeError("Object iterator must return a key-value pair");d[p=g[0]]=(m=d[p])?G.isArray(m)?[...m,g[1]]:[m,g[1]]:g[1]}c(d,r)}else n!=null&&u(r,n,a);return this}get(n,r){if(n=Js(n),n){const a=G.findKey(this,n);if(a){const o=this[a];if(!r)return o;if(r===!0)return eC(o);if(G.isFunction(r))return r.call(this,o,a);if(G.isRegExp(r))return r.exec(o);throw new TypeError("parser must be boolean|regexp|function")}}}has(n,r){if(n=Js(n),n){const a=G.findKey(this,n);return!!(a&&this[a]!==void 0&&(!r||_d(this,this[a],a,r)))}return!1}delete(n,r){const a=this;let o=!1;function u(c){if(c=Js(c),c){const d=G.findKey(a,c);d&&(!r||_d(a,a[d],d,r))&&(delete a[d],o=!0)}}return G.isArray(n)?n.forEach(u):u(n),o}clear(n){const r=Object.keys(this);let a=r.length,o=!1;for(;a--;){const u=r[a];(!n||_d(this,this[u],u,n,!0))&&(delete this[u],o=!0)}return o}normalize(n){const r=this,a={};return G.forEach(this,(o,u)=>{const c=G.findKey(a,u);if(c){r[c]=hu(o),delete r[u];return}const d=n?nC(u):String(u).trim();d!==u&&delete r[u],r[d]=hu(o),a[d]=!0}),this}concat(...n){return this.constructor.concat(this,...n)}toJSON(n){const r=Object.create(null);return G.forEach(this,(a,o)=>{a!=null&&a!==!1&&(r[o]=n&&G.isArray(a)?a.join(", "):a)}),r}[Symbol.iterator](){return Object.entries(this.toJSON())[Symbol.iterator]()}toString(){return Object.entries(this.toJSON()).map(([n,r])=>n+": "+r).join(`
|
||||
`)}getSetCookie(){return this.get("set-cookie")||[]}get[Symbol.toStringTag](){return"AxiosHeaders"}static from(n){return n instanceof this?n:new this(n)}static concat(n,...r){const a=new this(n);return r.forEach(o=>a.set(o)),a}static accessor(n){const a=(this[av]=this[av]={accessors:{}}).accessors,o=this.prototype;function u(c){const d=Js(c);a[d]||(iC(o,c),a[d]=!0)}return G.isArray(n)?n.forEach(u):u(n),this}};bn.accessor(["Content-Type","Content-Length","Accept","Accept-Encoding","User-Agent","Authorization"]);G.reduceDescriptors(bn.prototype,({value:e},n)=>{let r=n[0].toUpperCase()+n.slice(1);return{get:()=>e,set(a){this[r]=a}}});G.freezeMethods(bn);function Td(e,n){const r=this||Ro,a=n||r,o=bn.from(a.headers);let u=a.data;return G.forEach(e,function(d){u=d.call(r,u,o.normalize(),n?n.status:void 0)}),o.normalize(),u}function c1(e){return!!(e&&e.__CANCEL__)}let No=class extends Ae{constructor(n,r,a){super(n??"canceled",Ae.ERR_CANCELED,r,a),this.name="CanceledError",this.__CANCEL__=!0}};function f1(e,n,r){const a=r.config.validateStatus;!r.status||!a||a(r.status)?e(r):n(new Ae("Request failed with status code "+r.status,[Ae.ERR_BAD_REQUEST,Ae.ERR_BAD_RESPONSE][Math.floor(r.status/100)-4],r.config,r.request,r))}function rC(e){const n=/^([-+\w]{1,25})(:?\/\/|:)/.exec(e);return n&&n[1]||""}function aC(e,n){e=e||10;const r=new Array(e),a=new Array(e);let o=0,u=0,c;return n=n!==void 0?n:1e3,function(m){const p=Date.now(),g=a[u];c||(c=p),r[o]=m,a[o]=p;let y=u,x=0;for(;y!==o;)x+=r[y++],y=y%e;if(o=(o+1)%e,o===u&&(u=(u+1)%e),p-c<n)return;const E=g&&p-g;return E?Math.round(x*1e3/E):void 0}}function sC(e,n){let r=0,a=1e3/n,o,u;const c=(p,g=Date.now())=>{r=g,o=null,u&&(clearTimeout(u),u=null),e(...p)};return[(...p)=>{const g=Date.now(),y=g-r;y>=a?c(p,g):(o=p,u||(u=setTimeout(()=>{u=null,c(o)},a-y)))},()=>o&&c(o)]}const wu=(e,n,r=3)=>{let a=0;const o=aC(50,250);return sC(u=>{const c=u.loaded,d=u.lengthComputable?u.total:void 0,m=c-a,p=o(m),g=c<=d;a=c;const y={loaded:c,total:d,progress:d?c/d:void 0,bytes:m,rate:p||void 0,estimated:p&&d&&g?(d-c)/p:void 0,event:u,lengthComputable:d!=null,[n?"download":"upload"]:!0};e(y)},r)},sv=(e,n)=>{const r=e!=null;return[a=>n[0]({lengthComputable:r,total:e,loaded:a}),n[1]]},ov=e=>(...n)=>G.asap(()=>e(...n)),oC=Qt.hasStandardBrowserEnv?((e,n)=>r=>(r=new URL(r,Qt.origin),e.protocol===r.protocol&&e.host===r.host&&(n||e.port===r.port)))(new URL(Qt.origin),Qt.navigator&&/(msie|trident)/i.test(Qt.navigator.userAgent)):()=>!0,lC=Qt.hasStandardBrowserEnv?{write(e,n,r,a,o,u,c){if(typeof document>"u")return;const d=[`${e}=${encodeURIComponent(n)}`];G.isNumber(r)&&d.push(`expires=${new Date(r).toUTCString()}`),G.isString(a)&&d.push(`path=${a}`),G.isString(o)&&d.push(`domain=${o}`),u===!0&&d.push("secure"),G.isString(c)&&d.push(`SameSite=${c}`),document.cookie=d.join("; ")},read(e){if(typeof document>"u")return null;const n=document.cookie.match(new RegExp("(?:^|; )"+e+"=([^;]*)"));return n?decodeURIComponent(n[1]):null},remove(e){this.write(e,"",Date.now()-864e5,"/")}}:{write(){},read(){return null},remove(){}};function uC(e){return/^([a-z][a-z\d+\-.]*:)?\/\//i.test(e)}function cC(e,n){return n?e.replace(/\/?\/$/,"")+"/"+n.replace(/^\/+/,""):e}function d1(e,n,r){let a=!uC(n);return e&&(a||r==!1)?cC(e,n):n}const lv=e=>e instanceof bn?{...e}:e;function Yr(e,n){n=n||{};const r={};function a(p,g,y,x){return G.isPlainObject(p)&&G.isPlainObject(g)?G.merge.call({caseless:x},p,g):G.isPlainObject(g)?G.merge({},g):G.isArray(g)?g.slice():g}function o(p,g,y,x){if(G.isUndefined(g)){if(!G.isUndefined(p))return a(void 0,p,y,x)}else return a(p,g,y,x)}function u(p,g){if(!G.isUndefined(g))return a(void 0,g)}function c(p,g){if(G.isUndefined(g)){if(!G.isUndefined(p))return a(void 0,p)}else return a(void 0,g)}function d(p,g,y){if(y in n)return a(p,g);if(y in e)return a(void 0,p)}const m={url:u,method:u,data:u,baseURL:c,transformRequest:c,transformResponse:c,paramsSerializer:c,timeout:c,timeoutMessage:c,withCredentials:c,withXSRFToken:c,adapter:c,responseType:c,xsrfCookieName:c,xsrfHeaderName:c,onUploadProgress:c,onDownloadProgress:c,decompress:c,maxContentLength:c,maxBodyLength:c,beforeRedirect:c,transport:c,httpAgent:c,httpsAgent:c,cancelToken:c,socketPath:c,responseEncoding:c,validateStatus:d,headers:(p,g,y)=>o(lv(p),lv(g),y,!0)};return G.forEach(Object.keys({...e,...n}),function(g){const y=m[g]||o,x=y(e[g],n[g],g);G.isUndefined(x)&&y!==d||(r[g]=x)}),r}const h1=e=>{const n=Yr({},e);let{data:r,withXSRFToken:a,xsrfHeaderName:o,xsrfCookieName:u,headers:c,auth:d}=n;if(n.headers=c=bn.from(c),n.url=o1(d1(n.baseURL,n.url,n.allowAbsoluteUrls),e.params,e.paramsSerializer),d&&c.set("Authorization","Basic "+btoa((d.username||"")+":"+(d.password?unescape(encodeURIComponent(d.password)):""))),G.isFormData(r)){if(Qt.hasStandardBrowserEnv||Qt.hasStandardBrowserWebWorkerEnv)c.setContentType(void 0);else if(G.isFunction(r.getHeaders)){const m=r.getHeaders(),p=["content-type","content-length"];Object.entries(m).forEach(([g,y])=>{p.i
Line truncated
|
||||
`+c.map(mv).join(`
|
||||
`):" "+mv(c[0]):"as no adapter specified";throw new Ae("There is no suitable adapter to dispatch the request "+d,"ERR_NOT_SUPPORT")}return o}const p1={getAdapter:wC,adapters:Xh};function Ad(e){if(e.cancelToken&&e.cancelToken.throwIfRequested(),e.signal&&e.signal.aborted)throw new No(null,e)}function pv(e){return Ad(e),e.headers=bn.from(e.headers),e.data=Td.call(e,e.transformRequest),["post","put","patch"].indexOf(e.method)!==-1&&e.headers.setContentType("application/x-www-form-urlencoded",!1),p1.getAdapter(e.adapter||Ro.adapter,e)(e).then(function(a){return Ad(e),a.data=Td.call(e,e.transformResponse,a),a.headers=bn.from(a.headers),a},function(a){return c1(a)||(Ad(e),a&&a.response&&(a.response.data=Td.call(e,e.transformResponse,a.response),a.response.headers=bn.from(a.response.headers))),Promise.reject(a)})}const g1="1.13.4",Zu={};["object","boolean","number","function","string","symbol"].forEach((e,n)=>{Zu[e]=function(a){return typeof a===e||"a"+(n<1?"n ":" ")+e}});const gv={};Zu.transitional=function(n,r,a){function o(u,c){return"[Axios v"+g1+"] Transitional option '"+u+"'"+c+(a?". "+a:"")}return(u,c,d)=>{if(n===!1)throw new Ae(o(c," has been removed"+(r?" in "+r:"")),Ae.ERR_DEPRECATED);return r&&!gv[c]&&(gv[c]=!0,console.warn(o(c," has been deprecated since v"+r+" and will be removed in the near future"))),n?n(u,c,d):!0}};Zu.spelling=function(n){return(r,a)=>(console.warn(`${a} is likely a misspelling of ${n}`),!0)};function SC(e,n,r){if(typeof e!="object")throw new Ae("options must be an object",Ae.ERR_BAD_OPTION_VALUE);const a=Object.keys(e);let o=a.length;for(;o-- >0;){const u=a[o],c=n[u];if(c){const d=e[u],m=d===void 0||c(d,u,e);if(m!==!0)throw new Ae("option "+u+" must be "+m,Ae.ERR_BAD_OPTION_VALUE);continue}if(r!==!0)throw new Ae("Unknown option "+u,Ae.ERR_BAD_OPTION)}}const mu={assertOptions:SC,validators:Zu},oi=mu.validators;let $r=class{constructor(n){this.defaults=n||{},this.interceptors={request:new rv,response:new rv}}async request(n,r){try{return await this._request(n,r)}catch(a){if(a instanceof Error){let o={};Error.captureStackTrace?Error.captureStackTrace(o):o=new Error;const u=o.stack?o.stack.replace(/^.+\n/,""):"";try{a.stack?u&&!String(a.stack).endsWith(u.replace(/^.+\n.+\n/,""))&&(a.stack+=`
|
||||
`+u):a.stack=u}catch{}}throw a}}_request(n,r){typeof n=="string"?(r=r||{},r.url=n):r=n||{},r=Yr(this.defaults,r);const{transitional:a,paramsSerializer:o,headers:u}=r;a!==void 0&&mu.assertOptions(a,{silentJSONParsing:oi.transitional(oi.boolean),forcedJSONParsing:oi.transitional(oi.boolean),clarifyTimeoutError:oi.transitional(oi.boolean)},!1),o!=null&&(G.isFunction(o)?r.paramsSerializer={serialize:o}:mu.assertOptions(o,{encode:oi.function,serialize:oi.function},!0)),r.allowAbsoluteUrls!==void 0||(this.defaults.allowAbsoluteUrls!==void 0?r.allowAbsoluteUrls=this.defaults.allowAbsoluteUrls:r.allowAbsoluteUrls=!0),mu.assertOptions(r,{baseUrl:oi.spelling("baseURL"),withXsrfToken:oi.spelling("withXSRFToken")},!0),r.method=(r.method||this.defaults.method||"get").toLowerCase();let c=u&&G.merge(u.common,u[r.method]);u&&G.forEach(["delete","get","head","post","put","patch","common"],T=>{delete u[T]}),r.headers=bn.concat(c,u);const d=[];let m=!0;this.interceptors.request.forEach(function(A){typeof A.runWhen=="function"&&A.runWhen(r)===!1||(m=m&&A.synchronous,d.unshift(A.fulfilled,A.rejected))});const p=[];this.interceptors.response.forEach(function(A){p.push(A.fulfilled,A.rejected)});let g,y=0,x;if(!m){const T=[pv.bind(this),void 0];for(T.unshift(...d),T.push(...p),x=T.length,g=Promise.resolve(r);y<x;)g=g.then(T[y++],T[y++]);return g}x=d.length;let E=r;for(;y<x;){const T=d[y++],A=d[y++];try{E=T(E)}catch(_){A.call(this,_);break}}try{g=pv.call(this,E)}catch(T){return Promise.reject(T)}for(y=0,x=p.length;y<x;)g=g.then(p[y++],p[y++]);return g}getUri(n){n=Yr(this.defaults,n);const r=d1(n.baseURL,n.url,n.allowAbsoluteUrls);return o1(r,n.params,n.paramsSerializer)}};G.forEach(["delete","get","head","options"],function(n){$r.prototype[n]=function(r,a){return this.request(Yr(a||{},{method:n,url:r,data:(a||{}).data}))}});G.forEach(["post","put","patch"],function(n){function r(a){return function(u,c,d){return this.request(Yr(d||{},{method:n,headers:a?{"Content-Type":"multipart/form-data"}:{},url:u,data:c}))}}$r.prototype[n]=r(),$r.prototype[n+"Form"]=r(!0)});let EC=class y1{constructor(n){if(typeof n!="function")throw new TypeError("executor must be a function.");let r;this.promise=new Promise(function(u){r=u});const a=this;this.promise.then(o=>{if(!a._listeners)return;let u=a._listeners.length;for(;u-- >0;)a._listeners[u](o);a._listeners=null}),this.promise.then=o=>{let u;const c=new Promise(d=>{a.subscribe(d),u=d}).then(o);return c.cancel=function(){a.unsubscribe(u)},c},n(function(u,c,d){a.reason||(a.reason=new No(u,c,d),r(a.reason))})}throwIfRequested(){if(this.reason)throw this.reason}subscribe(n){if(this.reason){n(this.reason);return}this._listeners?this._listeners.push(n):this._listeners=[n]}unsubscribe(n){if(!this._listeners)return;const r=this._listeners.indexOf(n);r!==-1&&this._listeners.splice(r,1)}toAbortSignal(){const n=new AbortController,r=a=>{n.abort(a)};return this.subscribe(r),n.signal.unsubscribe=()=>this.unsubscribe(r),n.signal}static source(){let n;return{token:new y1(function(o){n=o}),cancel:n}}};function _C(e){return function(r){return e.apply(null,r)}}function TC(e){return G.isObject(e)&&e.isAxiosError===!0}const ih={Continue:100,SwitchingProtocols:101,Processing:102,EarlyHints:103,Ok:200,Created:201,Accepted:202,NonAuthoritativeInformation:203,NoContent:204,ResetContent:205,PartialContent:206,MultiStatus:207,AlreadyReported:208,ImUsed:226,MultipleChoices:300,MovedPermanently:301,Found:302,SeeOther:303,NotModified:304,UseProxy:305,Unused:306,TemporaryRedirect:307,PermanentRedirect:308,BadRequest:400,Unauthorized:401,PaymentRequired:402,Forbidden:403,NotFound:404,MethodNotAllowed:405,NotAcceptable:406,ProxyAuthenticationRequired:407,RequestTimeout:408,Conflict:409,Gone:410,LengthRequired:411,PreconditionFailed:412,PayloadTooLarge:413,UriTooLong:414,UnsupportedMediaType:415,RangeNotSatisfiable:416,ExpectationFailed:417,ImATeapot:418,MisdirectedRequest:421,UnprocessableEntity:422,Locked:423,FailedDependency:424,TooEarly:425,UpgradeRequired:426,PreconditionRequired:428,TooManyRequests:429,RequestHeaderFieldsTooLarge:431,UnavailableForLegalReasons:451,InternalServerError:500,NotImplemented:501,BadGateway:502,ServiceUnavailable:503,GatewayTimeout:504,HttpVersionNotSupported:505,VariantAlsoNegotiates:506,InsufficientStorage:507,LoopDetected:508,NotExtended:510,NetworkAuthenticationRequired:511,WebServerIsDown:521,ConnectionTimedOut:522,OriginIsUnreachable:523,TimeoutOccurred:524,SslHandshakeFailed:525,InvalidSslCertificate:526};Object.entries(ih).forEach(([e,n])=>{ih[n]=e});function v1(e){const n=new $r(e),r=Ix($r.prototype.request,n);return G.extend(r,$r.prototype,n,{allOwnKeys:!0}),G.extend(r,n,null,{allOwnKeys:!0}),r.create=function(o){return v1(Yr(e,o))},r}const wt=v1(Ro);wt.Axios=$r;wt.CanceledError=No;wt.CancelToken=EC;wt.isCancel=c1;wt.VERSION=g1;wt.toFormData=Hu;wt.AxiosError=Ae;wt.Cancel=wt.CanceledError;wt.all=function(n){return Promise.all(n)};wt.spread=_C;wt.isAxiosError=TC;wt.mergeConfig=Yr;wt.AxiosH
Line truncated
|
||||
`).filter(c=>c),o=Math.min(...a.map(c=>c.length-c.trimStart().length)),u=a.map(c=>c.slice(o)).map(c=>" ".repeat(this.indent*2)+c);for(const c of u)this.content.push(c)}compile(){const n=Function,r=this?.args,o=[...(this?.content??[""]).map(u=>` ${u}`)];return new n(...r,o.join(`
|
||||
`))}}const tN={major:4,minor:3,patch:6},kt=te("$ZodType",(e,n)=>{var r;e??(e={}),e._zod.def=n,e._zod.bag=e._zod.bag||{},e._zod.version=tN;const a=[...e._zod.def.checks??[]];e._zod.traits.has("$ZodCheck")&&a.unshift(e);for(const o of a)for(const u of o._zod.onattach)u(e);if(a.length===0)(r=e._zod).deferred??(r.deferred=[]),e._zod.deferred?.push(()=>{e._zod.run=e._zod.parse});else{const o=(c,d,m)=>{let p=Ua(c),g;for(const y of d){if(y._zod.def.when){if(!y._zod.def.when(c))continue}else if(p)continue;const x=c.issues.length,E=y._zod.check(c);if(E instanceof Promise&&m?.async===!1)throw new Fa;if(g||E instanceof Promise)g=(g??Promise.resolve()).then(async()=>{await E,c.issues.length!==x&&(p||(p=Ua(c,x)))});else{if(c.issues.length===x)continue;p||(p=Ua(c,x))}}return g?g.then(()=>c):c},u=(c,d,m)=>{if(Ua(c))return c.aborted=!0,c;const p=o(d,a,m);if(p instanceof Promise){if(m.async===!1)throw new Fa;return p.then(g=>e._zod.parse(g,m))}return e._zod.parse(p,m)};e._zod.run=(c,d)=>{if(d.skipChecks)return e._zod.parse(c,d);if(d.direction==="backward"){const p=e._zod.parse({value:c.value,issues:[]},{...d,skipChecks:!0});return p instanceof Promise?p.then(g=>u(g,c,d)):u(p,c,d)}const m=e._zod.parse(c,d);if(m instanceof Promise){if(d.async===!1)throw new Fa;return m.then(p=>o(p,a,d))}return o(m,a,d)}}Qe(e,"~standard",()=>({validate:o=>{try{const u=fR(e,o);return u.success?{value:u.data}:{issues:u.error?.issues}}catch{return dR(e,o).then(c=>c.success?{value:c.data}:{issues:c.error?.issues})}},vendor:"zod",version:1}))}),om=te("$ZodString",(e,n)=>{kt.init(e,n),e._zod.pattern=[...e?._zod.bag?.patterns??[]].pop()??HR(e._zod.bag),e._zod.parse=(r,a)=>{if(n.coerce)try{r.value=String(r.value)}catch{}return typeof r.value=="string"||r.issues.push({expected:"string",code:"invalid_type",input:r.value,inst:e}),r}}),lt=te("$ZodStringFormat",(e,n)=>{Iu.init(e,n),om.init(e,n)}),nN=te("$ZodGUID",(e,n)=>{n.pattern??(n.pattern=RR),lt.init(e,n)}),iN=te("$ZodUUID",(e,n)=>{if(n.version){const a={v1:1,v2:2,v3:3,v4:4,v5:5,v6:6,v7:7,v8:8}[n.version];if(a===void 0)throw new Error(`Invalid UUID version: "${n.version}"`);n.pattern??(n.pattern=Ov(a))}else n.pattern??(n.pattern=Ov());lt.init(e,n)}),rN=te("$ZodEmail",(e,n)=>{n.pattern??(n.pattern=NR),lt.init(e,n)}),aN=te("$ZodURL",(e,n)=>{lt.init(e,n),e._zod.check=r=>{try{const a=r.value.trim(),o=new URL(a);n.hostname&&(n.hostname.lastIndex=0,n.hostname.test(o.hostname)||r.issues.push({code:"invalid_format",format:"url",note:"Invalid hostname",pattern:n.hostname.source,input:r.value,inst:e,continue:!n.abort})),n.protocol&&(n.protocol.lastIndex=0,n.protocol.test(o.protocol.endsWith(":")?o.protocol.slice(0,-1):o.protocol)||r.issues.push({code:"invalid_format",format:"url",note:"Invalid protocol",pattern:n.protocol.source,input:r.value,inst:e,continue:!n.abort})),n.normalize?r.value=o.href:r.value=a;return}catch{r.issues.push({code:"invalid_format",format:"url",input:r.value,inst:e,continue:!n.abort})}}}),sN=te("$ZodEmoji",(e,n)=>{n.pattern??(n.pattern=zR()),lt.init(e,n)}),oN=te("$ZodNanoID",(e,n)=>{n.pattern??(n.pattern=AR),lt.init(e,n)}),lN=te("$ZodCUID",(e,n)=>{n.pattern??(n.pattern=wR),lt.init(e,n)}),uN=te("$ZodCUID2",(e,n)=>{n.pattern??(n.pattern=SR),lt.init(e,n)}),cN=te("$ZodULID",(e,n)=>{n.pattern??(n.pattern=ER),lt.init(e,n)}),fN=te("$ZodXID",(e,n)=>{n.pattern??(n.pattern=_R),lt.init(e,n)}),dN=te("$ZodKSUID",(e,n)=>{n.pattern??(n.pattern=TR),lt.init(e,n)}),hN=te("$ZodISODateTime",(e,n)=>{n.pattern??(n.pattern=PR(n)),lt.init(e,n)}),mN=te("$ZodISODate",(e,n)=>{n.pattern??(n.pattern=UR),lt.init(e,n)}),pN=te("$ZodISOTime",(e,n)=>{n.pattern??(n.pattern=BR(n)),lt.init(e,n)}),gN=te("$ZodISODuration",(e,n)=>{n.pattern??(n.pattern=CR),lt.init(e,n)}),yN=te("$ZodIPv4",(e,n)=>{n.pattern??(n.pattern=DR),lt.init(e,n),e._zod.bag.format="ipv4"}),vN=te("$ZodIPv6",(e,n)=>{n.pattern??(n.pattern=MR),lt.init(e,n),e._zod.bag.format="ipv6",e._zod.check=r=>{try{new URL(`http://[${r.value}]`)}catch{r.issues.push({code:"invalid_format",format:"ipv6",input:r.value,inst:e,continue:!n.abort})}}}),bN=te("$ZodCIDRv4",(e,n)=>{n.pattern??(n.pattern=OR),lt.init(e,n)}),xN=te("$ZodCIDRv6",(e,n)=>{n.pattern??(n.pattern=kR),lt.init(e,n),e._zod.check=r=>{const a=r.value.split("/");try{if(a.length!==2)throw new Error;const[o,u]=a;if(!u)throw new Error;const c=Number(u);if(`${c}`!==u)throw new Error;if(c<0||c>128)throw new Error;new URL(`http://[${o}]`)}catch{r.issues.push({code:"invalid_format",format:"cidrv6",input:r.value,inst:e,continue:!n.abort})}}});function L1(e){if(e==="")return!0;if(e.length%4!==0)return!1;try{return atob(e),!0}catch{return!1}}const wN=te("$ZodBase64",(e,n)=>{n.pattern??(n.pattern=LR),lt.init(e,n),e._zod.bag.contentEncoding="base64",e._zod.check=r=>{L1(r.value)||r.issues.push({code:"invalid_format",format:"base64",input:r.value,inst:e,continue:!n.abort})}});function SN(e){if(!M1.test(e))return!1;const n=e.replace(/[-_]/g,a=>a==="-"?"+":"/"),r=n.padEnd(Math.ceil(n.length/4)*4,"=");return L1(r)}const EN=te("$ZodB
Line truncated
|
||||
if (${F}.issues.length) {
|
||||
if (${Z} in input) {
|
||||
payload.issues = payload.issues.concat(${F}.issues.map(iss => ({
|
||||
...iss,
|
||||
path: iss.path ? [${Z}, ...iss.path] : [${Z}]
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
if (${F}.value === undefined) {
|
||||
if (${Z} in input) {
|
||||
newResult[${Z}] = undefined;
|
||||
}
|
||||
} else {
|
||||
newResult[${Z}] = ${F}.value;
|
||||
}
|
||||
|
||||
`):E.write(`
|
||||
if (${F}.issues.length) {
|
||||
payload.issues = payload.issues.concat(${F}.issues.map(iss => ({
|
||||
...iss,
|
||||
path: iss.path ? [${Z}, ...iss.path] : [${Z}]
|
||||
})));
|
||||
}
|
||||
|
||||
if (${F}.value === undefined) {
|
||||
if (${Z} in input) {
|
||||
newResult[${Z}] = undefined;
|
||||
}
|
||||
} else {
|
||||
newResult[${Z}] = ${F}.value;
|
||||
}
|
||||
|
||||
`)}E.write("payload.value = newResult;"),E.write("return payload;");const z=E.compile();return(D,F)=>z(x,D,F)};let u;const c=_u,d=!C1.jitless,p=d&&QC.value,g=n.catchall;let y;e._zod.parse=(x,E)=>{y??(y=a.value);const T=x.value;return c(T)?d&&p&&E?.async===!1&&E.jitless!==!0?(u||(u=o(n.shape)),x=u(x,E),g?U1([],T,x,E,y,e):x):r(x,E):(x.issues.push({expected:"object",code:"invalid_type",input:T,inst:e}),x)}});function Lv(e,n,r,a){for(const u of e)if(u.issues.length===0)return n.value=u.value,n;const o=e.filter(u=>!Ua(u));return o.length===1?(n.value=o[0].value,o[0]):(n.issues.push({code:"invalid_union",input:n.value,inst:r,errors:e.map(u=>u.issues.map(c=>Kr(c,a,Xr())))}),n)}const DN=te("$ZodUnion",(e,n)=>{kt.init(e,n),Qe(e._zod,"optin",()=>n.options.some(o=>o._zod.optin==="optional")?"optional":void 0),Qe(e._zod,"optout",()=>n.options.some(o=>o._zod.optout==="optional")?"optional":void 0),Qe(e._zod,"values",()=>{if(n.options.every(o=>o._zod.values))return new Set(n.options.flatMap(o=>Array.from(o._zod.values)))}),Qe(e._zod,"pattern",()=>{if(n.options.every(o=>o._zod.pattern)){const o=n.options.map(u=>u._zod.pattern);return new RegExp(`^(${o.map(u=>rm(u.source)).join("|")})$`)}});const r=n.options.length===1,a=n.options[0]._zod.run;e._zod.parse=(o,u)=>{if(r)return a(o,u);let c=!1;const d=[];for(const m of n.options){const p=m._zod.run({value:o.value,issues:[]},u);if(p instanceof Promise)d.push(p),c=!0;else{if(p.issues.length===0)return p;d.push(p)}}return c?Promise.all(d).then(m=>Lv(m,o,e,u)):Lv(d,o,e,u)}}),MN=te("$ZodIntersection",(e,n)=>{kt.init(e,n),e._zod.parse=(r,a)=>{const o=r.value,u=n.left._zod.run({value:o,issues:[]},a),c=n.right._zod.run({value:o,issues:[]},a);return u instanceof Promise||c instanceof Promise?Promise.all([u,c]).then(([m,p])=>Vv(r,m,p)):Vv(r,u,c)}});function lh(e,n){if(e===n)return{valid:!0,data:e};if(e instanceof Date&&n instanceof Date&&+e==+n)return{valid:!0,data:e};if(go(e)&&go(n)){const r=Object.keys(n),a=Object.keys(e).filter(u=>r.indexOf(u)!==-1),o={...e,...n};for(const u of a){const c=lh(e[u],n[u]);if(!c.valid)return{valid:!1,mergeErrorPath:[u,...c.mergeErrorPath]};o[u]=c.data}return{valid:!0,data:o}}if(Array.isArray(e)&&Array.isArray(n)){if(e.length!==n.length)return{valid:!1,mergeErrorPath:[]};const r=[];for(let a=0;a<e.length;a++){const o=e[a],u=n[a],c=lh(o,u);if(!c.valid)return{valid:!1,mergeErrorPath:[a,...c.mergeErrorPath]};r.push(c.data)}return{valid:!0,data:r}}return{valid:!1,mergeErrorPath:[]}}function Vv(e,n,r){const a=new Map;let o;for(const d of n.issues)if(d.code==="unrecognized_keys"){o??(o=d);for(const m of d.keys)a.has(m)||a.set(m,{}),a.get(m).l=!0}else e.issues.push(d);for(const d of r.issues)if(d.code==="unrecognized_keys")for(const m of d.keys)a.has(m)||a.set(m,{}),a.get(m).r=!0;else e.issues.push(d);const u=[...a].filter(([,d])=>d.l&&d.r).map(([d])=>d);if(u.length&&o&&e.issues.push({...o,keys:u}),Ua(e))return e;const c=lh(n.value,r.value);if(!c.valid)throw new Error(`Unmergable intersection. Error path: ${JSON.stringify(c.mergeErrorPath)}`);return e.value=c.data,e}const ON=te("$ZodEnum",(e,n)=>{kt.init(e,n);const r=R1(n.entries),a=new Set(r);e._zod.values=a,e._zod.pattern=new RegExp(`^(${r.filter(o=>JC.has(typeof o)).map(o=>typeof o=="string"?qu(o):o.toString()).join("|")})$`),e._zod.parse=(o,u)=>{const c=o.value;return a.has(c)||o.issues.push({code:"invalid_value",values:r,input:c,inst:e}),o}}),kN=te("$ZodTransform",(e,n)=>{kt.init(e,n),e._zod.parse=(r,a)=>{if(a.direction==="backward")throw new A1(e.constructor.name);const o=n.transform(r.value,r);if(a.async)return(o instanceof Promise?o:Promise.resolve(o)).then(c=>(r.value=c,r));if(o instanceof Promise)throw new Fa;return r.value=o,r}});function Uv(e,n){return e.issues.length&&n===void 0?{issues:[],value:void 0}:e}const B1=te("$ZodOptional",(e,n)=>{kt.init(e,n),e._zod.optin="optional",e._zod.optout="optional",Qe(e._zod,"values",()=>n.innerType._zod.values?new Set([...n.innerType._zod.values,void 0]):void 0),Qe(e._zod,"pattern",()=>{const r=n.innerType._zod.pattern;return r?new RegExp(`^(${rm(r.source)})?$`):void 0}),e._zod.parse=(r,a)=>{if(n.innerType._zod.optin==="optional"){const o=n.innerType._zod.run(r,a);return o instanceof Promise?o.then(u=>Uv(u,r.value)):Uv(o,r.value)}return r.value===void 0?r:n.innerType._zod.run(r,a)}}),LN=te("$ZodExactOptional",(e,n)=>{B1.init(e,n),Qe(e._zod,"values",()=>n.innerType._zod.values),Qe(e._zod,"pattern",()=>n.innerType._zod.pattern),e._zod.parse=(r,a)=>n.innerType._zod.run(r,a)}),VN=te("$ZodNullable",(e,n)=>{kt.init(e,n),Qe(e._zod,"optin",()=>n.innerType._zod.optin),Qe(e._zod,"optout",()=>n.innerType._zod.optout),Qe(e._zod,"pattern",()=>{const r=n.innerType._zod.pattern;return r?new RegExp(`^(${rm(r.source)}|null)$`):void 0}),Qe(e._zod,"values",()=>n.innerType._zod.values?new Set([...n.innerType._zod.values,null]):void 0),e._zod.parse=(r,a)=>r.value===null?r:n.innerType._zod.run(r,a)}),UN=te("$ZodDefault",(e,n)=>{kt.init(e,n),e._zod.optin="optional",Qe(e._zod,"v
Line truncated
|
||||
|
||||
Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.`)}for(const c of e.seen.entries()){const d=c[1];if(n===c[0]){u(c);continue}if(e.external){const p=e.external.registry.get(c[0])?.id;if(n!==c[0]&&p){u(c);continue}}if(e.metadataRegistry.get(c[0])?.id){u(c);continue}if(d.cycle){u(c);continue}if(d.count>1&&e.reused==="ref"){u(c);continue}}}function q1(e,n){const r=e.seen.get(n);if(!r)throw new Error("Unprocessed schema. This is a bug in Zod.");const a=c=>{const d=e.seen.get(c);if(d.ref===null)return;const m=d.def??d.schema,p={...m},g=d.ref;if(d.ref=null,g){a(g);const x=e.seen.get(g),E=x.schema;if(E.$ref&&(e.target==="draft-07"||e.target==="draft-04"||e.target==="openapi-3.0")?(m.allOf=m.allOf??[],m.allOf.push(E)):Object.assign(m,E),Object.assign(m,p),c._zod.parent===g)for(const A in m)A==="$ref"||A==="allOf"||A in p||delete m[A];if(E.$ref&&x.def)for(const A in m)A==="$ref"||A==="allOf"||A in x.def&&JSON.stringify(m[A])===JSON.stringify(x.def[A])&&delete m[A]}const y=c._zod.parent;if(y&&y!==g){a(y);const x=e.seen.get(y);if(x?.schema.$ref&&(m.$ref=x.schema.$ref,x.def))for(const E in m)E==="$ref"||E==="allOf"||E in x.def&&JSON.stringify(m[E])===JSON.stringify(x.def[E])&&delete m[E]}e.override({zodSchema:c,jsonSchema:m,path:d.path??[]})};for(const c of[...e.seen.entries()].reverse())a(c[0]);const o={};if(e.target==="draft-2020-12"?o.$schema="https://json-schema.org/draft/2020-12/schema":e.target==="draft-07"?o.$schema="http://json-schema.org/draft-07/schema#":e.target==="draft-04"?o.$schema="http://json-schema.org/draft-04/schema#":e.target,e.external?.uri){const c=e.external.registry.get(n)?.id;if(!c)throw new Error("Schema is missing an `id` property");o.$id=e.external.uri(c)}Object.assign(o,r.def??r.schema);const u=e.external?.defs??{};for(const c of e.seen.entries()){const d=c[1];d.def&&d.defId&&(u[d.defId]=d.def)}e.external||Object.keys(u).length>0&&(e.target==="draft-2020-12"?o.$defs=u:o.definitions=u);try{const c=JSON.parse(JSON.stringify(o));return Object.defineProperty(c,"~standard",{value:{...n["~standard"],jsonSchema:{input:Cu(n,"input",e.processors),output:Cu(n,"output",e.processors)}},enumerable:!1,writable:!1}),c}catch{throw new Error("Error converting schema to JSON.")}}function rn(e,n){const r=n??{seen:new Set};if(r.seen.has(e))return!1;r.seen.add(e);const a=e._zod.def;if(a.type==="transform")return!0;if(a.type==="array")return rn(a.element,r);if(a.type==="set")return rn(a.valueType,r);if(a.type==="lazy")return rn(a.getter(),r);if(a.type==="promise"||a.type==="optional"||a.type==="nonoptional"||a.type==="nullable"||a.type==="readonly"||a.type==="default"||a.type==="prefault")return rn(a.innerType,r);if(a.type==="intersection")return rn(a.left,r)||rn(a.right,r);if(a.type==="record"||a.type==="map")return rn(a.keyType,r)||rn(a.valueType,r);if(a.type==="pipe")return rn(a.in,r)||rn(a.out,r);if(a.type==="object"){for(const o in a.shape)if(rn(a.shape[o],r))return!0;return!1}if(a.type==="union"){for(const o of a.options)if(rn(o,r))return!0;return!1}if(a.type==="tuple"){for(const o of a.items)if(rn(o,r))return!0;return!!(a.rest&&rn(a.rest,r))}return!1}const Lj=(e,n={})=>r=>{const a=Z1({...r,processors:n});return Kt(e,a),F1(a,e),q1(a,e)},Cu=(e,n,r={})=>a=>{const{libraryOptions:o,target:u}=a??{},c=Z1({...o??{},target:u,io:n,processors:r});return Kt(e,c),F1(c,e),q1(c,e)},Vj={guid:"uuid",url:"uri",datetime:"date-time",json_string:"json-string",regex:""},Uj=(e,n,r,a)=>{const o=r;o.type="string";const{minimum:u,maximum:c,format:d,patterns:m,contentEncoding:p}=e._zod.bag;if(typeof u=="number"&&(o.minLength=u),typeof c=="number"&&(o.maxLength=c),d&&(o.format=Vj[d]??d,o.format===""&&delete o.format,d==="time"&&delete o.format),p&&(o.contentEncoding=p),m&&m.size>0){const g=[...m];g.length===1?o.pattern=g[0].source:g.length>1&&(o.allOf=[...g.map(y=>({...n.target==="draft-07"||n.target==="draft-04"||n.target==="openapi-3.0"?{type:"string"}:{},pattern:y.source}))])}},Bj=(e,n,r,a)=>{r.not={}},Pj=(e,n,r,a)=>{},Hj=(e,n,r,a)=>{const o=e._zod.def,u=R1(o.entries);u.every(c=>typeof c=="number")&&(r.type="number"),u.every(c=>typeof c=="string")&&(r.type="string"),r.enum=u},Zj=(e,n,r,a)=>{if(n.unrepresentable==="throw")throw new Error("Custom types cannot be represented in JSON Schema")},Fj=(e,n,r,a)=>{if(n.unrepresentable==="throw")throw new Error("Transforms cannot be represented in JSON Schema")},qj=(e,n,r,a)=>{const o=r,u=e._zod.def,{minimum:c,maximum:d}=e._zod.bag;typeof c=="number"&&(o.minItems=c),typeof d=="number"&&(o.maxItems=d),o.type="array",o.items=Kt(u.element,n,{...a,path:[...a.path,"items"]})},$j=(e,n,r,a)=>{const o=r,u=e._zod.def;o.type="object",o.properties={};const c=u.shape;for(const p in c)o.properties[p]=Kt(c[p],n,{...a,path:[...a.path,"properties",p]});const d=new Set(Object.keys(c)),m=new Set([...d].filter(p=>{const g=u.shape[p]._zod;return n.io==="input"?g.optin===void 0:g.optout===void 0}));m.size>0&&(o.required=Array.from(m)),u.catchall?._zod.def.type==="never"?o.additionLine truncated
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
cursor: move;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.reactEasyCrop_Image,
|
||||
.reactEasyCrop_Video {
|
||||
will-change: transform; /* this improves performances and prevent painting issues on iOS Chrome */
|
||||
}
|
||||
|
||||
.reactEasyCrop_Contain {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
margin: auto;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
.reactEasyCrop_Cover_Horizontal {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.reactEasyCrop_Cover_Vertical {
|
||||
width: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.reactEasyCrop_CropArea {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 0 0 9999em;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.reactEasyCrop_CropAreaRound {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.reactEasyCrop_CropAreaGrid::before {
|
||||
content: ' ';
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 33.33%;
|
||||
right: 33.33%;
|
||||
border-top: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.reactEasyCrop_CropAreaGrid::after {
|
||||
content: ' ';
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
top: 33.33%;
|
||||
bottom: 33.33%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
`,CM=1,RM=3,NM=1,jM=(function(e){uM(n,e);function n(){var r=e!==null&&e.apply(this,arguments)||this;return r.cropperRef=C.createRef(),r.imageRef=C.createRef(),r.videoRef=C.createRef(),r.containerPosition={x:0,y:0},r.containerRef=null,r.styleRef=null,r.containerRect=null,r.mediaSize={width:0,height:0,naturalWidth:0,naturalHeight:0},r.dragStartPosition={x:0,y:0},r.dragStartCrop={x:0,y:0},r.gestureZoomStart=0,r.gestureRotationStart=0,r.isTouching=!1,r.lastPinchDistance=0,r.lastPinchRotation=0,r.rafDragTimeout=null,r.rafPinchTimeout=null,r.wheelTimer=null,r.currentDoc=typeof document<"u"?document:null,r.currentWindow=typeof window<"u"?window:null,r.resizeObserver=null,r.previousCropSize=null,r.isInitialized=!1,r.state={cropSize:null,hasWheelJustStarted:!1,mediaObjectFit:void 0},r.initResizeObserver=function(){if(!(typeof window.ResizeObserver>"u"||!r.containerRef)){var a=!0;r.resizeObserver=new window.ResizeObserver(function(o){if(a){a=!1;return}r.computeSizes()}),r.resizeObserver.observe(r.containerRef)}},r.preventZoomSafari=function(a){return a.preventDefault()},r.cleanEvents=function(){r.currentDoc&&(r.currentDoc.removeEventListener("mousemove",r.onMouseMove),r.currentDoc.removeEventListener("mouseup",r.onDragStopped),r.currentDoc.removeEventListener("touchmove",r.onTouchMove),r.currentDoc.removeEventListener("touchend",r.onDragStopped),r.currentDoc.removeEventListener("gesturechange",r.onGestureChange),r.currentDoc.removeEventListener("gestureend",r.onGestureEnd),r.currentDoc.removeEventListener("scroll",r.onScroll))},r.clearScrollEvent=function(){r.containerRef&&r.containerRef.removeEventListener("wheel",r.onWheel),r.wheelTimer&&clearTimeout(r.wheelTimer)},r.onMediaLoad=function(){var a=r.computeSizes();a&&(r.previousCropSize=a,r.emitCropData(),r.setInitialCrop(a),r.isInitialized=!0),r.props.onMediaLoaded&&r.props.onMediaLoaded(r.mediaSize)},r.setInitialCrop=function(a){if(r.props.initialCroppedAreaPercentages){var o=SM(r.props.initialCroppedAreaPercentages,r.mediaSize,r.props.rotation,a,r.props.minZoom,r.props.maxZoom),u=o.crop,c=o.zoom;r.props.onCropChange(u),r.props.onZoomChange&&r.props.onZoomChange(c)}else if(r.props.initialCroppedAreaPixels){var d=_M(r.props.initialCroppedAreaPixels,r.mediaSize,r.props.rotation,a,r.props.minZoom,r.props.maxZoom),u=d.crop,c=d.zoom;r.props.onCropChange(u),r.props.onZoomChange&&r.props.onZoomChange(c)}},r.computeSizes=function(){var a,o,u,c,d,m,p=r.imageRef.current||r.videoRef.current;if(p&&r.containerRef){r.containerRect=r.containerRef.getBoundingClientRect(),r.saveContainerPosition();var g=r.containerRect.width/r.containerRect.height,y=((a=r.imageRef.current)===null||a===void 0?void 0:a.naturalWidth)||((o=r.videoRef.current)===null||o===void 0?void 0:o.videoWidth)||0,x=((u=r.imageRef.current)===null||u===void 0?void 0:u.naturalHeight)||((c=r.videoRef.current)===null||c===void 0?void 0:c.videoHeight)||0,E=p.offsetWidth<y||p.offsetHeight<x,T=y/x,A=void 0;if(E)switch(r.state.mediaObjectFit){default:case"contain":A=g>T?{width:r.containerRect.height*T,height:r.containerRect.height}:{width:r.containerRect.width,height:r.containerRect.width/T};break;case"horizontal-cover":A={width:r.containerRect.width,height:r.containerRect.width/T};break;case"vertical-cover":A={width:r.containerRect.height*T,height:r.containerRect.height};break}else A={width:p.offsetWidth,height:p.offsetHeight};r.mediaSize=Pt(Pt({},A),{naturalWidth:y,naturalHeight:x}),r.props.setMediaSize&&r.props.setMediaSize(r.mediaSize);var _=r.props.cropSize?r.props.cropSize:yM(r.mediaSize.width,r.mediaSize.height,r.containerRect.width,r.containerRect.height,r.props.aspect,r.props.rotation);return(((d=r.state.cropSize)===null||d===void 0?void 0:d.height)!==_.height||((m=r.state.cropSize)===null||m===void 0?void 0:m.width)!==_.width)&&r.props.onCropSizeChange&&r.props.onCropSizeChange(_),r.setState({cropSize:_},r.recomputeCropPosition),r.props.setCropSize&&r.props.setCropSize(_),_}},r.saveContainerPosition=function(){if(r.containerRef){var a=r.containerRef.getBoundingClientRect();r.containerPosition={x:a.left,y:a.top}}},r.onMouseDown=function(a){r.currentDoc&&(a.preventDefault(),r.currentDoc.addEventListener("mousemove",r.onMouseMove),r.currentDoc.addEventListener("mouseup",r.onDragStopped),r.saveContainerPosition(),r.onDragStart(n.getMousePoint(a)))},r.onMouseMove=function(a){return r.onDrag(n.getMousePoint(a))},r.onScroll=function(a){r.currentDoc&&(a.preventDefault(),r.saveContainerPosition())},r.onTouchStart=function(a){r.currentDoc&&(r.isTouching=!0,!(r.props.onTouchRequest&&!r.props.onTouchRequest(a))&&(r.currentDoc.addEventListener("touchmove",r.onTouchMove,{passive:!1}),r.currentDoc.addEventListener("touchend",r.onDragStopped),r.saveContainerPosition(),a.touches.length===2?r.onPinchStart(a):a.touches.length===1&&r.onDragStart(n.getTouchPoint(a.touches[0]))))},r.onTouchMove=function(a){a.preventDefault(),a.touches.length===2?r.onPinchMove(a):a.touches.length===1&&r.onDrag(n.getTouchPoint(a.touches[0]))},r.onGestu
Line truncated
|
||||
`);for(const D of z)if(D.startsWith("data: ")){const F=JSON.parse(D.slice(6));r(F),F.step==="complete"&&F.result?(o(F.result),sessionStorage.setItem("analysisResult",JSON.stringify(F.result)),sessionStorage.removeItem("pendingImage")):F.step==="error"&&c(F.message)}}}catch(y){c(y.message||"Analysis failed")}},m=()=>{e("/review")},p=()=>{sessionStorage.removeItem("pendingImage"),e("/new")};return u?v.jsx("div",{className:"min-h-screen bg-gray-50 flex items-center justify-center",children:v.jsxs("div",{className:"bg-white rounded-lg shadow-lg p-8 max-w-md text-center",children:[v.jsx("div",{className:"text-6xl mb-4",children:"❌"}),v.jsx("h2",{className:"text-xl font-bold text-gray-900 mb-2",children:"Erreur d'analyse"}),v.jsx("p",{className:"text-gray-600 mb-6",children:u}),v.jsx("button",{onClick:p,className:"px-6 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700",children:"Réessayer"})]})}):a?v.jsx("div",{className:"min-h-screen bg-gray-50 flex items-center justify-center",children:v.jsxs("div",{className:"bg-white rounded-lg shadow-lg p-8 max-w-md text-center",children:[v.jsx("div",{className:"text-6xl mb-4",children:"✅"}),v.jsx("h2",{className:"text-xl font-bold text-gray-900 mb-2",children:"Analyse terminée!"}),v.jsxs("p",{className:"text-gray-600 mb-2",children:[v.jsx("span",{className:"font-medium",children:"Titre:"})," ",a.title]}),v.jsxs("p",{className:"text-gray-600 mb-6",children:[v.jsx("span",{className:"font-medium",children:"Matière:"})," ",a.subject]}),a.math_safety_triggered&&v.jsx("div",{className:"mb-4 p-3 bg-yellow-50 rounded-md text-yellow-700 text-sm",children:"⚠️ Contenu mathématique détecté - Mode extraction stricte activé"}),v.jsx("button",{onClick:m,className:"px-6 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700",children:"Continuer"})]})}):v.jsx("div",{className:"min-h-screen bg-gray-50 flex items-center justify-center",children:v.jsxs("div",{className:"bg-white rounded-lg shadow-lg p-8 max-w-md w-full",children:[v.jsx("h2",{className:"text-xl font-bold text-gray-900 mb-6 text-center",children:"Analyse en cours..."}),v.jsx("div",{className:"mb-6",children:v.jsx("div",{className:"h-2 bg-gray-200 rounded-full overflow-hidden",children:v.jsx("div",{className:"h-full bg-indigo-600 transition-all duration-300 ease-out",style:{width:`${n?.progress||0}%`}})})}),n&&v.jsxs("div",{className:"text-center",children:[v.jsx("div",{className:"text-4xl mb-3",children:ub[n.step]||"⏳"}),v.jsx("p",{className:"text-gray-700 font-medium",children:n.message})]}),v.jsx("div",{className:"mt-8 flex justify-between",children:["uploading","reading","analyzing","synthesizing"].map((g,y)=>v.jsxs("div",{className:`flex flex-col items-center ${(n?.progress||0)>=(y+1)*25?"text-indigo-600":"text-gray-300"}`,children:[v.jsx("div",{className:"text-xl",children:ub[g]}),v.jsx("span",{className:"text-xs mt-1 capitalize",children:g})]},g))})]})})};function kM(e,n){let r;try{r=e()}catch{return}return{getItem:o=>{var u;const c=m=>m===null?null:JSON.parse(m,void 0),d=(u=r.getItem(o))!=null?u:null;return d instanceof Promise?d.then(c):c(d)},setItem:(o,u)=>r.setItem(o,JSON.stringify(u,void 0)),removeItem:o=>r.removeItem(o)}}const dh=e=>n=>{try{const r=e(n);return r instanceof Promise?r:{then(a){return dh(a)(r)},catch(a){return this}}}catch(r){return{then(a){return this},catch(a){return dh(a)(r)}}}},LM=(e,n)=>(r,a,o)=>{let u={storage:kM(()=>localStorage),partialize:_=>_,version:0,merge:(_,M)=>({...M,..._}),...n},c=!1,d=0;const m=new Set,p=new Set;let g=u.storage;if(!g)return e((..._)=>{console.warn(`[zustand persist middleware] Unable to update item '${u.name}', the given storage is currently unavailable.`),r(..._)},a,o);const y=()=>{const _=u.partialize({...a()});return g.setItem(u.name,{state:_,version:u.version})},x=o.setState;o.setState=(_,M)=>(x(_,M),y());const E=e((..._)=>(r(..._),y()),a,o);o.getInitialState=()=>E;let T;const A=()=>{var _,M;if(!g)return;const z=++d;c=!1,m.forEach(F=>{var Z;return F((Z=a())!=null?Z:E)});const D=((M=u.onRehydrateStorage)==null?void 0:M.call(u,(_=a())!=null?_:E))||void 0;return dh(g.getItem.bind(g))(u.name).then(F=>{if(F)if(typeof F.version=="number"&&F.version!==u.version){if(u.migrate){const Z=u.migrate(F.state,F.version);return Z instanceof Promise?Z.then(se=>[!0,se]):[!0,Z]}console.error("State loaded from storage couldn't be migrated since no migrate function was provided")}else return[!1,F.state];return[!1,void 0]}).then(F=>{var Z;if(z!==d)return;const[se,ne]=F;if(T=u.merge(ne,(Z=a())!=null?Z:E),r(T,!0),se)return y()}).then(()=>{z===d&&(D?.(T,void 0),T=a(),c=!0,p.forEach(F=>F(T)))}).catch(F=>{z===d&&D?.(void 0,F)})};return o.persist={setOptions:_=>{u={...u,..._},_.storage&&(g=_.storage)},clearStorage:()=>{g?.removeItem(u.name)},getOptions:()=>u,rehydrate:()=>A(),hasHydrated:()=>c,onHydrate:_=>(m.add(_),()=>{m.delete(_)}),onFinishHydration:_=>(p.add(_),()=>{p.delete(_)})},u.skipHydration||A(),T||E},VM=LM,lm=Kx()(VM(e=>({isDyslexic:!1,toggleDysle
Line truncated
|
||||
`);try{O=new Worker(URL.createObjectURL(new Blob([Q])))}catch(ae){return typeof console<"u"&&typeof console.warn=="function"&&console.warn("🎊 Could not load worker",ae),null}q(O)}return O}})(),E={particleCount:50,angle:90,spread:45,startVelocity:45,decay:.9,gravity:1,drift:0,ticks:200,x:.5,y:.5,shapes:["square","circle"],zIndex:100,colors:["#26ccff","#a25afd","#ff5e7e","#88ff5a","#fcff42","#ffa62d","#ff36ff"],disableForReducedMotion:!1,scalar:1};function T(O,w){return w?w(O):O}function A(O){return O!=null}function _(O,w,k){return T(O&&A(O[w])?O[w]:E[w],k)}function M(O){return O<0?0:Math.floor(O)}function z(O,w){return Math.floor(Math.random()*(w-O))+O}function D(O){return parseInt(O,16)}function F(O){return O.map(Z)}function Z(O){var w=String(O).replace(/[^0-9a-f]/gi,"");return w.length<6&&(w=w[0]+w[0]+w[1]+w[1]+w[2]+w[2]),{r:D(w.substring(0,2)),g:D(w.substring(2,4)),b:D(w.substring(4,6))}}function se(O){var w=_(O,"origin",Object);return w.x=_(w,"x",Number),w.y=_(w,"y",Number),w}function ne(O){O.width=document.documentElement.clientWidth,O.height=document.documentElement.clientHeight}function Y(O){var w=O.getBoundingClientRect();O.width=w.width,O.height=w.height}function ge(O){var w=document.createElement("canvas");return w.style.position="fixed",w.style.top="0px",w.style.left="0px",w.style.pointerEvents="none",w.style.zIndex=O,w}function ve(O,w,k,q,Q,ae,ue,ie,be){O.save(),O.translate(w,k),O.rotate(ae),O.scale(q,Q),O.arc(0,0,1,ue,ie,be),O.restore()}function Fe(O){var w=O.angle*(Math.PI/180),k=O.spread*(Math.PI/180);return{x:O.x,y:O.y,wobble:Math.random()*10,wobbleSpeed:Math.min(.11,Math.random()*.1+.05),velocity:O.startVelocity*.5+Math.random()*O.startVelocity,angle2D:-w+(.5*k-Math.random()*k),tiltAngle:(Math.random()*(.75-.25)+.25)*Math.PI,color:O.color,shape:O.shape,tick:0,totalTicks:O.ticks,decay:O.decay,drift:O.drift,random:Math.random()+2,tiltSin:0,tiltCos:0,wobbleX:0,wobbleY:0,gravity:O.gravity*3,ovalScalar:.6,scalar:O.scalar,flat:O.flat}}function ce(O,w){w.x+=Math.cos(w.angle2D)*w.velocity+w.drift,w.y+=Math.sin(w.angle2D)*w.velocity+w.gravity,w.velocity*=w.decay,w.flat?(w.wobble=0,w.wobbleX=w.x+10*w.scalar,w.wobbleY=w.y+10*w.scalar,w.tiltSin=0,w.tiltCos=0,w.random=1):(w.wobble+=w.wobbleSpeed,w.wobbleX=w.x+10*w.scalar*Math.cos(w.wobble),w.wobbleY=w.y+10*w.scalar*Math.sin(w.wobble),w.tiltAngle+=.1,w.tiltSin=Math.sin(w.tiltAngle),w.tiltCos=Math.cos(w.tiltAngle),w.random=Math.random()+2);var k=w.tick++/w.totalTicks,q=w.x+w.random*w.tiltCos,Q=w.y+w.random*w.tiltSin,ae=w.wobbleX+w.random*w.tiltCos,ue=w.wobbleY+w.random*w.tiltSin;if(O.fillStyle="rgba("+w.color.r+", "+w.color.g+", "+w.color.b+", "+(1-k)+")",O.beginPath(),c&&w.shape.type==="path"&&typeof w.shape.path=="string"&&Array.isArray(w.shape.matrix))O.fill(J(w.shape.path,w.shape.matrix,w.x,w.y,Math.abs(ae-q)*.1,Math.abs(ue-Q)*.1,Math.PI/10*w.wobble));else if(w.shape.type==="bitmap"){var ie=Math.PI/10*w.wobble,be=Math.abs(ae-q)*.1,we=Math.abs(ue-Q)*.1,Le=w.shape.bitmap.width*w.scalar,ke=w.shape.bitmap.height*w.scalar,$e=new DOMMatrix([Math.cos(ie)*be,Math.sin(ie)*be,-Math.sin(ie)*we,Math.cos(ie)*we,w.x,w.y]);$e.multiplySelf(new DOMMatrix(w.shape.matrix));var ut=O.createPattern(g.transform(w.shape.bitmap),"no-repeat");ut.setTransform($e),O.globalAlpha=1-k,O.fillStyle=ut,O.fillRect(w.x-Le/2,w.y-ke/2,Le,ke),O.globalAlpha=1}else if(w.shape==="circle")O.ellipse?O.ellipse(w.x,w.y,Math.abs(ae-q)*w.ovalScalar,Math.abs(ue-Q)*w.ovalScalar,Math.PI/10*w.wobble,0,2*Math.PI):ve(O,w.x,w.y,Math.abs(ae-q)*w.ovalScalar,Math.abs(ue-Q)*w.ovalScalar,Math.PI/10*w.wobble,0,2*Math.PI);else if(w.shape==="star")for(var ze=Math.PI/2*3,yt=4*w.scalar,Vt=8*w.scalar,en=w.x,un=w.y,jn=5,Ht=Math.PI/jn;jn--;)en=w.x+Math.cos(ze)*Vt,un=w.y+Math.sin(ze)*Vt,O.lineTo(en,un),ze+=Ht,en=w.x+Math.cos(ze)*yt,un=w.y+Math.sin(ze)*yt,O.lineTo(en,un),ze+=Ht;else O.moveTo(Math.floor(w.x),Math.floor(w.y)),O.lineTo(Math.floor(w.wobbleX),Math.floor(Q)),O.lineTo(Math.floor(ae),Math.floor(ue)),O.lineTo(Math.floor(q),Math.floor(w.wobbleY));return O.closePath(),O.fill(),w.tick<w.totalTicks}function Ee(O,w,k,q,Q){var ae=w.slice(),ue=O.getContext("2d"),ie,be,we=p(function(Le){function ke(){ie=be=null,ue.clearRect(0,0,q.width,q.height),g.clear(),Q(),Le()}function $e(){a&&!(q.width===o.width&&q.height===o.height)&&(q.width=O.width=o.width,q.height=O.height=o.height),!q.width&&!q.height&&(k(O),q.width=O.width,q.height=O.height),ue.clearRect(0,0,q.width,q.height),ae=ae.filter(function(ut){return ce(ue,ut)}),ae.length?ie=y.frame($e):ke()}ie=y.frame($e),be=ke});return{addFettis:function(Le){return ae=ae.concat(Le),we},canvas:O,promise:we,reset:function(){ie&&y.cancel(ie),be&&be()}}}function Ce(O,w){var k=!O,q=!!_(w||{},"resize"),Q=!1,ae=_(w,"disableForReducedMotion",Boolean),ue=u&&!!_(w||{},"useWorker"),ie=ue?x():null,be=k?ne:Y,we=O&&ie?!!O.__confetti_initialized:!1,Le=typeof matchMedia=="function"&&matchMedia("(prefers-reduced-motion)").matches,ke;function $e(ze,yt,Vt){for(var en=_(ze,"particleCount",M),un=_(ze
Line truncated
|
||||
${u.status==="MASTERED"?"bg-emerald-100 text-emerald-600":u.status==="REVIEWING"?"bg-blue-100 text-blue-600":"bg-amber-100 text-amber-600"}
|
||||
`,children:u.status==="MASTERED"?"Maîtrisé":u.status==="REVIEWING"?"En cours":"Débutant"}),v.jsxs("div",{className:"relative w-12 h-12 flex items-center justify-center",children:[v.jsxs("svg",{className:"transform -rotate-90 w-full h-full",children:[v.jsx("circle",{cx:"24",cy:"24",r:"20",stroke:"currentColor",strokeWidth:"4",fill:"transparent",className:"text-gray-100"}),v.jsx("circle",{cx:"24",cy:"24",r:"20",stroke:"currentColor",strokeWidth:"4",fill:"transparent",strokeDasharray:125.6,strokeDashoffset:125.6-125.6*u.mastery_score/100,className:`transition-all duration-1000 ease-out
|
||||
${u.mastery_score>=80?"text-emerald-500":u.mastery_score>=50?"text-blue-500":"text-amber-500"}
|
||||
`,strokeLinecap:"round"})]}),v.jsxs("span",{className:"absolute text-[10px] font-bold text-gray-600",children:[u.mastery_score,"%"]})]})]})]},c))}),v.jsx("button",{className:"w-full mt-6 py-3 text-sm font-bold text-emerald-600 bg-emerald-50 rounded-xl hover:bg-emerald-100 transition-colors",children:"Voir tous les sujets"})]}):null}const hw=C.createContext({});function HO(e){const n=C.useRef(null);return n.current===null&&(n.current=e()),n.current}const mw=typeof window<"u",ZO=mw?C.useLayoutEffect:C.useEffect,fm=C.createContext(null);function dm(e,n){e.indexOf(n)===-1&&e.push(n)}function hm(e,n){const r=e.indexOf(n);r>-1&&e.splice(r,1)}const fi=(e,n,r)=>r>n?n:r<e?e:r;let mm=()=>{};const Bi={},pw=e=>/^-?(?:\d+(?:\.\d+)?|\.\d+)$/u.test(e);function gw(e){return typeof e=="object"&&e!==null}const yw=e=>/^0[^.\s]+$/u.test(e);function pm(e){let n;return()=>(n===void 0&&(n=e()),n)}const $n=e=>e,FO=(e,n)=>r=>n(e(r)),zo=(...e)=>e.reduce(FO),vo=(e,n,r)=>{const a=n-e;return a===0?1:(r-e)/a};class gm{constructor(){this.subscriptions=[]}add(n){return dm(this.subscriptions,n),()=>hm(this.subscriptions,n)}notify(n,r,a){const o=this.subscriptions.length;if(o)if(o===1)this.subscriptions[0](n,r,a);else for(let u=0;u<o;u++){const c=this.subscriptions[u];c&&c(n,r,a)}}getSize(){return this.subscriptions.length}clear(){this.subscriptions.length=0}}const Vi=e=>e*1e3,qn=e=>e/1e3;function vw(e,n){return n?e*(1e3/n):0}const bw=(e,n,r)=>(((1-3*r+3*n)*e+(3*r-6*n))*e+3*n)*e,qO=1e-7,$O=12;function GO(e,n,r,a,o){let u,c,d=0;do c=n+(r-n)/2,u=bw(c,a,o)-e,u>0?r=c:n=c;while(Math.abs(u)>qO&&++d<$O);return c}function Do(e,n,r,a){if(e===n&&r===a)return $n;const o=u=>GO(u,0,1,e,r);return u=>u===0||u===1?u:bw(o(u),n,a)}const xw=e=>n=>n<=.5?e(2*n)/2:(2-e(2*(1-n)))/2,ww=e=>n=>1-e(1-n),Sw=Do(.33,1.53,.69,.99),ym=ww(Sw),Ew=xw(ym),_w=e=>(e*=2)<1?.5*ym(e):.5*(2-Math.pow(2,-10*(e-1))),vm=e=>1-Math.sin(Math.acos(e)),Tw=ww(vm),Aw=xw(vm),YO=Do(.42,0,1,1),XO=Do(0,0,.58,1),Cw=Do(.42,0,.58,1),KO=e=>Array.isArray(e)&&typeof e[0]!="number",Rw=e=>Array.isArray(e)&&typeof e[0]=="number",IO={linear:$n,easeIn:YO,easeInOut:Cw,easeOut:XO,circIn:vm,circInOut:Aw,circOut:Tw,backIn:ym,backInOut:Ew,backOut:Sw,anticipate:_w},QO=e=>typeof e=="string",yb=e=>{if(Rw(e)){mm(e.length===4);const[n,r,a,o]=e;return Do(n,r,a,o)}else if(QO(e))return IO[e];return e},ou=["setup","read","resolveKeyframes","preUpdate","update","preRender","render","postRender"];function JO(e,n){let r=new Set,a=new Set,o=!1,u=!1;const c=new WeakSet;let d={delta:0,timestamp:0,isProcessing:!1};function m(g){c.has(g)&&(p.schedule(g),e()),g(d)}const p={schedule:(g,y=!1,x=!1)=>{const T=x&&o?r:a;return y&&c.add(g),T.has(g)||T.add(g),g},cancel:g=>{a.delete(g),c.delete(g)},process:g=>{if(d=g,o){u=!0;return}o=!0,[r,a]=[a,r],r.forEach(m),r.clear(),o=!1,u&&(u=!1,p.process(g))}};return p}const WO=40;function Nw(e,n){let r=!1,a=!0;const o={delta:0,timestamp:0,isProcessing:!1},u=()=>r=!0,c=ou.reduce((D,F)=>(D[F]=JO(u),D),{}),{setup:d,read:m,resolveKeyframes:p,preUpdate:g,update:y,preRender:x,render:E,postRender:T}=c,A=()=>{const D=Bi.useManualTiming?o.timestamp:performance.now();r=!1,Bi.useManualTiming||(o.delta=a?1e3/60:Math.max(Math.min(D-o.timestamp,WO),1)),o.timestamp=D,o.isProcessing=!0,d.process(o),m.process(o),p.process(o),g.process(o),y.process(o),x.process(o),E.process(o),T.process(o),o.isProcessing=!1,r&&n&&(a=!1,e(A))},_=()=>{r=!0,a=!0,o.isProcessing||e(A)};return{schedule:ou.reduce((D,F)=>{const Z=c[F];return D[F]=(se,ne=!1,Y=!1)=>(r||_(),Z.schedule(se,ne,Y)),D},{}),cancel:D=>{for(let F=0;F<ou.length;F++)c[ou[F]].cancel(D)},state:o,steps:c}}const{schedule:at,cancel:pr,state:Xt,steps:kd}=Nw(typeof requestAnimationFrame<"u"?requestAnimationFrame:$n,!0);let pu;function e4(){pu=void 0}const sn={now:()=>(pu===void 0&&sn.set(Xt.isProcessing||Bi.useManualTiming?Xt.timestamp:performance.now()),pu),set:e=>{pu=e,queueMicrotask(e4)}},jw=e=>n=>typeof n=="string"&&n.startsWith(e),zw=jw("--"),t4=jw("var(--"),bm=e=>t4(e)?n4.test(e.split("/*")[0].trim()):!1,n4=/var\(--(?:[\w-]+\s*|[\w-]+\s*,(?:\s*[^)(\s]|\s*\((?:[^)(]|\([^)(]*\))*\))+\s*)\)$/iu;function vb(e){return typeof e!="string"?!1:e.split("/*")[0].includes("var(--")}const ts={test:e=>typeof e=="number",parse:parseFloat,transform:e=>e},bo={...ts,transform:e=>fi(0,1,e)},lu={...ts,default:1},co=e=>Math.round(e*1e5)/1e5,xm=/-?(?:\d+(?:\.\d+)?|\.\d+)/gu;function i4(e){return e==null}const r4=/^(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\((?:-?[\d.]+%?[,\s]+){2}-?[\d.]+%?\s*(?:[,/]\s*)?(?:\b\d+(?:\.\d+)?|\.\d+)?%?\))$/iu,wm=(e,n)=>r=>!!(typeof r=="string"&&r4.test(r)&&r.startsWith(e)||n&&!i4(r)&&Object.prototype.hasOwnProperty.call(r,n)),Dw=(e,n,r)=>a=>{if(typeof a!="string")return a;const[o,u,c,d]=a.match(xm);return{[e]:parseFloat(o),[n]:parseFloat(u),[r]:parseFloat(c),alpha:d!==void 0?parseFloat(d):1}},a4=e=>fi(0,255,e),Ld={...ts,transform:e=>Math.round(a4(e))},Fr={test:wm("rgb","red"),parse:Dw("red","green","blue"),transform:({red:e,green:n,
Line truncated
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Reviflow</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/opendyslexic@latest/open-dyslexic.css" rel="stylesheet">
|
||||
<script type="module" crossorigin src="/assets/index-CcsouM31.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C2lSBDW-.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,12 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from app.main import app
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def client():
|
||||
async with AsyncClient(app=app, base_url="http://test") as c:
|
||||
yield c
|
||||
@@ -0,0 +1,38 @@
|
||||
import pytest
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_health_check(client):
|
||||
response = await client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
# Allow extra fields, just check status
|
||||
assert response.json().get("status") == "ok"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_register_flow(client):
|
||||
# Minimal required fields for UserCreate
|
||||
user_data = {
|
||||
"email": "test@example.com",
|
||||
"password": "strongPassword123!",
|
||||
"role": "parent" # Specific to our schema
|
||||
}
|
||||
|
||||
response = await client.post("/api/auth/register", json=user_data)
|
||||
if response.status_code not in [201, 400]:
|
||||
print(f"Registration failed: {response.json()}")
|
||||
# 201 Created or 400 Bad Request (if exists)
|
||||
assert response.status_code in [201, 400]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_login_flow(client, anyio_backend):
|
||||
login_data = {
|
||||
"username": "test@example.com",
|
||||
"password": "strongPassword123!"
|
||||
}
|
||||
# Correct path for fastapi-users JWT strategy
|
||||
response = await client.post("/api/auth/jwt/login", data=login_data)
|
||||
|
||||
if response.status_code == 200:
|
||||
assert "access_token" in response.json()
|
||||
else:
|
||||
# 400/401 acceptable if user creation failed or db issue
|
||||
assert response.status_code in [400, 401]
|
||||
Reference in new issue
Block a user