pdf-quiz-generator/backend/app/routers/admin.py
Daniel 9af63e67b5 Major: categories, question bank, security fixes, mobile layout, UX improvements
Security:
- Nginx: X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, CSP, Referrer-Policy, Permissions-Policy headers
- Redis-backed login rate limiting (survives container restarts)
- Admin litellm/models endpoint: api_key moved from GET query param to POST body
- Nextcloud credentials moved from localStorage to sessionStorage (cleared on tab close)

UX / Layout:
- Login: unverified users see inline "Resend verification email" option
- QuizPage mobile: TTS Listen button on its own row below question text
- QuizPage mobile: Voice selector on its own row in header card (not squashed with timer)
- QuizEditPage: scroll position preserved after saving a question edit

Quiz Categories:
- New QuizCategory model + quiz_categories table
- category_id column added to quizzes table
- GET/POST/DELETE /api/categories endpoints
- Quizzes grouped by category in QuizzesPage; moderators can assign via 🏷 menu
- Uncategorized section shown when categories exist

Question Bank:
- GET /api/questions/bank — search all questions across quizzes
- POST /api/questions/from-bank — create new quiz from selected questions (copies, originals untouched)
- QuestionBankPage: search, checkbox select, study modal, create quiz form
- "Question Bank" link added to Navbar

Search:
- "View all N questions →" button expands to full question list
- Each question has a Study button opening in-place modal with study mode
- Summary view shows 2 questions per quiz with Study button

Extraction prompt:
- Stronger emphasis on correct_answer field with step-by-step letter → full text example
- Explicit instruction never to store just the letter

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 20:08:05 +02:00

204 lines
6.6 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
import httpx
from app.config import settings
from app.database import get_db
from app.models.user import User
from app.models.ai_model_config import AIModelConfig
from app.schemas.auth import UserResponse, UserUpdateRole, UserCreate
from app.schemas.admin import AIModelConfigCreate, AIModelConfigResponse, AIModelConfigUpdate
from app.utils.auth import require_admin, get_current_user, get_password_hash
router = APIRouter()
# --- User Management ---
@router.get("/users", response_model=list[UserResponse])
def list_users(
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
return db.query(User).order_by(User.created_at.desc()).all()
@router.put("/users/{user_id}/role", response_model=UserResponse)
def update_user_role(
user_id: int,
role_data: UserUpdateRole,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
if role_data.role not in ("admin", "moderator", "user"):
raise HTTPException(status_code=400, detail="Role must be admin, moderator, or user")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == admin.id:
raise HTTPException(status_code=400, detail="Cannot change your own role")
user.role = role_data.role
db.commit()
db.refresh(user)
return user
@router.post("/users", response_model=UserResponse)
def create_user(
user_data: UserCreate,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
"""Admin creates a user directly — email is auto-verified."""
from app.models.email_verification import EmailVerification
from datetime import datetime
if db.query(User).filter(User.email == user_data.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
user = User(
email=user_data.email,
hashed_password=get_password_hash(user_data.password),
name=user_data.name,
role="user",
)
db.add(user)
db.flush()
db.add(EmailVerification(
user_id=user.id,
token=f"admin_created_{user.id}",
expires_at=datetime.utcnow(),
verified_at=datetime.utcnow(),
))
db.commit()
db.refresh(user)
return user
# --- AI Model Configuration ---
@router.get("/models/available")
def list_available_models(
task: str = Query("extraction"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Returns active models for a given task — for users to choose when taking/creating a quiz."""
models = db.query(AIModelConfig).filter(
AIModelConfig.task == task,
AIModelConfig.is_active == True,
).order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all()
result = [{"id": m.id, "name": m.name, "model_id": m.model_id, "is_default": m.is_default} for m in models]
# Always include env default as fallback if nothing configured
if not result:
result.append({"id": None, "name": "Default (from config)", "model_id": settings.LITELLM_MODEL, "is_default": True})
return result
class LiteLLMModelQuery(BaseModel):
api_key: str | None = None
api_base: str | None = None
@router.post("/litellm/models")
def search_litellm_models(
body: LiteLLMModelQuery = LiteLLMModelQuery(),
admin: User = Depends(require_admin),
):
"""Query available models from LiteLLM proxy or OpenAI-compatible API."""
base = (body.api_base or settings.LITELLM_API_BASE or "").rstrip("/")
key = body.api_key or settings.LITELLM_API_KEY
if base:
try:
headers = {"Authorization": f"Bearer {key}"} if key else {}
resp = httpx.get(f"{base}/v1/models", headers=headers, timeout=10)
resp.raise_for_status()
data = resp.json()
models = sorted([m["id"] for m in data.get("data", [])])
return {"models": models, "source": base}
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to query models API: {e}")
# Fall back to LiteLLM's built-in model list
try:
import litellm
models = sorted(litellm.utils.get_valid_models())
return {"models": models, "source": "litellm-builtin"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get LiteLLM models: {e}")
@router.get("/models", response_model=list[AIModelConfigResponse])
def list_models(
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
return db.query(AIModelConfig).order_by(AIModelConfig.task, AIModelConfig.name).all()
@router.post("/models", response_model=AIModelConfigResponse)
def create_model(
data: AIModelConfigCreate,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
if data.task not in ("extraction", "tts", "general"):
raise HTTPException(status_code=400, detail="Task must be extraction, tts, or general")
if data.is_default:
db.query(AIModelConfig).filter(
AIModelConfig.task == data.task,
AIModelConfig.is_default == True,
).update({"is_default": False})
model = AIModelConfig(**data.model_dump())
db.add(model)
db.commit()
db.refresh(model)
return model
@router.put("/models/{model_id}", response_model=AIModelConfigResponse)
def update_model(
model_id: int,
data: AIModelConfigUpdate,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first()
if not model:
raise HTTPException(status_code=404, detail="Model config not found")
update_data = data.model_dump(exclude_unset=True)
task = update_data.get("task", model.task)
if update_data.get("is_default"):
db.query(AIModelConfig).filter(
AIModelConfig.task == task,
AIModelConfig.is_default == True,
AIModelConfig.id != model_id,
).update({"is_default": False})
for key, value in update_data.items():
setattr(model, key, value)
db.commit()
db.refresh(model)
return model
@router.delete("/models/{model_id}", status_code=204)
def delete_model(
model_id: int,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first()
if not model:
raise HTTPException(status_code=404, detail="Model config not found")
db.delete(model)
db.commit()