pdf-quiz-generator/docs/api-reference.md
Daniel 4ca7f6b1f2 feat: Cap replaces hCaptcha, self-hosted beside the app
Proof-of-work rather than a puzzle, and — the reason for it — nothing
about the person signing up is described to a third party in order to let
them in. Turnstile and then hCaptcha were both here; both told Cloudflare
who was at the door.

The `cap` service runs on the compose network with its own Redis
database, kept apart from the app's so a flush of one cannot clear the
other's challenges. The widget talks to /cap/ on this origin, proxied by
the frontend's nginx, so the browser reaches nobody else either. Caddy
passes the whole host through to that container, so it needed no change.

Two things that had to be found rather than read:

Cap's key API is undocumented. The routes are `/auth/login` and
`/server/keys`, and the Bearer value is base64 JSON of `{token, hash}` —
not the session token itself, which is why the obvious call returns
"Malformed session token". The site key and secret were created that way
rather than by hand in a dashboard.

And an nginx proxy_pass whose target is a variable passes the URI through
untouched: the trailing slash that strips a location prefix on a literal
target does nothing. Cap was being asked for /cap/<key>/challenge and
answering NOT_FOUND until the prefix was stripped by an explicit rewrite.

Verified end to end against the running service: a challenge is issued
through the public path, and a token that was never issued is refused
rather than waved through.

Also here: the register modal's Name and Email were bare labels that
neither wrapped their input nor named it, so a screen reader met two
boxes with no names and clicking the word did nothing.

And the knowledge profile paginates ten to a page and expands each row to
its two bars beside the next step. "Correct using hints" is missing from
that bar because /study-tools/recommendations does not carry it per
topic — inferring it from the lifetime figure would be a different set of
answers, so the bar is honestly two-tone until the backend offers it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 06:14:14 +02:00

1050 lines
27 KiB
Markdown

# PedsHub API Reference
Base URL: `/api`
All endpoints return JSON unless noted otherwise. Authentication is via Bearer token in the `Authorization` header. Role hierarchy: **admin > moderator > user**.
---
## Auth (`/api/auth`)
### POST `/api/auth/register`
Register a new user account.
- **Auth:** None
- **Rate limit:** Cap verification required if configured
- **Request body:**
```json
{
"email": "string",
"password": "string (min 8 chars)",
"name": "string",
"captcha_token": "string | null"
}
```
- **Response:**
- First user ever: `{"access_token": "...", "token_type": "bearer"}` (auto-admin, auto-verified)
- Subsequent users: `{"requires_verification": true, "message": "..."}`
- **Notes:** Registration can be disabled by admin via settings. First registered user is always admin and auto-verified.
### POST `/api/auth/login`
Authenticate and receive a JWT token.
- **Auth:** None
- **Rate limit:** 10 attempts per IP per 15 minutes (Redis). No captcha on login.
- **Request body:**
```json
{
"email": "string",
"password": "string"
}
```
- **Response:** `{"access_token": "string", "token_type": "bearer"}`
- **Errors:** 401 invalid credentials, 403 email not verified, 429 rate limited
### GET `/api/auth/verify-email`
Verify a user's email address via token link.
- **Auth:** None
- **Query params:** `token` (string, required)
- **Response:** `{"message": "Email verified successfully! You can now log in."}`
- **Errors:** 400 invalid/expired token
### POST `/api/auth/resend-verification`
Resend the email verification link.
- **Auth:** None
- **Request body:** `{"email": "string"}`
- **Response:** `{"message": "If that email exists, a verification link has been sent."}`
- **Notes:** Deliberately vague response to prevent email enumeration.
### POST `/api/auth/forgot-password`
Request a password reset link.
- **Auth:** None
- **Rate limit:** 3 reset requests per email per hour
- **Request body:** `{"email": "string"}`
- **Response:** `{"message": "If that email is registered, a reset link has been sent."}`
- **Notes:** Deliberately vague response to prevent email enumeration. Sends email in background.
### POST `/api/auth/reset-password`
Reset password using a valid token.
- **Auth:** None
- **Request body:**
```json
{
"token": "string",
"new_password": "string (min 8 chars)"
}
```
- **Response:** `{"message": "Password reset successfully. You can now log in."}`
- **Errors:** 400 invalid/expired/used token
### GET `/api/auth/me`
Get the current authenticated user's profile.
- **Auth:** User
- **Response:** `UserResponse``{id, email, name, role, created_at}`
### PUT `/api/auth/me`
Update current user's profile (name, password).
- **Auth:** User
- **Request body:**
```json
{
"name": "string | null",
"current_password": "string | null (required to change password)",
"new_password": "string | null (min 8 chars)"
}
```
- **Response:** `UserResponse`
### GET `/api/auth/me/settings`
Get user settings stored in Redis (e.g. Nextcloud config).
- **Auth:** User
- **Response:** `{}` or a JSON object of user settings
### PUT `/api/auth/me/settings`
Save user settings to Redis.
- **Auth:** User
- **Request body:** Any JSON object
- **Response:** `{"saved": true}`
---
## Documents (`/api/documents`)
### POST `/api/documents/upload`
Upload a PDF document for processing.
- **Auth:** Moderator
- **Content-Type:** `multipart/form-data`
- **Request:** `file` (PDF, max size from `MAX_UPLOAD_SIZE` setting)
- **Response:** `DocumentResponse``{id, user_id, filename, original_filename, status, total_pages, error_message, uploaded_at}`
- **Notes:** Processing dispatched to Celery (falls back to sync). Status starts as `"processing"`.
### GET `/api/documents/`
List all documents. Moderators see all; regular users see only their own.
- **Auth:** User
- **Response:** `DocumentResponse[]`
### GET `/api/documents/{document_id}`
Get a single document by ID.
- **Auth:** User (own documents only; moderators can access any)
- **Response:** `DocumentResponse`
- **Errors:** 404
### GET `/api/documents/{document_id}/status`
Poll document processing status.
- **Auth:** User (own documents only; moderators can access any)
- **Response:** `{id, status, total_pages, error_message}`
### GET `/api/documents/{document_id}/processing-steps`
Get PDF processing progress steps from Redis.
- **Auth:** User (own documents only; moderators can access any)
- **Response:** `{"steps": [{step object}, ...]}`
- **Notes:** Steps are stored in Redis list `pdf:steps:{document_id}`.
### POST `/api/documents/{document_id}/sections`
Create a named page range section within a document.
- **Auth:** Moderator
- **Request body:**
```json
{
"name": "string",
"start_page": "int (>= 1)",
"end_page": "int (> start_page, <= total_pages)"
}
```
- **Response:** `SectionResponse``{id, document_id, name, start_page, end_page}`
- **Errors:** 400 if document not ready, invalid page range
### DELETE `/api/documents/{document_id}/sections/{section_id}`
Delete a section.
- **Auth:** Moderator
- **Response:** 204 No Content
### DELETE `/api/documents/{document_id}`
Delete a document, its file, and its ChromaDB vector collection.
- **Auth:** Moderator
- **Response:** 204 No Content
---
## Quizzes (`/api/quizzes`)
### POST `/api/quizzes/`
Start async quiz extraction from a document section.
- **Auth:** Moderator
- **Request body:**
```json
{
"section_id": "int",
"title": "string",
"mode": "timed | learning",
"time_limit_minutes": "int | null",
"model_id": "int | null (AIModelConfig.id)",
"question_category_id": "int | null",
"extraction_mode": "string | null (standard, questions_only, two_step, regex, ai_decide, generate)"
}
```
- **Response:** `{"job_id": "uuid", "status": "pending"}`
- **Notes:** Poll `/api/quizzes/job/{job_id}` for progress. Falls back to sync if Celery unavailable.
### GET `/api/quizzes/jobs`
List all recent extraction jobs for the current user (any browser session).
- **Auth:** User
- **Response:**
```json
[
{
"job_id": "uuid",
"title": "string",
"status": "pending | running | completed | failed | cancelled",
"steps_count": "int",
"last_step": "string (truncated to 80 chars)",
"quiz_id": "int | null"
}
]
```
### GET `/api/quizzes/job/{job_id}`
Poll extraction job progress.
- **Auth:** Moderator
- **Response:** `{"job_id", "status", "steps": [...], "quiz_id" (if completed), "error" (if failed)}`
### POST `/api/quizzes/job/{job_id}/cancel`
Cancel a running extraction job.
- **Auth:** User (must own the job)
- **Response:** `{"status": "cancelled"}`
- **Errors:** 403 if not owner, 400 if not running
### GET `/api/quizzes/search`
Hybrid semantic + keyword search across quiz titles and questions.
- **Auth:** User
- **Query params:**
- `q` (string, required, 2-200 chars)
- `mode` (string: `"title"` | `"questions"` | `"all"`, default `"all"`)
- **Response:** Array of search results with `quiz_id`, `quiz_title`, `match_type`, `matching_questions[]`
- **Notes:** Uses pgvector cosine similarity (threshold 0.30) for semantic search, plus ILIKE for keyword matching.
### GET `/api/quizzes/`
List all quizzes. Moderators see all (non-deleted); regular users see only published.
- **Auth:** User
- **Response:** `QuizResponse[]``{id, section_id, user_id, title, questions_count, mode, time_limit_minutes, is_published, created_at}`
### GET `/api/quizzes/{quiz_id}`
Get a quiz for taking. Learning-mode quizzes include answers/explanations.
- **Auth:** User
- **Query params:** `study` (bool, default false) — forces answers/explanations to be included
- **Response:** `QuizDetail` (timed) or `QuizLearningDetail` (learning/study)
- **Notes:** Excludes soft-deleted quizzes.
### PATCH `/api/quizzes/{quiz_id}`
Update quiz metadata (title).
- **Auth:** Moderator
- **Request body:** `{"title": "string"}`
- **Response:** `QuizResponse`
### POST `/api/quizzes/{quiz_id}/shuffle`
Return quiz with shuffled question and option order (does not modify DB).
- **Auth:** User
- **Query params:** `shuffle_options` (bool, default true)
- **Response:** `QuizDetail` with shuffled questions
### GET `/api/quizzes/{quiz_id}/review`
Get quiz with full answers. Requires a completed attempt (or moderator role).
- **Auth:** User (must have completed attempt) or Moderator
- **Response:** `QuizReview`
### GET `/api/quizzes/{quiz_id}/questions`
Get all questions with answers for editing.
- **Auth:** Moderator
- **Response:** Array of `{id, question_text, question_type, options, correct_answer, explanation}`
### PATCH `/api/quizzes/{quiz_id}/questions/{question_id}`
Update a question. Changes are shared across all quizzes referencing this question.
- **Auth:** Moderator
- **Request body:** Any of `{question_text, options, correct_answer, explanation, question_type}`
- **Response:** Updated question object
- **Errors:** 400 if correct_answer does not match one of the options
### DELETE `/api/quizzes/{quiz_id}/questions/{question_id}`
Remove a question from this quiz. If shared with other quizzes, only the link is removed. If exclusive, the question is deleted from the bank.
- **Auth:** Moderator
- **Response:** 204 No Content
### PATCH `/api/quizzes/{quiz_id}/publish`
Show or hide a quiz for regular users.
- **Auth:** Moderator
- **Query params:** `published` (bool, default true)
- **Response:** `{"quiz_id": int, "is_published": int}`
### GET `/api/quizzes/trash`
List soft-deleted quizzes.
- **Auth:** Moderator
- **Response:** `QuizResponse[]`
### DELETE `/api/quizzes/{quiz_id}`
Soft-delete a quiz (move to trash).
- **Auth:** Moderator
- **Response:** 204 No Content
### PATCH `/api/quizzes/{quiz_id}/restore`
Restore a soft-deleted quiz from trash.
- **Auth:** Moderator
- **Response:** `QuizResponse`
### DELETE `/api/quizzes/{quiz_id}/permanent`
Permanently delete a quiz that is already in trash. Exclusive questions are orphaned to the bank.
- **Auth:** Moderator
- **Response:** 204 No Content
- **Errors:** 400 if quiz is not in trash
---
## Questions (`/api/questions`)
### GET `/api/questions/bank`
List all questions across all quizzes with filtering, search, and pagination.
- **Auth:** User
- **Query params:**
- `q` (string, optional) — keyword/semantic search
- `quiz_id` (int, optional) — filter by source quiz
- `category_id` (int, optional) — filter by question category
- `category_ids` (string, optional) — comma-separated category IDs (OR filter)
- `uncategorized` (bool, default false)
- `favorites_only` (bool, default false)
- `tag_ids` (string, optional) — comma-separated tag IDs (AND filter)
- `search_mode` (string: `"keyword"` | `"semantic"` | `"hybrid"`, default `"hybrid"`)
- `limit` (int, default 50, max 200)
- `offset` (int, default 0)
- **Response:**
```json
{
"total": "int",
"questions": [
{
"id": "int",
"quiz_id": "int",
"quiz_title": "string",
"question_category_id": "int | null",
"question_category_name": "string | null",
"question_text": "string",
"question_type": "string",
"options": ["string"],
"correct_answer": "string",
"explanation": "string",
"image_path": "string | null"
}
]
}
```
- **Notes:** Hybrid search returns semantic matches first (pgvector cosine similarity >= 0.55), then keyword matches.
### GET `/api/questions/bank/ids`
Return just question IDs for all matching questions (for server-side select-all).
- **Auth:** User
- **Query params:** Same filters as `/bank` (except `limit`, `offset`, `search_mode`)
- **Response:** `[int]`
### PATCH `/api/questions/{question_id}/category`
Assign or remove a question category.
- **Auth:** Moderator
- **Query params:** `category_id` (int | null)
- **Response:** `{"question_id": int, "question_category_id": int | null}`
### POST `/api/questions/from-bank`
Create a new quiz by referencing existing bank questions (no copying -- edits propagate).
- **Auth:** Moderator
- **Request body:**
```json
{
"title": "string",
"question_ids": [1, 2, 3],
"mode": "timed | learning",
"time_limit_minutes": "int | null"
}
```
- **Response:** `{"id": int, "title": "string", "questions_count": int}`
### POST `/api/questions/bulk-category`
Assign or remove a category from multiple questions at once.
- **Auth:** Moderator
- **Request body:**
```json
{
"question_ids": [1, 2, 3],
"category_id": "int | null"
}
```
- **Response:** `{"updated": int, "question_category_id": int | null}`
---
## Attempts (`/api/attempts`)
### POST `/api/attempts/start`
Start a new quiz attempt (or resume the most recent incomplete one).
- **Auth:** User
- **Query params:**
- `quiz_id` (int, required)
- `fresh` (bool, default false) — force a new attempt even if one is in progress
- **Response:** `AttemptResponse``{id, quiz_id, score, total_questions, percentage, started_at, completed_at}`
### POST `/api/attempts/{attempt_id}/submit`
Submit answers for a quiz attempt.
- **Auth:** User (must own the attempt)
- **Request body:**
```json
{
"answers": [
{"question_id": "int", "user_answer": "string"}
]
}
```
- **Response:** `AttemptDetail` — includes `answers[]` with full review (question text, options, user answer, correct answer, is_correct, explanation)
- **Notes:** Updates spaced repetition reminder schedule. Clears saved progress from Redis. Unanswered questions are marked incorrect.
### GET `/api/attempts/`
List all attempts for the current user.
- **Auth:** User
- **Query params:** `quiz_id` (int, optional)
- **Response:** `AttemptResponse[]`
### GET `/api/attempts/{attempt_id}`
Get a specific attempt with full answer details.
- **Auth:** User (must own the attempt)
- **Response:** `AttemptDetail`
### GET `/api/attempts/stats/dashboard`
Get dashboard statistics for the current user.
- **Auth:** User
- **Response:**
```json
{
"total_documents": "int",
"total_quizzes": "int (distinct quizzes attempted)",
"total_attempts": "int",
"average_score": "float",
"quiz_stats": [
{
"quiz_id": "int",
"quiz_title": "string",
"attempts_count": "int",
"best_score": "float",
"latest_score": "float",
"average_score": "float"
}
]
}
```
### GET `/api/attempts/history`
Get per-quiz attempt history for the performance line graph.
- **Auth:** User
- **Response:**
```json
[
{
"quiz_id": "int",
"title": "string",
"attempts": [
{"attempt_id": "int", "date": "ISO string", "percentage": "float", "score": "int", "total": "int"}
]
}
]
```
### GET `/api/attempts/in-progress`
Return all incomplete (not yet submitted) attempts for the current user.
- **Auth:** User
- **Response:**
```json
[
{
"attempt_id": "int",
"quiz_id": "int",
"quiz_title": "string",
"total_questions": "int",
"started_at": "ISO string"
}
]
```
### GET `/api/attempts/{attempt_id}/in-progress`
Return the latest incomplete attempt for a specific quiz.
- **Auth:** User
- **Query params:** `quiz_id` (int, required)
- **Response:** `AttemptResponse | null`
### POST `/api/attempts/progress`
Save in-progress quiz answers to Redis (survives logout/browser change).
- **Auth:** User
- **Headers:** `x-quiz-session` (optional, for device session lock)
- **Request body:**
```json
{
"quiz_id": "int",
"attempt_id": "int",
"answers": {"question_id": "answer"},
"current_idx": "int",
"mode": "string",
"voice": "string | null",
"time_left": "int | null",
"started_at": "string | null (ISO timestamp)",
"total_time": "int | null (seconds)"
}
```
- **Response:** `{"saved": true}`
- **Notes:** Progress expires after 7 days. Session lock (30s TTL) prevents concurrent resume on another device.
### GET `/api/attempts/progress`
Retrieve saved progress for a quiz. Auto-submits timed quizzes if timer expired.
- **Auth:** User
- **Headers:** `x-quiz-session` (optional, for device session lock)
- **Query params:** `quiz_id` (int, required)
- **Response:** Saved progress object or `null`
- **Errors:** 409 if another device is actively using this attempt
### DELETE `/api/attempts/progress/{attempt_id}`
Clear saved progress.
- **Auth:** User
- **Response:** 204 No Content
### DELETE `/api/attempts/{attempt_id}`
Delete an attempt, its answers, and associated reminder schedule.
- **Auth:** User (must own the attempt)
- **Response:** 204 No Content
---
## Tags (`/api/tags`)
### GET `/api/tags`
Get all tags grouped by type (subjects, diseases, keywords), with question counts.
- **Auth:** User
- **Response:**
```json
{
"subjects": [{"id": "int", "name": "string", "count": "int"}],
"diseases": [{"id": "int", "name": "string", "count": "int"}],
"keywords": [{"id": "int", "name": "string", "count": "int"}]
}
```
### POST `/api/tags/classify`
Trigger background AI classification of untagged questions.
- **Auth:** Moderator
- **Response:** `{"job_id": "uuid", "status": "started"}`
### GET `/api/tags/status/{job_id}`
Check classification job progress.
- **Auth:** User
- **Response:** `{"job_id": "string", "status": "string", "steps": [...], "error": "string | null"}`
---
## Teach (`/api/teach`)
### GET `/api/teach/models`
Return available teach AI models.
- **Auth:** User
- **Response:** `[{"id": int, "name": "string", "model_id": "string", "is_default": bool}]`
### POST `/api/teach/chat`
Send a message to the AI tutor with full question context.
- **Auth:** User
- **Rate limit:** 30 messages per user per 10 minutes (admins and unthrottled users exempt)
- **Request body:**
```json
{
"question_id": "int",
"messages": [
{"role": "user | assistant", "content": "string"}
],
"model_id": "int | null (AIModelConfig.id)"
}
```
- **Response:** `{"reply": "string (markdown)", "suggestions": ["string", "string", "string"]}`
- **Notes:** System prompt includes the question, correct answer, explanation, and up to 4 semantically similar questions for broader context. Follow-up suggestions are parsed from lines starting with `> `.
- **Errors:** 503 if no teach model configured, 502 if AI temporarily unavailable
---
## TTS (`/api/tts`)
### GET `/api/tts/voices`
Return available TTS voices. Excludes Polly voices if Polly is disabled.
- **Auth:** User
- **Response:** `[{"id": "string (model_id)", "name": "string", "is_default": bool}]`
### POST `/api/tts/speak`
Convert text to speech audio.
- **Auth:** User
- **Rate limit:** 60 requests per user per hour (admins and unthrottled users exempt)
- **Request body:**
```json
{
"text": "string (max 2000 chars)",
"voice": "string | null (model_id override)"
}
```
- **Response:** Binary `audio/mpeg` data
- **Notes:** Supports LiteLLM-routed local TTS voices, e.g. `local-kokoro-tts:am_adam`. The prefix before `:` is the LiteLLM model route; the suffix is the speaker voice sent to that route.
---
## Favorites (`/api/favorites`)
### POST `/api/favorites`
Add a question to the current user's favorites.
- **Auth:** User
- **Request body:** `{"question_id": "int"}`
- **Response (201):** `FavoriteResponse``{id, user_id, question_id, created_at}`
- **Notes:** Idempotent -- returns existing favorite if already favorited.
### DELETE `/api/favorites/{question_id}`
Remove a question from favorites.
- **Auth:** User
- **Response:** 204 No Content
### GET `/api/favorites`
Get list of favorited question IDs for the current user.
- **Auth:** User
- **Response:** `[int]`
---
## Categories (`/api/categories`)
Quiz-level categories for organizing quizzes.
### GET `/api/categories/`
List all quiz categories with quiz counts.
- **Auth:** User
- **Response:** `[{"id": int, "name": "string", "quiz_count": int}]`
### POST `/api/categories/`
Create a new quiz category.
- **Auth:** Moderator
- **Request body:** `{"name": "string"}`
- **Response:** `CategoryResponse`
### DELETE `/api/categories/{category_id}`
Delete a quiz category. Unassigns all quizzes in the category.
- **Auth:** Moderator
- **Response:** 204 No Content
### PATCH `/api/categories/quizzes/{quiz_id}`
Assign or remove a category from a quiz.
- **Auth:** Moderator
- **Query params:** `category_id` (int | null)
- **Response:** `{"quiz_id": int, "category_id": int | null}`
---
## Question Categories (`/api/question-categories`)
Question-level categories for organizing the question bank.
### GET `/api/question-categories/`
List all question categories with question counts.
- **Auth:** User
- **Response:** `[{"id": int, "name": "string", "description": "string | null", "question_count": int}]`
### POST `/api/question-categories/`
Create a new question category.
- **Auth:** Moderator
- **Request body:** `{"name": "string", "description": "string | null"}`
- **Response:** `QCatResponse`
### PATCH `/api/question-categories/{cat_id}`
Update a question category.
- **Auth:** Moderator
- **Request body:** `{"name": "string", "description": "string | null"}`
- **Response:** `QCatResponse`
### DELETE `/api/question-categories/{cat_id}`
Delete a question category. Optionally move its questions to another category.
- **Auth:** Moderator
- **Query params:** `move_to` (int | null) -- target category ID, or null to uncategorize
- **Response:** 204 No Content
### POST `/api/question-categories/{cat_id}/create-quiz`
Create a new quiz from all questions in a question category.
- **Auth:** Moderator
- **Query params:**
- `title` (string, required)
- `mode` (string: `"timed"` | `"learning"`, default `"timed"`)
- `time_limit_minutes` (int | null)
- **Response:** `{"id": int, "title": "string", "questions_count": int}`
---
## Contact (`/api/contact`)
### POST `/api/contact`
Submit a contact form (public, no auth).
- **Auth:** None
- **Request body:**
```json
{
"name": "string (max 120)",
"email": "valid email",
"type": "question | moderator",
"message": "string (max 2000)",
"captcha_token": "string | null"
}
```
- **Response:** `{"success": true}`
- **Notes:** Stores submission in DB and emails admin. Cap verification if configured.
### GET `/api/contact/submissions`
List all contact submissions.
- **Auth:** Admin (enforced in endpoint body, not decorator)
- **Response:** `[{id, name, email, type, message, read, created_at}]`
### PUT `/api/contact/submissions/{submission_id}/read`
Mark a contact submission as read.
- **Auth:** Admin (enforced in endpoint body, not decorator)
- **Response:** `{"success": true}`
---
## Admin (`/api/admin`)
### User Management
#### GET `/api/admin/users`
List all users.
- **Auth:** Admin
- **Response:** `UserResponse[]`
#### POST `/api/admin/users`
Create a user directly (email auto-verified).
- **Auth:** Admin
- **Request body:** `{"email": "string", "password": "string", "name": "string"}`
- **Response:** `UserResponse`
#### PUT `/api/admin/users/{user_id}/role`
Change a user's role.
- **Auth:** Admin
- **Request body:** `{"role": "admin | moderator | user"}`
- **Response:** `UserResponse`
- **Errors:** 400 cannot change own role
#### PUT `/api/admin/users/{user_id}/unthrottle`
Set or clear the unthrottle flag (exempt from AI/TTS rate limits).
- **Auth:** Admin
- **Request body:** `{"unthrottled": true | false}`
- **Response:** `UserResponse`
#### DELETE `/api/admin/users/{user_id}`
Delete a user and all their data.
- **Auth:** Admin
- **Response:** 204 No Content
- **Errors:** 400 cannot delete yourself
### AI Model Configuration
#### GET `/api/admin/models/available`
List active models for a task (for users when creating/taking quizzes).
- **Auth:** User
- **Query params:** `task` (string, default `"extraction"`)
- **Response:** `[{"id": int | null, "name": "string", "model_id": "string", "is_default": bool}]`
- **Notes:** Falls back to env `LITELLM_MODEL` if nothing configured.
#### GET `/api/admin/models`
List all AI model configurations.
- **Auth:** Admin
- **Response:** `AIModelConfigResponse[]` -- `{id, name, model_id, task, is_active, is_default, api_key (masked)}`
#### POST `/api/admin/models`
Create an AI model configuration.
- **Auth:** Admin
- **Request body:**
```json
{
"name": "string",
"model_id": "string",
"task": "extraction | tts | teach | keyword",
"is_active": "bool",
"is_default": "bool",
"api_key": "string | null"
}
```
- **Response:** `AIModelConfigResponse`
- **Notes:** Auto-sets as default if first model for that task. Unique constraint on (model_id, task).
#### PUT `/api/admin/models/{model_id}`
Update an AI model configuration.
- **Auth:** Admin
- **Request body:** Partial `AIModelConfigUpdate`
- **Response:** `AIModelConfigResponse`
#### DELETE `/api/admin/models/{model_id}`
Delete an AI model configuration.
- **Auth:** Admin
- **Response:** 204 No Content
#### POST `/api/admin/models/{model_id}/test`
Send a test completion to verify an LLM model is reachable.
- **Auth:** Admin
- **Response:** `{"message": "model_id -> 'OK'"}`
- **Errors:** 400 for TTS models (use Preview button instead), 502 if unreachable
#### POST `/api/admin/litellm/models`
Query available models from LiteLLM proxy or OpenAI-compatible API.
- **Auth:** Admin
- **Request body:** `{"api_key": "string | null", "api_base": "string | null"}`
- **Response:** `{"models": ["string"], "source": "string"}`
### TTS Voice Search
#### POST `/api/admin/tts/voices`
Discover available TTS voices from a provider.
- **Auth:** Admin
- **Request body:**
```json
{
"provider": "elevenlabs | polly | openai",
"api_key": "string | null",
"region": "string | null"
}
```
- **Response:** `{"voices": [{"model_id": "string", "name": "string", "labels": {}}]}`
- **Notes:** OpenAI returns hardcoded list of tts-1 and tts-1-hd voices.
### System Settings
#### GET `/api/admin/settings`
Get system settings (registration enabled, embedding model, Polly enabled).
- **Auth:** Admin
- **Response:** `{"registration_enabled": bool, "embedding_model": "string"}`
#### PUT `/api/admin/settings`
Update system settings.
- **Auth:** Admin
- **Request body:** `{"registration_enabled": bool, "embedding_model": "string"}`
- **Response:** `{"success": true, "message": "Settings updated"}`
### Embedding Management
#### POST `/api/admin/embedding/test`
Test the currently configured embedding model.
- **Auth:** Admin
- **Response:** `{"model": "string", "dimensions": int, "status": "ok"}`
#### POST `/api/admin/embedding/regenerate`
Queue a Celery task to regenerate all question embeddings.
- **Auth:** Admin
- **Response:** `{"job_id": "uuid", "message": "Regeneration started..."}`
---
## Nextcloud (`/api/nextcloud`)
WebDAV proxy for importing PDFs from Nextcloud (avoids browser CORS issues).
### POST `/api/nextcloud/test`
Test Nextcloud credentials.
- **Auth:** Moderator
- **Request body:** `{"server": "string", "username": "string", "password": "string", "path": "/"}`
- **Response:** `{"ok": true, "message": "Connected successfully"}`
### POST `/api/nextcloud/files`
List PDFs and folders at a given Nextcloud path.
- **Auth:** Moderator
- **Request body:** `{"server": "string", "username": "string", "password": "string", "path": "/"}`
- **Response:** `{"path": "string", "items": [{"name", "path", "type": "dir | pdf", "size"}]}`
### POST `/api/nextcloud/download`
Download a PDF from Nextcloud and stream it back.
- **Auth:** Moderator
- **Request body:** `{"server": "string", "username": "string", "password": "string", "path": "/some/file.pdf"}`
- **Response:** Streamed `application/pdf` binary
- **Errors:** 400 if not a PDF file