Features: - Hybrid semantic + keyword quiz search (pgvector HNSW + PostgreSQL ILIKE) - AWS Bedrock Titan Embed V2 embeddings via LiteLLM proxy (0.71 cosine sim) - Multi-provider TTS: OpenAI, AWS Polly (neural), ElevenLabs, Google Cloud TTS - Unified Settings page (profile, theme, Nextcloud integration, admin shortcuts) - Good morning/afternoon greeting on dashboard - manage.py CLI: reset-password, list-users, reembed - Email verification enforced: register no longer returns JWT for unverified users - Quiz search with debounced input, semantic/keyword/title modes, highlighted snippets - TTS button: loading/playing states, voice selector locked during playback - TTS auto-stops when navigating between questions - Footer added; mobile quiz nav overflow fixed; markdown theme body selector fixed - OpenAI Alloy as default TTS voice; favicon added - SMTP configured via smtp2go; password reset rate limiting (3/hour) - PostgreSQL upgraded to pgvector/pgvector:pg16 Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
199 lines
6.5 KiB
Python
199 lines
6.5 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
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
|
|
|
|
|
|
@router.get("/litellm/models")
|
|
def search_litellm_models(
|
|
api_key: str = Query(None),
|
|
api_base: str = Query(None),
|
|
admin: User = Depends(require_admin),
|
|
):
|
|
"""Query available models from LiteLLM proxy or OpenAI-compatible API."""
|
|
base = (api_base or settings.LITELLM_API_BASE or "").rstrip("/")
|
|
key = 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()
|