Add Quiz feature: 10-question quizzes with progressive scoring (max 40 pts)
- Quizzes table with questions, answer options, attempts, answers - Question types: multiple_choice, exclusion, true_false, free_text - Progressive scoring: [1,1,2,2,3,3,4,6,8,10] = max 40 per quiz - Alpine.js countdown timer per question with auto-submit on timeout - Admin: CRUD for quizzes + per-question editor, JSON export/import - Child: quiz overview with best scores, question view, result breakdown - Nav: Quiz link in child header and admin sidebar
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Admin;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\{Quiz, QuizQuestion, QuizAnswerOption, Subject};
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class QuizController extends Controller {
|
||||
|
||||
public function index() {
|
||||
$quizzes = Quiz::with('subject')->withCount('questions')->latest()->get();
|
||||
return view('admin.quizzes.index', compact('quizzes'));
|
||||
}
|
||||
|
||||
public function create() {
|
||||
$subjects = Subject::all();
|
||||
return view('admin.quizzes.create', compact('subjects'));
|
||||
}
|
||||
|
||||
public function store(Request $r) {
|
||||
$r->validate(['title'=>'required|string|max:120','subject_id'=>'required|exists:subjects,id','description'=>'nullable|string|max:500']);
|
||||
$quiz = Quiz::create($r->only('title','subject_id','description') + ['active'=>true]);
|
||||
return redirect()->route('admin.quizzes.edit', $quiz)->with('success','Quiz erstellt – jetzt Fragen hinzufügen.');
|
||||
}
|
||||
|
||||
public function edit(Quiz $quiz) {
|
||||
$subjects = Subject::all();
|
||||
$questions = $quiz->questions()->with('answerOptions')->get();
|
||||
return view('admin.quizzes.edit', compact('quiz','subjects','questions'));
|
||||
}
|
||||
|
||||
public function update(Request $r, Quiz $quiz) {
|
||||
$r->validate(['title'=>'required|string|max:120','subject_id'=>'required|exists:subjects,id','description'=>'nullable|string|max:500']);
|
||||
$quiz->update($r->only('title','subject_id','description') + ['active'=>$r->boolean('active')]);
|
||||
return back()->with('success','Quiz gespeichert.');
|
||||
}
|
||||
|
||||
public function destroy(Quiz $quiz) {
|
||||
$quiz->delete();
|
||||
return redirect()->route('admin.quizzes.index')->with('success','Quiz gelöscht.');
|
||||
}
|
||||
|
||||
public function export(Quiz $quiz) {
|
||||
$quiz->load('subject','questions.answerOptions');
|
||||
$data = [
|
||||
'subject' => $quiz->subject->slug,
|
||||
'title' => $quiz->title,
|
||||
'description' => $quiz->description,
|
||||
'questions' => $quiz->questions->map(fn($q) => [
|
||||
'type' => $q->type,
|
||||
'question_text' => $q->question_text,
|
||||
'time_limit' => $q->time_limit,
|
||||
'correct_answer' => $q->correct_answer,
|
||||
'options' => $q->answerOptions->map(fn($o) => [
|
||||
'text' => $o->text,
|
||||
'is_correct' => (bool)$o->is_correct,
|
||||
])->values(),
|
||||
])->values(),
|
||||
];
|
||||
$filename = 'quiz-'.now()->format('Y-m-d').'-'.str($quiz->title)->slug().'.json';
|
||||
return response()->streamDownload(
|
||||
fn() => print(json_encode($data, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)),
|
||||
$filename, ['Content-Type'=>'application/json']
|
||||
);
|
||||
}
|
||||
|
||||
public function import(Request $r) {
|
||||
$r->validate(['file'=>'required|file|mimes:json|max:4096']);
|
||||
$raw = json_decode(file_get_contents($r->file('file')->getRealPath()), true);
|
||||
if (!is_array($raw) || !isset($raw['title'],$raw['subject'],$raw['questions'])) {
|
||||
return back()->with('error','Ungültiges JSON-Format. Felder: title, subject, questions erwartet.');
|
||||
}
|
||||
$subject = Subject::where('slug',$raw['subject'])->first();
|
||||
if (!$subject) return back()->with('error','Unbekanntes Fach: '.$raw['subject']);
|
||||
|
||||
DB::transaction(function() use ($raw,$subject) {
|
||||
$quiz = Quiz::create([
|
||||
'subject_id' => $subject->id,
|
||||
'title' => $raw['title'],
|
||||
'description' => $raw['description'] ?? null,
|
||||
'active' => true,
|
||||
]);
|
||||
foreach (array_slice($raw['questions'],0,10) as $i => $item) {
|
||||
$q = QuizQuestion::create([
|
||||
'quiz_id' => $quiz->id,
|
||||
'sort_order' => $i,
|
||||
'type' => $item['type'] ?? 'multiple_choice',
|
||||
'question_text' => $item['question_text'],
|
||||
'time_limit' => $item['time_limit'] ?? null,
|
||||
'correct_answer' => $item['correct_answer'] ?? null,
|
||||
]);
|
||||
foreach (($item['options'] ?? []) as $j => $opt) {
|
||||
QuizAnswerOption::create([
|
||||
'quiz_question_id' => $q->id,
|
||||
'text' => $opt['text'],
|
||||
'is_correct' => $opt['is_correct'] ?? false,
|
||||
'sort_order' => $j,
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
return redirect()->route('admin.quizzes.index')->with('success','Quiz "'.$raw['title'].'" importiert.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Admin;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\{Quiz, QuizQuestion, QuizAnswerOption};
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class QuizQuestionController extends Controller {
|
||||
|
||||
public function create(Quiz $quiz) {
|
||||
return view('admin.quiz_questions.create', compact('quiz'));
|
||||
}
|
||||
|
||||
public function store(Request $r, Quiz $quiz) {
|
||||
$r->validate([
|
||||
'type' => 'required|in:multiple_choice,exclusion,true_false,free_text',
|
||||
'question_text' => 'required|string',
|
||||
'time_limit' => 'nullable|integer|min:5|max:120',
|
||||
'correct_answer' => 'nullable|string|max:200',
|
||||
'correct_option' => 'nullable|integer|min:0|max:3',
|
||||
'options' => 'array',
|
||||
'options.*.text' => 'nullable|string|max:200',
|
||||
]);
|
||||
$q = QuizQuestion::create([
|
||||
'quiz_id' => $quiz->id,
|
||||
'sort_order' => ($quiz->questions()->max('sort_order') ?? -1) + 1,
|
||||
'type' => $r->type,
|
||||
'question_text' => $r->question_text,
|
||||
'time_limit' => $r->time_limit ?: null,
|
||||
'correct_answer' => $r->correct_answer,
|
||||
]);
|
||||
if (in_array($r->type,['multiple_choice','exclusion'])) {
|
||||
$correctIdx = (int)$r->input('correct_option', 0);
|
||||
foreach (($r->options ?? []) as $i => $opt) {
|
||||
if (!empty($opt['text'])) {
|
||||
QuizAnswerOption::create([
|
||||
'quiz_question_id' => $q->id,
|
||||
'text' => $opt['text'],
|
||||
'is_correct' => $i === $correctIdx,
|
||||
'sort_order' => $i,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return redirect()->route('admin.quizzes.edit', $quiz)->with('success','Frage hinzugefügt.');
|
||||
}
|
||||
|
||||
public function edit(QuizQuestion $question) {
|
||||
return view('admin.quiz_questions.edit', compact('question'));
|
||||
}
|
||||
|
||||
public function update(Request $r, QuizQuestion $question) {
|
||||
$r->validate([
|
||||
'type' => 'required|in:multiple_choice,exclusion,true_false,free_text',
|
||||
'question_text' => 'required|string',
|
||||
'time_limit' => 'nullable|integer|min:5|max:120',
|
||||
'correct_answer' => 'nullable|string|max:200',
|
||||
'correct_option' => 'nullable|integer|min:0|max:3',
|
||||
'options' => 'array',
|
||||
'options.*.text' => 'nullable|string|max:200',
|
||||
]);
|
||||
$question->update([
|
||||
'type' => $r->type,
|
||||
'question_text' => $r->question_text,
|
||||
'time_limit' => $r->time_limit ?: null,
|
||||
'correct_answer' => $r->correct_answer,
|
||||
]);
|
||||
if (in_array($r->type,['multiple_choice','exclusion'])) {
|
||||
$question->answerOptions()->delete();
|
||||
$correctIdx = (int)$r->input('correct_option', 0);
|
||||
foreach (($r->options ?? []) as $i => $opt) {
|
||||
if (!empty($opt['text'])) {
|
||||
QuizAnswerOption::create([
|
||||
'quiz_question_id' => $question->id,
|
||||
'text' => $opt['text'],
|
||||
'is_correct' => $i === $correctIdx,
|
||||
'sort_order' => $i,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return redirect()->route('admin.quizzes.edit', $question->quiz)->with('success','Frage gespeichert.');
|
||||
}
|
||||
|
||||
public function destroy(QuizQuestion $question) {
|
||||
$quiz = $question->quiz;
|
||||
$question->delete();
|
||||
return redirect()->route('admin.quizzes.edit', $quiz)->with('success','Frage gelöscht.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Child;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\{Quiz, QuizAttempt, QuizAttemptAnswer, QuizQuestion, Subject};
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class QuizController extends Controller {
|
||||
|
||||
public function index() {
|
||||
$subjects = Subject::all()->keyBy('id');
|
||||
$quizzes = Quiz::with('subject')->where('active',true)
|
||||
->withCount('questions')->get()->groupBy('subject_id');
|
||||
$bestScores = QuizAttempt::where('user_id',auth()->id())
|
||||
->where('status','completed')
|
||||
->selectRaw('quiz_id, MAX(score) as best')
|
||||
->groupBy('quiz_id')->pluck('best','quiz_id');
|
||||
return view('child.quiz.index', compact('subjects','quizzes','bestScores'));
|
||||
}
|
||||
|
||||
public function start(Quiz $quiz) {
|
||||
abort_unless($quiz->active, 403);
|
||||
$attempt = QuizAttempt::firstOrCreate(
|
||||
['user_id'=>auth()->id(),'quiz_id'=>$quiz->id,'status'=>'in_progress'],
|
||||
['score'=>0,'current_question'=>0,'started_at'=>now()]
|
||||
);
|
||||
return redirect()->route('quiz.question', $attempt);
|
||||
}
|
||||
|
||||
public function question(QuizAttempt $attempt) {
|
||||
abort_unless($attempt->user_id === auth()->id(), 403);
|
||||
if ($attempt->status === 'completed') return redirect()->route('quiz.result', $attempt);
|
||||
$question = $attempt->quiz->questions()->skip($attempt->current_question)->first();
|
||||
$pointsIfCorrect = QuizAttempt::POINTS[$attempt->current_question];
|
||||
return view('child.quiz.question', compact('attempt','question','pointsIfCorrect'));
|
||||
}
|
||||
|
||||
public function answer(Request $r, QuizAttempt $attempt) {
|
||||
abort_unless($attempt->user_id === auth()->id(), 403);
|
||||
abort_if($attempt->status === 'completed', 400);
|
||||
|
||||
$question = $attempt->quiz->questions()->skip($attempt->current_question)->first();
|
||||
$possible = QuizAttempt::POINTS[$attempt->current_question];
|
||||
$timedOut = $r->input('timeout') === '1';
|
||||
$answerGiven = $r->input('answer','');
|
||||
$isCorrect = false;
|
||||
$pointsEarned = 0;
|
||||
|
||||
if (!$timedOut && $answerGiven !== '') {
|
||||
$isCorrect = $this->check($question, $answerGiven);
|
||||
$pointsEarned = $isCorrect ? $possible : 0;
|
||||
}
|
||||
|
||||
QuizAttemptAnswer::create([
|
||||
'quiz_attempt_id' => $attempt->id,
|
||||
'quiz_question_id' => $question->id,
|
||||
'answer_given' => $timedOut ? '__timeout__' : $answerGiven,
|
||||
'is_correct' => $isCorrect,
|
||||
'points_earned' => $pointsEarned,
|
||||
]);
|
||||
|
||||
session()->flash('last_answer', [
|
||||
'correct' => $isCorrect,
|
||||
'points' => $pointsEarned,
|
||||
'possible' => $possible,
|
||||
'timeout' => $timedOut,
|
||||
]);
|
||||
|
||||
$next = $attempt->current_question + 1;
|
||||
$totalQuestions = $attempt->quiz->questions()->count();
|
||||
|
||||
if ($next >= $totalQuestions || $next >= 10) {
|
||||
$total = QuizAttemptAnswer::where('quiz_attempt_id',$attempt->id)->sum('points_earned');
|
||||
$attempt->update(['score'=>$total,'current_question'=>$next,'status'=>'completed','completed_at'=>now()]);
|
||||
auth()->user()->increment('points', $total);
|
||||
return redirect()->route('quiz.result', $attempt);
|
||||
}
|
||||
$attempt->update(['current_question'=>$next]);
|
||||
return redirect()->route('quiz.question', $attempt);
|
||||
}
|
||||
|
||||
public function result(QuizAttempt $attempt) {
|
||||
abort_unless($attempt->user_id === auth()->id(), 403);
|
||||
abort_unless($attempt->status === 'completed', 400);
|
||||
$answers = $attempt->answers()->with('question.answerOptions')->get();
|
||||
return view('child.quiz.result', compact('attempt','answers'));
|
||||
}
|
||||
|
||||
private function check(QuizQuestion $q, string $answer): bool {
|
||||
return match($q->type) {
|
||||
'multiple_choice','exclusion' => ($opt = $q->answerOptions()->where('is_correct',true)->first())
|
||||
&& (string)$opt->id === $answer,
|
||||
'true_false','free_text' => strtolower(trim($answer)) === strtolower(trim((string)$q->correct_answer)),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
class Quiz extends Model {
|
||||
protected $fillable = ['subject_id','title','description','active'];
|
||||
protected $casts = ['active' => 'boolean'];
|
||||
public function subject() { return $this->belongsTo(Subject::class); }
|
||||
public function questions() { return $this->hasMany(QuizQuestion::class)->orderBy('sort_order'); }
|
||||
public function attempts() { return $this->hasMany(QuizAttempt::class); }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
class QuizAnswerOption extends Model {
|
||||
public $timestamps = false;
|
||||
protected $fillable = ['quiz_question_id','text','is_correct','sort_order'];
|
||||
public function question() { return $this->belongsTo(QuizQuestion::class,'quiz_question_id'); }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
class QuizAttempt extends Model {
|
||||
protected $fillable = ['user_id','quiz_id','score','current_question','status','started_at','completed_at'];
|
||||
protected $casts = ['started_at' => 'datetime', 'completed_at' => 'datetime'];
|
||||
public const POINTS = [1, 1, 2, 2, 3, 3, 4, 6, 8, 10];
|
||||
public function user() { return $this->belongsTo(User::class); }
|
||||
public function quiz() { return $this->belongsTo(Quiz::class); }
|
||||
public function answers() { return $this->hasMany(QuizAttemptAnswer::class); }
|
||||
public function stars(): string {
|
||||
$s = $this->score;
|
||||
if ($s >= 40) return '🌟🌟🌟🌟🌟';
|
||||
if ($s >= 30) return '⭐⭐⭐⭐';
|
||||
if ($s >= 20) return '⭐⭐⭐';
|
||||
if ($s >= 10) return '⭐⭐';
|
||||
if ($s >= 1) return '⭐';
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
class QuizAttemptAnswer extends Model {
|
||||
protected $fillable = ['quiz_attempt_id','quiz_question_id','answer_given','is_correct','points_earned'];
|
||||
public function attempt() { return $this->belongsTo(QuizAttempt::class,'quiz_attempt_id'); }
|
||||
public function question() { return $this->belongsTo(QuizQuestion::class,'quiz_question_id'); }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
class QuizQuestion extends Model {
|
||||
protected $fillable = ['quiz_id','sort_order','type','question_text','time_limit','correct_answer'];
|
||||
public function quiz() { return $this->belongsTo(Quiz::class); }
|
||||
public function answerOptions() { return $this->hasMany(QuizAnswerOption::class,'quiz_question_id')->orderBy('sort_order'); }
|
||||
public function typeLabel(): string {
|
||||
return match($this->type) {
|
||||
'multiple_choice' => 'Multiple Choice',
|
||||
'exclusion' => 'Ausschluss',
|
||||
'true_false' => 'Wahr/Falsch',
|
||||
'free_text' => 'Freitext',
|
||||
default => $this->type,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user