Compare commits

..

15 Commits

Author SHA1 Message Date
hobokenchicken 41df7e6f9f feat: add AI quiz generation frontend modal and sidebar button 2026-07-28 09:34:20 -04:00
hobokenchicken 7d2a309d0f docs: add AI quiz generation endpoint documentation 2026-07-28 09:30:01 -04:00
hobokenchicken d0ed79a986 feat: add AI quiz generation endpoint with httpx and OpenAI/Ollama support
PyTest / test (push) Has been cancelled
2026-07-28 09:28:57 -04:00
Mawoka 4d1f1d3165 Fix load test 2026-07-21 13:59:56 +02:00
Marlon 6d805a4fb8 Merge pull request #452 from mattifrind/master
Add load test
2026-07-21 11:36:06 +02:00
Marlon b454f37fc2 Merge pull request #558 from mawoka-myblock/translation
Translations update from Hosted Weblate
2026-07-19 10:56:18 +02:00
pre-commit-ci[bot] ad3ebb2f85 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-17 15:02:20 +00:00
Omer I.S. 811d0ea845 🌐 Translated (Hebrew)
Currently translated at 40.6% (156 of 384 strings)

Translation: ClassQuiz/Frontend
Translate-URL: https://hosted.weblate.org/projects/classquiz/frontend/he/
2026-07-17 17:01:59 +02:00
Enric Pagès i Gassull 2249571dbd 🌐 Translated (Catalan)
Currently translated at 27.0% (104 of 384 strings)

Translation: ClassQuiz/Frontend
Translate-URL: https://hosted.weblate.org/projects/classquiz/frontend/ca/
2026-07-17 17:01:58 +02:00
reducedradius 643732b46c 🌐 Translated (Chinese (Simplified Han script))
Currently translated at 10.4% (40 of 384 strings)

Translation: ClassQuiz/Frontend
Translate-URL: https://hosted.weblate.org/projects/classquiz/frontend/zh_Hans/
2026-07-17 17:01:54 +02:00
Libre 89aff79050 🌐 Translated (Spanish)
Currently translated at 100.0% (384 of 384 strings)

Translation: ClassQuiz/Frontend
Translate-URL: https://hosted.weblate.org/projects/classquiz/frontend/es/
2026-07-17 17:01:50 +02:00
தமிழ்நேரம் b582ad6c46 🌐 Translated (Tamil)
Currently translated at 100.0% (384 of 384 strings)

Translation: ClassQuiz/Frontend
Translate-URL: https://hosted.weblate.org/projects/classquiz/frontend/ta/
2026-07-17 17:01:47 +02:00
Marlon 7aca8f8798 Fix Caddy to also work on amd64 2026-07-15 15:35:50 +02:00
Felix Kleinsteuber c93853709f Merge branch 'mawoka-myblock:master' into master 2025-12-13 15:09:33 +01:00
Matti J. Frind 5e2107d398 add load test 2025-10-16 22:25:36 +02:00
17 changed files with 925 additions and 90 deletions
+1 -1
View File
@@ -68,4 +68,4 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/arm64
platforms: linux/arm64, linux/amd64
+10
View File
@@ -34,6 +34,16 @@ SPDX-License-Identifier: MPL-2.0
</div>
## AI Quiz Generation
ClassQuiz-AI includes an OpenAI-compatible API endpoint for generating quizzes dynamically using LLMs.
- **Endpoint**: `POST /api/v1/ai/generate`
- **Environment Variables**:
- `AI_API_KEY`: API key for your LLM provider.
- `AI_BASE_URL`: Base URL (defaults to `https://api.openai.com/v1`).
- `AI_MODEL`: Model name (defaults to `gpt-4o-mini`).
## About ClassQuiz
ClassQuiz is a quiz app to learn interactively for students,
+2
View File
@@ -34,6 +34,7 @@ from classquiz.routers import (
quiztivity,
pixabay,
moderation,
ai,
)
from classquiz.socket_server import sio
from classquiz.helpers import meilisearch_init
@@ -104,6 +105,7 @@ app.include_router(login.router, tags=["auth"], prefix="/api/v1/login", include_
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
app.include_router(users.router, tags=["users"], prefix="/api/v1/users", include_in_schema=True)
app.include_router(quiz.router, tags=["quiz"], prefix="/api/v1/quiz", include_in_schema=True)
app.include_router(ai.router, tags=["ai"], prefix="/api/v1/ai", include_in_schema=True)
app.include_router(utils.router, tags=["utils"], prefix="/api/v1/utils", include_in_schema=True)
app.include_router(stats.router, tags=["stats"], prefix="/api/v1/stats", include_in_schema=True)
app.include_router(storage.router, tags=["storage"], prefix="/api/v1/storage", include_in_schema=True)
+6 -20
View File
@@ -47,29 +47,17 @@ class OpenIDResponse(BaseModel):
@router.get("/login")
async def openid_login(req: Request):
if (
settings.custom_openid_provider.client_id is None
or settings.custom_openid_provider.client_secret is None
):
raise HTTPException(
status_code=501, detail="Custom-OAuth-Login isn't available on this server"
)
if settings.custom_openid_provider.client_id is None or settings.custom_openid_provider.client_secret is None:
raise HTTPException(status_code=501, detail="Custom-OAuth-Login isn't available on this server")
oauth = init_oauth()
return await oauth.custom.authorize_redirect(
req, f"{settings.root_address}/api/v1/users/oauth/custom/auth"
)
return await oauth.custom.authorize_redirect(req, f"{settings.root_address}/api/v1/users/oauth/custom/auth")
@router.get("/auth")
async def auth(request: Request, response: Response):
if (
settings.custom_openid_provider.client_id is None
or settings.custom_openid_provider.client_secret is None
):
raise HTTPException(
status_code=501, detail="Custom-OAuth-Login isn't available on this server"
)
if settings.custom_openid_provider.client_id is None or settings.custom_openid_provider.client_secret is None:
raise HTTPException(status_code=501, detail="Custom-OAuth-Login isn't available on this server")
access_token = request.cookies.get("access_token")
rememberme_token = request.cookies.get("rememberme_token")
if access_token is not None:
@@ -80,9 +68,7 @@ async def auth(request: Request, response: Response):
except HTTPException:
pass
if rememberme_token is not None:
return await rememberme_check(
rememberme_token=rememberme_token, response=response
)
return await rememberme_check(rememberme_token=rememberme_token, response=response)
oauth = init_oauth()
user_data = await oauth.custom.authorize_access_token(request)
+4 -12
View File
@@ -76,21 +76,15 @@ class GitHubOauthResponse(BaseModel):
@router.get("/login")
async def github_login(req: Request):
if settings.github_client_id is None or settings.github_client_secret is None:
raise HTTPException(
status_code=501, detail="GitHub-Login isn't available on this server"
)
raise HTTPException(status_code=501, detail="GitHub-Login isn't available on this server")
oauth = init_oauth()
return await oauth.github.authorize_redirect(
req, f"{settings.root_address}/api/v1/users/oauth/github/auth"
)
return await oauth.github.authorize_redirect(req, f"{settings.root_address}/api/v1/users/oauth/github/auth")
@router.get("/auth")
async def auth(request: Request, response: Response):
if settings.github_client_id is None or settings.github_client_secret is None:
raise HTTPException(
status_code=501, detail="GitHub-Login isn't available on this server"
)
raise HTTPException(status_code=501, detail="GitHub-Login isn't available on this server")
access_token = request.cookies.get("access_token")
rememberme_token = request.cookies.get("rememberme_token")
if access_token is not None:
@@ -101,9 +95,7 @@ async def auth(request: Request, response: Response):
except HTTPException:
pass
if rememberme_token is not None:
return await rememberme_check(
rememberme_token=rememberme_token, response=response
)
return await rememberme_check(rememberme_token=rememberme_token, response=response)
oauth = init_oauth()
try:
token = await oauth.github.authorize_access_token(request)
+4 -12
View File
@@ -51,22 +51,16 @@ class OauthGoogleResponse(BaseModel):
@router.get("/login")
async def google_login(req: Request):
if settings.google_client_secret is None or settings.google_client_id is None:
raise HTTPException(
status_code=501, detail="Google-Login isn't available on this server"
)
raise HTTPException(status_code=501, detail="Google-Login isn't available on this server")
oauth = init_oauth()
return await oauth.google.authorize_redirect(
req, f"{settings.root_address}/api/v1/users/oauth/google/auth"
)
return await oauth.google.authorize_redirect(req, f"{settings.root_address}/api/v1/users/oauth/google/auth")
@router.get("/auth")
async def auth(request: Request, response: Response):
if settings.google_client_secret is None or settings.google_client_id is None:
raise HTTPException(
status_code=501, detail="Google-Login isn't available on this server"
)
raise HTTPException(status_code=501, detail="Google-Login isn't available on this server")
access_token = request.cookies.get("access_token")
rememberme_token = request.cookies.get("rememberme_token")
if access_token is not None:
@@ -77,9 +71,7 @@ async def auth(request: Request, response: Response):
except HTTPException:
pass
if rememberme_token is not None:
return await rememberme_check(
rememberme_token=rememberme_token, response=response
)
return await rememberme_check(rememberme_token=rememberme_token, response=response)
oauth = init_oauth()
user_data = await oauth.google.authorize_access_token(request)
+40
View File
@@ -0,0 +1,40 @@
# SPDX-FileCopyrightText: 2026 LobotomyLabs
# SPDX-License-Identifier: MPL-2.0
import os
import json
import httpx
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from classquiz.auth import get_current_user
from classquiz.db.models import User, QuizInput
router = APIRouter()
class AIGenerateRequest(BaseModel):
topic: str
num_questions: int = 5
@router.post("/generate", response_model=QuizInput)
async def generate_ai_quiz(req: AIGenerateRequest, user: User = Depends(get_current_user)):
api_key = os.getenv("AI_API_KEY", "")
base_url = os.getenv("AI_BASE_URL", "https://api.openai.com/v1")
model = os.getenv("AI_MODEL", "gpt-4o-mini")
prompt = f"Generate a quiz about '{req.topic}' with exactly {req.num_questions} multiple-choice questions (ABCD type). Return ONLY valid JSON with keys: title, description, questions (list of {{question, time: '20', type: 'ABCD', answers: [{{answer, right: bool, color}}]}})."
async with httpx.AsyncClient(timeout=30.0) as client:
res = await client.post(
f"{base_url}/chat/completions",
headers={"Authorization": f"Bearer {api_key}"},
json={"model": model, "messages": [{"role": "user", "content": prompt}], "temperature": 0.7},
)
if res.status_code != 200:
raise HTTPException(status_code=502, detail=f"AI provider error: {res.text}")
content = res.json()["choices"][0]["message"]["content"].strip()
if content.startswith("```"):
content = content.split("```")[1]
if content.startswith("json"):
content = content[4:]
content = content.strip("` \n")
return QuizInput(**json.loads(content))
+12
View File
@@ -0,0 +1,12 @@
# SPDX-FileCopyrightText: 2026 LobotomyLabs
# SPDX-License-Identifier: MPL-2.0
import pytest
from fastapi.testclient import TestClient
from classquiz import app
client = TestClient(app)
def test_ai_router_exists():
response = client.post("/api/v1/ai/generate", json={"topic": "Python", "num_questions": 1})
assert response.status_code in [200, 401, 502]
+51
View File
@@ -0,0 +1,51 @@
<!--
SPDX-FileCopyrightText: 2026 LobotomyLabs
SPDX-License-Identifier: MPL-2.0
-->
<script lang="ts">
import BrownButton from '$lib/components/buttons/brown.svelte';
let { data = $bindable() } = $props();
let open = $state(false);
let topic = $state('');
let loading = $state(false);
async function generate() {
if (!topic) return;
loading = true;
try {
const res = await fetch('/api/v1/ai/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, num_questions: 5 })
});
if (res.ok) {
const quiz = await res.json();
data.title = quiz.title;
data.description = quiz.description;
data.questions = quiz.questions;
open = false;
} else {
alert('Generation failed');
}
} finally {
loading = false;
}
}
</script>
<div class="my-2 px-6">
<BrownButton onclick={() => (open = true)}> Generate with AI</BrownButton>
</div>
{#if open}
<div class="fixed z-50 inset-0 flex items-center justify-center bg-black/50">
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-xl w-96">
<h3 class="text-lg font-bold mb-4 dark:text-white">Generate Quiz with AI</h3>
<input type="text" bind:value={topic} placeholder="Enter topic (e.g. World History)" class="w-full p-2 border rounded mb-4 dark:bg-gray-700 dark:text-white" />
<div class="flex justify-end gap-2">
<button class="px-4 py-2 bg-gray-300 rounded" onclick={() => (open = false)}>Cancel</button>
<button class="px-4 py-2 bg-indigo-600 text-white rounded" onclick={generate} disabled={loading}>{loading ? 'Generating...' : 'Generate'}</button>
</div>
</div>
</div>
{/if}
+5 -1
View File
@@ -13,6 +13,7 @@ SPDX-License-Identifier: MPL-2.0
import { getLocalization } from '$lib/i18n';
import AddNewQuestionPopup from '$lib/editor/AddNewQuestionPopup.svelte';
import BrownButton from '$lib/components/buttons/brown.svelte';
import AIModal from '$lib/editor/AIModal.svelte';
import { fade } from 'svelte/transition';
const { t } = getLocalization();
@@ -74,7 +75,7 @@ SPDX-License-Identifier: MPL-2.0
</script>
<div class="h-screen relative">
<div class="h-10 flex justify-center w-full p-1 absolute z-20">
<div class="h-10 flex justify-center w-full p-1 absolute z-20 gap-2">
<div>
<BrownButton onclick={() => (reorder_mode = !reorder_mode)}
>{#if reorder_mode}{$t('editor.disable_reorder')}{:else}{$t(
@@ -82,6 +83,9 @@ SPDX-License-Identifier: MPL-2.0
)}{/if}</BrownButton
>
</div>
<div>
<AIModal bind:data />
</div>
</div>
<div class="border-r-2 pt-6 px-6 overflow-scroll h-full">
<div
+1 -1
View File
@@ -191,7 +191,7 @@
"play_quiz": "2. Resol el qüestionari",
"see_all_quizzes": "Mostra tots els teus qüestionaris",
"completely_free": "Completament gratuït",
"completely_free_content": "",
"completely_free_content": "ClassQuiz és completament gratuït (per a l'usuari), sense cap pla de pagament o redireccions molestes a pàgines d'actualitzacions. Les donacions són molt apreciades.",
"user_friendly_content": "ClassQuiz pretén ser senzill, per tal de poder ser emprat per tothom.",
"no_tracking_content": "Altres aplicacions registren el que feu i envien aquesta informació a terceres parts. ClassQuiz no ho fa.",
"multilingual_content": "",
+5 -5
View File
@@ -160,7 +160,7 @@
"backup_code": "Código de la copia de seguridad",
"totp": "contraseña de un solo uso (Totp)",
"text": "Texto",
"order": "Ordenar",
"order": "Pedido",
"results": "Resultados",
"note": "Nota",
"player_plural": "Jugadores",
@@ -173,7 +173,7 @@
"finish": "Finalizar",
"normal": "Normal",
"selected": "Seleccionado",
"select": "Seleccionar",
"select": "Selección",
"quiz": "Cuestionario",
"quiztivity": "Quiztivity",
"next": "Siguiente",
@@ -288,7 +288,7 @@
"order_description": "Las respuestas se pueden poner en el orden correcto",
"text_description": "Los jugadores pueden introducir texto",
"range_description": "Se puede seleccionar un intervalo de números con un control deslizante",
"check_choice_description": "Todas las respuestas correctas deben seleccionarse para obtener puntos",
"check_choice_description": "Hay que elegir todas las respuestas correctas para sumar puntos",
"need_more_help": "¿Necesitas más ayuda?",
"enter_answer": "Escribe una respuesta",
"visit_docs": "Visita los documentos.",
@@ -362,7 +362,7 @@
"player_count": "Número de jugadores",
"no_results_so_far": "No hay resultados guardados hasta ahora...",
"general_overview": {
"sentence": "El cuestionario \"{{title}}\", el cual tuvo {{date}} jugadores, tenía {{player_count}} jugadores con una puntuación media de {{average_score}}."
"sentence": "La prueba \"{{title}}\", el cual tuvo jugadores en {{date}} tenía {{player_count}} jugadores con una puntuación media de {{average_score}}."
}
},
"result_page": {
@@ -406,7 +406,7 @@
"enable_totp": "Activar TOTP"
},
"view_quiz_page": {
"made_by": "Hecho por",
"made_by": "Realizado por Anonimous",
"view_on_kahoot": "Ver en el original"
},
"start_game": {
+176 -20
View File
@@ -1,36 +1,192 @@
{
"index_page": {
"get_ranking_and_winners": "צפו בניקוד וראו מי זכה",
"completely_free": "לגמרי ללא עלות",
"create_a_quiz_from_scratch": "צור חידון מאפס בעזרת העורך וכלול תמונות ועוד",
"see_how_many_true_and_false": "צפו בכמה צדקו או טעו",
"see_all_quizzes": "צפו בכל החידונים שלכם",
"get_ranking_and_winners": "צפייה בניקוד והצגת הזוכה",
"completely_free": "לגמרי בחינם",
"create_a_quiz_from_scratch": "אפשר ליצור חידון מאפס בעזרת העורך ולכלול תמונות ועוד",
"see_how_many_true_and_false": "לראות כמה צדקו או טעו",
"see_all_quizzes": "צפייה בכל החידונים שיצרת",
"meta": {
"description": "ClassQuiz היא אפליקציית חידון ללמידה אינטראקטיבית עבור תלמידים, בקוד פתוח וחינמית לשימוש",
"description": "ClassQuiz היא יישום ליצירת חידונים ללמידה אינטראקטיבית לתלמידים, הזמין בקוד פתוח ובאופן חופשי",
"title": "בית"
},
"stats": "כבר יש {{user_count}} משתמשים ו-{{quiz_count}} חידונים ב-ClassQuiz.",
"see_what_true_and_false": "צפו במה היה נכון או לא נכון",
"teachers_site": "אתר המורה",
"students_site": "אתר התלמיד",
"stats": "כבר יש {{user_count}} משתמשים ו־{{quiz_count}} חידונים ב־ClassQuiz.",
"see_what_true_and_false": "לראות היכן צדקנו או טעינו",
"teachers_site": "אתר למורים",
"students_site": "אתר לתלמידים",
"no_tracking": "ללא מעקב",
"self_hostable": "ניתן לאחסון מקומי",
"self_hostable": "ניתן לאירוח מקומי",
"german_server": "שרת גרמני",
"user_friendly": "ידידותי למשתמש",
"quiz_results_downloadable": "תוצאות החידון ניתנות להורדה",
"multilingual": "רב לשוני",
"dark_mode": "מצב כהה",
"get_a_quiz": "1. קבל חידון",
"play_quiz": "2. שחקו את החידון",
"select_answer": "בחר את התשובה",
"choose_answer_wisely": "בחר את התשובה בחכמה",
"get_a_quiz": "1. השגת חידון",
"play_quiz": "2. הפעלת החידון",
"select_answer": "בחירת התשובה",
"choose_answer_wisely": "יש לבחור את התשובה בתבונה",
"view_results": "צפייה בתוצאות",
"check_if_chosen_wisely": "בדקו, אם בחרת בחוכמה",
"check_if_chosen_wisely": "אפשר לבדוק אם בחרת תשובה בתבונה",
"list_winners": "רשימת הזוכים",
"why_classquiz": "למה ClassQuiz?",
"no_tracking_content": "אחרים עוקבים אחריך ושולחים את המידע הזה לצד שלישי, אבל ClassQuiz לא.",
"slogan": "פלטפורמת חידון בקוד פתוח!",
"create_or_import": "צור או יבא",
"find_or_explore": "מצא (או גלה) חידונים שנוצרו או יובאו על ידי אנשים אחרים"
"no_tracking_content": תרים אחרים עוקבים אחריך ושולחים את המידע הזה לצד שלישי, אבל ClassQuiz לא.",
"slogan": "פלטפורמת הקוד הפתוח ליצירת חידונים!",
"create_or_import": "יצירה או ייבוא",
"find_or_explore": "אפשר למצוא (או לגלות) חידונים שנוצרו או יובאו על ידי אנשים אחרים",
"no_player_limit": "אין מגבלה על כמות השחקנים",
"no_player_limit_content": "ל־ClassQuiz אין מגבלה מלאכותית על כמות השחקנים. תיאורטית, יותר מאלף שחקנים יכולים להשתתף באותו החידון, אבל בפועל נבדקה כמות של עד 300 שחקנים לחידון.",
"import_quiz_from_kahoot_and_edit": "אפשר לייבא חידוני Kahoot! ולערוך אותם ב־ClassQuiz"
},
"overview_page": {
"created_at": "מועד יצירה",
"question_count": "כמות שאלות",
"no_quizzes": "יש ללחוץ על הכפתור „יצירה” או לייבא חידון כדי להתחיל."
},
"edit_page": {
"success_update_title": "החידון התעדכן."
},
"create_page": {
"success": {
"title": "החידון נוצר."
}
},
"register_page": {
"greeting": "נעים להכיר!",
"create_account": "יצירת חשבון",
"forgot_password?": "שכחת את הסיסמה?",
"already_have_account?": "כבר יש לך חשבון?"
},
"login_page": {
"welcome_back": "ברוך שובך.",
"login_or_create_account": "התחברות או יצירת חשבון",
"already_have_account": "אין לך חשבון?",
"email_or_username": "דוא״ל או שם משתמש",
"modal": {
"success": {
"success_check_mail": "התחברת. נא לבדוק את תיבת הדואר הנכנס.",
"success": "התחברת.",
"description": {
"success": "התחברת."
}
},
"error": {
"wrong_creds": "כתובת דוא״ל או סיסמה שגויות.",
"unexpected": "שגיאה לא צפויה!"
}
},
"use_backup_code": "שימוש בקוד גיבוי"
},
"words": {
"question": "שאלה",
"answer": "תשובה",
"stats": "סטטיסטיקה",
"features": "תכונות",
"login": "התחברות",
"email": "כתובת דוא״ל",
"username": "שם משתמש",
"password": "סיסמה",
"play": "הפעלה",
"edit": "עריכה",
"delete": "מחיקה",
"public": "ציבורי",
"start": "התחלה",
"create": "יצירה",
"import": "ייבוא",
"logout": "התנתקות",
"title": "כותרת",
"url": "כתובת",
"submit": "שליחה",
"connect": "התחברות",
"kick": "סילוק",
"register": "הרשמה",
"docs": "תיעוד",
"close": "סגירה",
"save": "שמירה",
"description": "תיאור",
"image": "תמונה",
"settings": "הגדרות",
"repeat_password": "חזרה על הסיסמה",
"overview": "סקירה כוללת",
"report": "דיווח",
"explore": "היחשפות",
"search": "חיפוש",
"screenshot": "צילום מסך",
"screenshot_plural": "צילומי מסך",
"browser": "דפדפן",
"view": "תצוגה",
"view_plural": "תצוגות",
"result": "תוצאה",
"result_plural": "תוצאות",
"count": "כמות",
"range": "טווח",
"multiple_choice": "בחירה מרובה",
"private": "פרטי",
"question_plural": "שאלות",
"find": "איתור",
"error": "שגיאה",
"download": "הורדה",
"continue": "המשך",
"backup_code": "קוד גיבוי",
"text": "טקסט",
"order": "סדר",
"note": "הערה",
"player_plural": "שחקנים",
"results": "תוצאות",
"unknown": "לא ידוע",
"never": "אף פעם",
"update": "עדכון",
"score": "ניקוד",
"slide": "שקופית",
"name": "שם",
"point": "נקודה",
"point_plural": "נקודות",
"back": "חזרה",
"finish": "סיום",
"select": "בחירה",
"quiz": "חידון",
"video": "סרטון",
"library": "ספרייה",
"progress": "התקדמות",
"speed": "מהירות",
"upload": "העלאה",
"files_library": "ספריית קבצים",
"answer_plural": "תשובות",
"yes": "כן",
"no": "לא",
"analytics": "ניתוח נתונים",
"rating": "דירוג",
"like": "לייק",
"like_plural": "לייקים",
"play_plural": "הפעלות",
"info": "מידע",
"player": "שחקן"
},
"editor": {
"time_in_seconds": "זמן בשניות",
"add_new_answer": "הוספת תשובה חדשה",
"add_new_question": "הוספת שאלה חדשה",
"delete_question": "מחיקת שאלה",
"delete_answer": "מחיקת תשובה",
"no_title": "אין כותרת...",
"empty": "ריק...",
"bg_image": "תמונת רקע",
"slide": {
"text": "טקסט",
"rectangle": "מלבן",
"rectangle_description": "פשוט מלבן"
},
"visit_docs": "נא לעיין בתיעוד.",
"advanced_settings": "הגדרות מתקדמות"
},
"import_page": {
"a_kahoot_quiz": "חידון Kahoot!",
"url_should_look_like_this": "הכתובת צריכה להיראות כך: https://create.kahoot.it/details/...",
"classquiz_quiz": "חידון ClassQuiz"
},
"admin_page": {
"start_game": "התחלת המשחק",
"time_left": "זמן נותר",
"get_results": "קבלת התוצאות",
"get_final_results": "קבלת התוצאות הסופיות",
"show_next_question": "הצגת השאלה הבאה"
}
}
+8 -4
View File
@@ -46,7 +46,7 @@
"how_does_classquiz_work": "கிளாச் க்விச் கூட எவ்வாறு செயல்படுகிறது?",
"no_player_limit": "வீரர் வரம்பு இல்லை",
"no_player_limit_content": "ClassQuiz க்கு செயற்கை வீரர் வரம்பு இல்லை. கோட்பாட்டில், 1000+ வீரர்கள் ஒரே நொடி வினாவை விளையாட முடியும், ஆனால் இது ஒரு நொடி வினா வரை 300 வீரர்களுடன் சோதிக்கப்படுகிறது.",
"import_quiz_from_kahoot_and_edit": "ஏற்றுமதி"
"import_quiz_from_kahoot_and_edit": "கஊட்டிலிருந்து நொடி வினாவை இறக்குமதி செய்! வகுப்பு வினாடிவினாவில் அவற்றைத் திருத்தவும்"
},
"play_page": {
"join_description": "{{pin} at இல் சேரவும், {{url}}} ஐ உள்ளிடவும்.",
@@ -247,7 +247,9 @@
"visit_docs": "டாக்சைப் பார்வையிடவும்.",
"enter_answer": "ஒரு பதிலை உள்ளிடவும்",
"enable_reorder": "மறுதொடக்கம் பயன்முறையை இயக்கவும்",
"disable_reorder": "மறுதொடக்கம் பயன்முறையை முடக்கு"
"disable_reorder": "மறுதொடக்கம் பயன்முறையை முடக்கு",
"advanced_settings": "மேம்பட்ட அமைப்புகள்",
"hide_question_results": "கேள்வி முடிவுகளை மறைக்கவா?"
},
"import_page": {
"need_help": "உதவி தேவையா?",
@@ -314,7 +316,8 @@
"search_for_own_quizzes": "உங்கள் சொந்த நொடி வினாக்களைத் தேடுங்கள்",
"views_n_plays": "காட்சிகள் மற்றும் நாடகங்கள்",
"info_analytics": "\"நாடகங்கள்\" நொடி வினா எவ்வளவு அடிக்கடி தொடங்கப்பட்டது என்பதை மட்டுமே காட்டுகிறது (நீங்கள் சேர்த்துள்ளீர்கள்), அதேசமயம் \"காட்சிகள்\" \"பார்வை\"-பக்கம் எவ்வளவு அடிக்கடி பார்வையிடப்பட்டது என்பதை கணக்கிடுகிறது, எனவே இது போட்களையும் கணக்கிடுகிறது (அதற்காக மன்னிக்கவும்).",
"commandpalette_notice": "கட்டளைத் தட்டுகளைத் திறக்க <kbd> ctr </kbd>+<kbd> k </kbd> அல்லது <kbd> மேவு </kbd>+<kbd> k </kbd> ஐ அழுத்தவும்"
"commandpalette_notice": "கட்டளைத் தட்டுகளைத் திறக்க <kbd> ctr </kbd>+<kbd> k </kbd> அல்லது <kbd> மேவு </kbd>+<kbd> k </kbd> ஐ அழுத்தவும்",
"create_quiz": "புதிய நொடி வினாவை உருவாக்கவும்"
},
"footer": {
"self_ads": "{{mawoka_link} by மற்றும் {{others_link} of உதவியுடன் தயாரிக்கப்பட்டது.",
@@ -370,7 +373,8 @@
"correct_answer": "{{count}} சரியான பதில்",
"correct_answer_plural": "{{count}} சரியான பதில்கள்",
"time_taken": "எடுக்கப்பட்ட நேரம்",
"player_score": "பிளேயர் ச்கோர்"
"player_score": "பிளேயர் ச்கோர்",
"player_correct_questions": "சரியான பதில்கள்"
},
"controllers": {
"add_new_controller": "புதிய கட்டுப்படுத்தியைச் சேர்க்கவும்",
+8 -8
View File
@@ -40,7 +40,7 @@
"multilingual_content": "",
"dark_mode": "暗黑模式",
"why_classquiz": "为什么选择ClassQuiz",
"community_driven": "",
"community_driven": "社区驱动",
"play_quiz": "2. 开始测验",
"see_all_quizzes": "查看您的所有测验",
"download_quizzes_content": ""
@@ -233,7 +233,7 @@
},
"overview_page": {
"question_count": "",
"created_at": "",
"created_at": "创建于",
"no_quizzes": ""
},
"settings_page": {
@@ -382,15 +382,15 @@
"login_page": {
"use_backup_code": "",
"email_or_username": "",
"already_have_account": "",
"already_have_account": "没有账号吗?",
"modal": {
"error": {
"description": {
"unexpected": "",
"wrong_creds": ""
"wrong_creds": "请确保你的密码和电子邮件地址是正确的。"
},
"wrong_creds": "",
"unexpected": ""
"wrong_creds": "电子邮件地址或密码错误。",
"unexpected": "发生了未预料的错误!"
},
"success": {
"success": "",
@@ -401,8 +401,8 @@
"success_check_mail": ""
}
},
"login_or_create_account": "",
"welcome_back": ""
"login_or_create_account": "登录或创建账户",
"welcome_back": "欢迎回来。"
},
"password_reset_page": {
"reset_password": ""
+1 -6
View File
@@ -65,12 +65,7 @@ export interface Question {
}
export type Answers =
| Answer[]
| RangeQuizAnswer
| VotingAnswer[]
| string
| TextQuizAnswer[]
| OrderQuizAnswer[];
Answer[] | RangeQuizAnswer | VotingAnswer[] | string | TextQuizAnswer[] | OrderQuizAnswer[];
export interface Answer {
right: boolean;
+591
View File
@@ -0,0 +1,591 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Marlon W (Mawoka)
#
# SPDX-License-Identifier: MPL-2.0
"""
Quiz App Load Tester (Playwright)
- Launches N concurrent browser contexts (>=200).
- Workflow per client:
1) Open http://localhost:8000/play
2) Fill game key (default "620306"), submit
3) Wait for name field, enter "client-<id>", click "Abschicken"
4) Wait for game start (#progress-circle or answer grid)
5) Repeatedly click a random answer button; wait for next question (buttons enabled again)
- Measures:
* Page load time for /play (goto -> networkidle/domcontentloaded)
* All REST calls (resource_type in {'xhr','fetch'}) with durations (request -> finished)
- Outputs summary tables (page loads + REST endpoints) at the end.
Usage examples:
python load_quiz.py --clients 250 --key 620306 --rounds 20
python load_quiz.py --duration 120 # run answers for ~120s per client
python load_quiz.py --csv metrics.csv --json metrics.json
Tips:
- If your "Submit"/"Abschicken" button texts differ, adjust selectors below.
- For heavy loads, consider --ramp-up 20 to spread start times.
"""
import argparse
import asyncio
import json
import math
import random
import sys
import time
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
from urllib.parse import urlparse
from playwright.async_api import Browser, Page, Request, async_playwright
# ---------- Config defaults ----------
DEFAULT_BASE = "http://localhost:8080"
DEFAULT_PATH = "/play"
# ---------- Data structures ----------
@dataclass
class RequestMetric:
client_id: int
method: str
url: str
path: str
status: Optional[int]
resource_type: str
start_ms: float
end_ms: float
duration_ms: float
ok: bool
phase: str = "rest" # "rest" or "page_load"
@dataclass
class Aggregator:
# all metrics
metrics: List[RequestMetric] = field(default_factory=list)
def add(self, m: RequestMetric) -> None:
self.metrics.append(m)
# --- Stats helpers ---
@staticmethod
def _percentile(values: List[float], pct: float) -> float:
if not values:
return float("nan")
values_sorted = sorted(values)
# Nearest-rank method
k = max(1, math.ceil(pct * len(values_sorted))) - 1
return values_sorted[k]
@staticmethod
def _fmt_ms(x: float) -> str:
if math.isnan(x):
return "nan"
return f"{x:.1f}"
def summarize(self) -> Tuple[str, str]:
"""
Returns (page_load_table_str, rest_table_str)
"""
# Group page loads by path
page_loads = [m for m in self.metrics if m.phase == "page_load"]
rest_calls = [m for m in self.metrics if m.phase == "rest" and m.resource_type in {"xhr", "fetch"}]
def group_by(items: List[RequestMetric], key_fn):
g: Dict[str, List[RequestMetric]] = {}
for it in items:
g.setdefault(key_fn(it), []).append(it)
return g
# PAGE LOADS
by_page = group_by(page_loads, lambda m: m.path or urlparse(m.url).path)
page_lines = []
header = [
"Page",
"Count",
"Avg ms",
"p50",
"p90",
"p99",
"Min",
"Max",
"Errors",
]
page_lines.append(" | ".join(header))
page_lines.append("-" * (len(" | ".join(header)) + 4))
for path, items in sorted(by_page.items(), key=lambda kv: kv[0]):
durs = [m.duration_ms for m in items if not math.isnan(m.duration_ms)]
errs = sum(1 for m in items if not m.ok)
avg = sum(durs) / len(durs) if durs else float("nan")
p50 = self._percentile(durs, 0.50)
p90 = self._percentile(durs, 0.90)
p99 = self._percentile(durs, 0.99)
mn = min(durs) if durs else float("nan")
mx = max(durs) if durs else float("nan")
page_lines.append(
" | ".join(
[
path or "(unknown)",
str(len(items)),
self._fmt_ms(avg),
self._fmt_ms(p50),
self._fmt_ms(p90),
self._fmt_ms(p99),
self._fmt_ms(mn),
self._fmt_ms(mx),
f"{errs}",
]
)
)
page_table = "\n".join(page_lines) if len(page_lines) > 2 else "No page load metrics captured."
# REST CALLS
by_endpoint = group_by(rest_calls, lambda m: f"{m.method} {m.path or urlparse(m.url).path}")
rest_lines = []
header_r = [
"Endpoint (method path)",
"Count",
"Avg ms",
"p50",
"p90",
"p99",
"Min",
"Max",
"Err%",
]
rest_lines.append(" | ".join(header_r))
rest_lines.append("-" * (len(" | ".join(header_r)) + 4))
for ep, items in sorted(by_endpoint.items(), key=lambda kv: kv[0]):
durs = [m.duration_ms for m in items if not math.isnan(m.duration_ms)]
errs = sum(1 for m in items if not m.ok)
avg = sum(durs) / len(durs) if durs else float("nan")
p50 = self._percentile(durs, 0.50)
p90 = self._percentile(durs, 0.90)
p99 = self._percentile(durs, 0.99)
mn = min(durs) if durs else float("nan")
mx = max(durs) if durs else float("nan")
err_pct = (errs / len(items) * 100.0) if items else 0.0
rest_lines.append(
" | ".join(
[
ep,
str(len(items)),
self._fmt_ms(avg),
self._fmt_ms(p50),
self._fmt_ms(p90),
self._fmt_ms(p99),
self._fmt_ms(mn),
self._fmt_ms(mx),
f"{err_pct:.1f}%",
]
)
)
rest_table = "\n".join(rest_lines) if len(rest_lines) > 2 else "No REST (xhr/fetch) metrics captured."
return page_table, rest_table
# ---------- Client logic ----------
async def wait_for_game_ui(page: Page, timeout_ms: int) -> None:
"""
Wait until the game screen appears: either #progress-circle
or the answer grid with buttons.
"""
try:
await page.wait_for_selector("#progress-circle", state="visible", timeout=timeout_ms)
return
except Exception:
pass
# Answer grid fallback
await page.wait_for_selector("div.grid button", state="visible", timeout=timeout_ms)
async def click_random_answer(page: Page, timeout_ms: int) -> None:
"""
Click a random enabled answer button, then wait for the next question
by waiting for buttons to be disabled then re-enabled.
"""
# Ensure buttons are visible
await page.wait_for_selector("div.grid button", state="visible", timeout=timeout_ms)
# Pick among currently enabled buttons
enabled_locator = page.locator("div.grid button:not([disabled])")
count = await enabled_locator.count()
# If none enabled (edge), wait briefly and retry once
if count == 0:
await page.wait_for_timeout(200)
count = await enabled_locator.count()
if count == 0:
# As a last resort click first button if present
any_btns = page.locator("div.grid button")
if await any_btns.count() > 0:
await any_btns.nth(0).click()
return
idx = random.randrange(count)
await enabled_locator.nth(idx).click()
# Wait for transient disabled state (server evaluating question)
# Then wait for next set to become enabled again
try:
await page.wait_for_selector("div.grid button[disabled]", state="attached", timeout=timeout_ms)
except Exception:
# Sometimes they never disable; just proceed
pass
# Now wait until enabled buttons appear again (next question)
try:
await page.wait_for_selector("div.grid button:not([disabled])", state="visible", timeout=timeout_ms)
except Exception:
# If not re-enabled, at least ensure buttons are visible again
await page.wait_for_selector("div.grid button", state="visible", timeout=timeout_ms)
def just_path(url: str) -> str:
try:
return urlparse(url).path or "/"
except Exception:
return url
async def run_client(
client_id: int,
browser: Browser,
agg: Aggregator,
base_url: str,
start_path: str,
key_value: str,
rounds: int,
duration_s: Optional[int],
think_time_ms: Tuple[int, int],
timeouts: Dict[str, int],
headful_name: Optional[str] = None,
) -> None:
context = await browser.new_context(
viewport={"width": 1280, "height": 800},
user_agent=f"QuizLoadTester/1.0 Client/{client_id}",
java_script_enabled=True,
ignore_https_errors=True,
bypass_csp=True,
)
page = await context.new_page()
# ---- Network timing hooks ----
req_start: Dict[Request, float] = {}
def on_request(req: Request):
req_start[req] = time.perf_counter() * 1000.0 # ms
async def finalize_metric(req: Request, ok: bool):
start_ms = req_start.pop(req, time.perf_counter() * 1000.0)
end_ms = time.perf_counter() * 1000.0
dur = max(0.0, end_ms - start_ms)
resp = await req.response() if ok else None
status = resp.status if resp else None
agg.add(
RequestMetric(
client_id=client_id,
method=req.method,
url=req.url,
path=just_path(req.url),
status=status,
resource_type=req.resource_type,
start_ms=start_ms,
end_ms=end_ms,
duration_ms=dur,
ok=(ok and (status is not None) and (status < 400)),
phase="rest" if req.resource_type in {"xhr", "fetch"} else "other",
)
)
page.on("request", on_request)
page.on("requestfinished", lambda req: asyncio.create_task(finalize_metric(req, True)))
page.on("requestfailed", lambda req: asyncio.create_task(finalize_metric(req, False)))
# ---- Flow ----
url = f"{base_url.rstrip('/')}{start_path}"
# Measure initial page load
t0 = time.perf_counter() * 1000.0
try:
# Try networkidle first, fallback to domcontentloaded
try:
await page.goto(url, wait_until="networkidle", timeout=timeouts["goto"])
except Exception:
await page.goto(url, wait_until="domcontentloaded", timeout=timeouts["goto"])
t1 = time.perf_counter() * 1000.0
agg.add(
RequestMetric(
client_id=client_id,
method="GET",
url=url,
path=start_path,
status=200,
resource_type="document",
start_ms=t0,
end_ms=t1,
duration_ms=max(0.0, t1 - t0),
ok=True,
phase="page_load",
)
)
except Exception:
t1 = time.perf_counter() * 1000.0
agg.add(
RequestMetric(
client_id=client_id,
method="GET",
url=url,
path=start_path,
status=None,
resource_type="document",
start_ms=t0,
end_ms=t1,
duration_ms=max(0.0, t1 - t0),
ok=False,
phase="page_load",
)
)
await context.close()
return
# Step 2: Enter key and submit
try:
# Fill any visible text input (assumed "key")
await page.wait_for_selector('input[inputmode="numeric"]:visible', timeout=timeouts["ui"])
key_input = page.locator('input[inputmode="numeric"]:visible').first
await key_input.fill(key_value)
except Exception:
# If this fails, client can't proceed
await context.close()
return
# Step 3: Wait for name field and click "Abschicken"
try:
await page.wait_for_selector('button:has-text("Submit")', state="visible", timeout=timeouts["ui"])
name_btn = page.locator('button:has-text("Submit")').first
# Fill the visible text input again (assumed "name")
await page.wait_for_selector('input[maxlength="17"]:visible', timeout=timeouts["ui"])
name_input = page.locator('input[maxlength="17"]:visible').first
await name_input.fill(f"client-{client_id}")
await name_btn.click()
except Exception:
# If localized differently, try pressing Enter as fallback
try:
await page.keyboard.press("Enter")
except Exception:
await context.close()
return
# Step 4: Wait for game start
try:
await wait_for_game_ui(page, timeout_ms=timeouts["game"])
except Exception:
await context.close()
return
# Step 5: Answer loop
start_play = time.perf_counter()
q_count = 0
try:
while True:
# Optional stopping conditions
if rounds > 0 and q_count >= rounds:
break
if duration_s is not None and (time.perf_counter() - start_play) >= duration_s:
break
# "Think" a bit to avoid synchronized clicks
await page.wait_for_timeout(random.randint(*think_time_ms))
await click_random_answer(page, timeout_ms=timeouts["question"])
q_count += 1
finally:
await context.close()
# ---------- Main / CLI ----------
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Load test for Quiz app with real browsers (Playwright).")
p.add_argument("--base", default=DEFAULT_BASE, help="Base URL, default http://localhost:8000")
p.add_argument("--path", default=DEFAULT_PATH, help="Start path, default /play")
p.add_argument("--key", required=True, help="Quiz key to enter at first input")
p.add_argument(
"--clients",
type=int,
default=200,
help="Number of concurrent clients (default 200)",
)
p.add_argument(
"--rounds",
type=int,
default=15,
help="Max questions to answer per client (0 = unlimited)",
)
p.add_argument(
"--duration",
type=int,
default=None,
help="Max answering time per client in seconds (overrides rounds if provided)",
)
p.add_argument(
"--headful",
action="store_true",
help="Run headed (useful for debugging small client counts)",
)
p.add_argument(
"--ramp-up",
type=float,
default=10.0,
help="Seconds to spread client starts over (default 10s)",
)
p.add_argument(
"--min-think",
type=int,
default=200,
help="Min think time between answers in ms (default 200)",
)
p.add_argument(
"--max-think",
type=int,
default=1000,
help="Max think time between answers in ms (default 1200)",
)
p.add_argument(
"--goto-timeout",
type=int,
default=30000,
help="Timeout for page.goto in ms (default 15000)",
)
p.add_argument(
"--ui-timeout",
type=int,
default=30000,
help="Timeout for waiting UI elements in ms (default 15000)",
)
p.add_argument(
"--game-timeout",
type=int,
default=30000,
help="Timeout for waiting game UI in ms (default 30000)",
)
p.add_argument(
"--question-timeout",
type=int,
default=20000,
help="Timeout around a question cycle in ms (default 20000)",
)
p.add_argument("--csv", default=None, help="Write raw metrics to CSV file")
p.add_argument("--json", default=None, help="Write raw metrics to JSON file")
return p.parse_args()
def write_outputs(agg: Aggregator, csv_path: Optional[str], json_path: Optional[str]) -> None:
if json_path:
with open(json_path, "w", encoding="utf-8") as f:
json.dump([m.__dict__ for m in agg.metrics], f, indent=2)
print(f"\nWrote JSON metrics to: {json_path}")
if csv_path:
import csv
with open(csv_path, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(
[
"client_id",
"phase",
"method",
"url",
"path",
"status",
"resource_type",
"start_ms",
"end_ms",
"duration_ms",
"ok",
]
)
for m in agg.metrics:
w.writerow(
[
m.client_id,
m.phase,
m.method,
m.url,
m.path,
m.status if m.status is not None else "",
m.resource_type,
f"{m.start_ms:.3f}",
f"{m.end_ms:.3f}",
f"{m.duration_ms:.3f}",
int(m.ok),
]
)
print(f"Wrote CSV metrics to: {csv_path}")
async def main_async():
args = parse_args()
agg = Aggregator()
async with async_playwright() as pw:
browser = await pw.chromium.launch(
headless=not args.headful,
args=[
"--disable-dev-shm-usage",
"--no-sandbox",
],
)
# Schedule all clients with ramp-up
tasks = []
for i in range(args.clients):
delay = (args.ramp_up * (i / max(1, args.clients - 1))) if args.ramp_up > 0 else 0.0
async def starter(idx=i, dly=delay):
if dly > 0:
await asyncio.sleep(dly)
return await run_client(
client_id=idx + 1,
browser=browser,
agg=agg,
base_url=args.base,
start_path=args.path,
key_value=args.key,
rounds=args.rounds if args.duration is None else 0,
duration_s=args.duration,
think_time_ms=(args.min_think, args.max_think),
timeouts={
"goto": args.goto_timeout,
"ui": args.ui_timeout,
"game": args.game_timeout,
"question": args.question_timeout,
},
)
tasks.append(asyncio.create_task(starter()))
await asyncio.gather(*tasks, return_exceptions=True)
await browser.close()
# ----- Output -----
page_table, rest_table = agg.summarize()
print("\n=== Page Loads ===")
print(page_table)
print("\n=== REST (XHR/Fetch) ===")
print(rest_table)
write_outputs(agg, args.csv, args.json)
def main():
try:
asyncio.run(main_async())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
if __name__ == "__main__":
main()