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.put("/users/{user_id}/unthrottle", response_model=UserResponse) def set_user_unthrottle( user_id: int, data: dict, db: Session = Depends(get_db), admin: User = Depends(require_admin), ): """Set or clear the unthrottle flag for a user — exempt from AI/TTS rate limits.""" user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") user.is_unthrottled = 1 if data.get("unthrottled") else 0 db.commit() db.refresh(user) return user @router.delete("/users/{user_id}", status_code=204) def delete_user( user_id: int, db: Session = Depends(get_db), admin: User = Depends(require_admin), ): """Delete a user and all their data (attempts, favorites, settings).""" 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 delete yourself") # Nullify non-cascading FKs before delete from sqlalchemy import text db.execute(text("UPDATE question_categories SET user_id = NULL WHERE user_id = :uid"), {"uid": user_id}) db.execute(text("UPDATE quiz_categories SET user_id = NULL WHERE user_id = :uid"), {"uid": user_id}) db.delete(user) db.commit() @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 email_normalized = user_data.email.lower().strip() if db.query(User).filter(User.email == email_normalized).first(): raise HTTPException(status_code=400, detail="Email already registered") user = User( email=email_normalized, 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 LiteLLMSearchRequest(BaseModel): api_key: str | None = None api_base: str | None = None @router.post("/litellm/models") def search_litellm_models( data: LiteLLMSearchRequest, admin: User = Depends(require_admin), ): """Query available models from LiteLLM proxy or OpenAI-compatible API.""" import logging log = logging.getLogger(__name__) base = (data.api_base or settings.LITELLM_API_BASE or "").rstrip("/") key = data.api_key or settings.LITELLM_API_KEY if base: try: headers = {"Authorization": f"Bearer {key}"} if key else {} resp = httpx.post(f"{base}/v1/models", headers=headers, timeout=10) if False else \ httpx.get(f"{base}/v1/models", headers=headers, timeout=10) resp.raise_for_status() models = sorted([m["id"] for m in resp.json().get("data", [])]) return {"models": models, "source": base} except Exception as e: log.warning(f"LiteLLM model search failed: {e}") raise HTTPException(status_code=400, detail=f"Failed to query models API: {e}") try: import litellm models = sorted(litellm.utils.get_valid_models()) return {"models": models, "source": "litellm-builtin"} except Exception as e: log.warning(f"LiteLLM builtin model list failed: {e}") raise HTTPException(status_code=500, detail="Failed to retrieve LiteLLM built-in model list.") @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", "teach", "keyword", "flashcard"): raise HTTPException(status_code=400, detail="Task must be extraction, tts, teach, or keyword") if data.is_default: db.query(AIModelConfig).filter( AIModelConfig.task == data.task, AIModelConfig.is_default == True, ).update({"is_default": False}) # Auto-set as default if this is the first model for the task existing = db.query(AIModelConfig).filter( AIModelConfig.task == data.task, AIModelConfig.is_active == True, ).count() if existing == 0: data_dict = data.model_dump() data_dict["is_default"] = True else: data_dict = data.model_dump() model = AIModelConfig(**data_dict) db.add(model) try: db.commit() except Exception as e: db.rollback() if "uq_model_task" in str(e).lower() or "unique" in str(e).lower(): raise HTTPException(status_code=409, detail=f"Model '{data.model_id}' already exists for task '{data.task}'") raise HTTPException(status_code=500, detail=str(e)) 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() @router.post("/models/{model_id}/test") def test_model( model_id: int, db: Session = Depends(get_db), admin: User = Depends(require_admin), ): """Send a simple test completion to verify an LLM model is reachable. TTS models are previewed via /tts/speak instead.""" model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first() if not model: raise HTTPException(status_code=404, detail="Model config not found") if model.task == "tts": raise HTTPException(status_code=400, detail="Use the Preview button to test TTS voices — it plays audio directly.") try: import litellm from app.services.ai_service import _proxy_model use_model = _proxy_model(model.model_id) kwargs = { "model": use_model, "messages": [{"role": "user", "content": "Reply with only the word: OK"}], "max_tokens": 10, } if model.api_key: kwargs["api_key"] = model.api_key elif settings.LITELLM_API_KEY: kwargs["api_key"] = settings.LITELLM_API_KEY if settings.LITELLM_API_BASE: kwargs["api_base"] = settings.LITELLM_API_BASE response = litellm.completion(**kwargs) reply = response.choices[0].message.content.strip() return {"message": f"✓ {model.model_id} → {reply!r}"} except Exception as e: raise HTTPException(status_code=502, detail=str(e)) class TTSVoiceSearchRequest(BaseModel): provider: str api_key: str | None = None region: str | None = None @router.post("/tts/voices") def search_tts_voices( data: TTSVoiceSearchRequest, admin: User = Depends(require_admin), ): """Discover available TTS voices from ElevenLabs, AWS Polly, or return OpenAI hardcoded list.""" import logging log = logging.getLogger(__name__) provider = data.provider api_key = data.api_key region = data.region if provider == "elevenlabs": key = api_key or settings.ELEVENLABS_API_KEY if not key: raise HTTPException(status_code=400, detail="ElevenLabs API key required (set ELEVENLABS_API_KEY in .env or enter it above)") try: resp = httpx.get( "https://api.elevenlabs.io/v1/voices", headers={"xi-api-key": key}, timeout=15, ) resp.raise_for_status() voices = resp.json().get("voices", []) return {"voices": [ { "model_id": f"elevenlabs/{v['voice_id']}", "name": v["name"], "labels": v.get("labels", {}), } for v in sorted(voices, key=lambda x: x["name"]) ]} except HTTPException: raise except Exception as e: log.warning(f"ElevenLabs voice discovery failed: {e}") raise HTTPException(status_code=400, detail=f"ElevenLabs API error: {e}") elif provider == "polly": access_key = api_key or settings.AWS_ACCESS_KEY_ID secret_key = settings.AWS_SECRET_ACCESS_KEY aws_region = region or settings.AWS_REGION or "us-east-1" if not access_key or not secret_key: raise HTTPException( status_code=400, detail="AWS credentials required — set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY in .env", ) try: import boto3 polly = boto3.client( "polly", aws_access_key_id=access_key, aws_secret_access_key=secret_key, region_name=aws_region, ) resp = polly.describe_voices(Engine="neural") voices = resp.get("Voices", []) return {"voices": [ { "model_id": f"polly/{v['Id']}", "name": f"{v['Name']} — {v['LanguageName']} ({v.get('Gender', '')})", "labels": {"gender": v.get("Gender", ""), "language": v.get("LanguageName", "")}, } for v in sorted(voices, key=lambda x: x["Name"]) ]} except HTTPException: raise except Exception as e: log.warning(f"AWS Polly voice discovery failed: {e}") raise HTTPException(status_code=400, detail=f"AWS Polly error: {e}") elif provider == "openai": voices = [] for model_name in ["tts-1", "tts-1-hd"]: for voice in ["alloy", "ash", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer"]: voices.append({ "model_id": f"{model_name}:{voice}", "name": f"{model_name} · {voice}", "labels": {"model": model_name, "voice": voice}, }) return {"voices": voices} else: raise HTTPException(status_code=400, detail=f"Unknown provider '{provider}'. Valid: elevenlabs, polly, openai") # --- System Settings --- @router.get("/settings") def get_settings(admin: User = Depends(require_admin)): """Get system settings.""" try: import redis as redis_lib r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) registration_enabled = r.get("settings:registration_enabled") embedding_model = r.get("settings:embedding_model") polly_enabled = r.get("settings:polly_enabled") return { "registration_enabled": registration_enabled != "false", "embedding_model": embedding_model or settings.LITELLM_EMBEDDING_MODEL or "", "polly_enabled": polly_enabled != "false", } except Exception: return { "registration_enabled": True, "embedding_model": settings.LITELLM_EMBEDDING_MODEL or "", "polly_enabled": True, } @router.put("/settings") def update_settings( settings_data: dict, admin: User = Depends(require_admin), ): """Update system settings.""" try: import redis as redis_lib r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) if "registration_enabled" in settings_data: value = "true" if settings_data["registration_enabled"] else "false" r.set("settings:registration_enabled", value) if "embedding_model" in settings_data: r.set("settings:embedding_model", settings_data["embedding_model"]) if "polly_enabled" in settings_data: value = "true" if settings_data["polly_enabled"] else "false" r.set("settings:polly_enabled", value) return {"success": True, "message": "Settings updated"} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to update settings: {str(e)}") @router.post("/embedding/test") def test_embedding(admin: User = Depends(require_admin)): """Test the currently configured embedding model.""" from app.services.embedding_service import generate_embedding, _get_embedding_model model = _get_embedding_model() if not model: raise HTTPException(status_code=400, detail="No embedding model configured") result = generate_embedding("The quick brown fox jumps over the lazy dog") if result is None: raise HTTPException(status_code=500, detail=f"Embedding failed for model: {model}") return {"model": model, "dimensions": len(result), "status": "ok"} @router.post("/embedding/regenerate") def regenerate_embeddings(admin: User = Depends(require_admin)): """Queue a background Celery task to regenerate all question embeddings.""" import uuid from app.tasks.quiz_tasks import regenerate_embeddings as regen_task job_id = str(uuid.uuid4()) regen_task.delay(job_id, admin.id) return {"job_id": job_id, "message": "Regeneration started — progress visible in the Jobs badge."}