Admin: manual scoring for open-ended questions

This commit is contained in:
jeremy bayse
2026-03-22 22:06:12 +01:00
parent 732d9416f4
commit 2df0d6def0
5 changed files with 105 additions and 20 deletions

View File

@@ -110,26 +110,9 @@ class AttemptController extends Controller
return redirect()->route('dashboard');
}
$attempt->load(['quiz.questions.options', 'answers']);
$score = 0;
$maxScore = $attempt->quiz->questions->sum('points');
foreach ($attempt->quiz->questions as $question) {
if ($question->type === 'qcm') {
$userAnswer = $attempt->answers->where('question_id', $question->id)->first();
if ($userAnswer && $userAnswer->option_id) {
$option = $question->options->where('id', $userAnswer->option_id)->first();
if ($option && $option->is_correct) {
$score += $question->points;
}
}
}
}
$this->recalculateScore($attempt);
$attempt->update([
'score' => $score,
'max_score' => $maxScore,
'finished_at' => now(),
]);
@@ -137,4 +120,47 @@ class AttemptController extends Controller
return redirect()->route('dashboard');
}
public function updateAnswerScore(Request $request, Answer $answer)
{
$this->authorizeAdmin();
$request->validate([
'score' => 'required|numeric|min:0'
]);
$answer->update(['score' => $request->score]);
$this->recalculateScore($answer->attempt);
return back()->with('success', 'Note mise à jour et score total recalculé.');
}
private function recalculateScore(Attempt $attempt)
{
$attempt->load(['quiz.questions.options', 'answers.option']);
$score = 0;
$maxScore = 0;
foreach ($attempt->quiz->questions as $question) {
$maxScore += $question->points;
$userAnswer = $attempt->answers->where('question_id', $question->id)->first();
if ($userAnswer) {
if ($question->type === 'qcm') {
if ($userAnswer->option && $userAnswer->option->is_correct) {
$score += $question->points;
}
} else if ($question->type === 'open') {
$score += (float) $userAnswer->score;
}
}
}
$attempt->update([
'score' => $score,
'max_score' => $maxScore,
]);
}
}

View File

@@ -8,11 +8,15 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
#[Fillable(['attempt_id', 'question_id', 'option_id', 'text_content'])]
#[Fillable(['attempt_id', 'question_id', 'option_id', 'text_content', 'score'])]
class Answer extends Model
{
use HasFactory;
protected $casts = [
'score' => 'float',
];
public function attempt(): BelongsTo
{
return $this->belongsTo(Attempt::class);

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('answers', function (Blueprint $table) {
$table->decimal('score', 8, 2)->nullable()->after('text_content');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('answers', function (Blueprint $table) {
$table->dropColumn('score');
});
}
};

View File

@@ -100,6 +100,14 @@ const saveScores = () => {
const openPreview = (doc) => {
selectedDocument.value = doc;
};
const updateAnswerScore = (answerId, score) => {
router.patch(route('admin.answers.update-score', answerId), {
score: score
}, {
preserveScroll: true,
});
};
</script>
<template>
@@ -461,10 +469,28 @@ const openPreview = (doc) => {
</div>
<!-- Open Answer -->
<div v-else>
<div v-else class="space-y-4">
<div class="p-6 bg-slate-50 dark:bg-slate-900 border border-slate-100 dark:border-slate-800 rounded-3xl text-[13px] text-slate-600 dark:text-slate-400 leading-relaxed italic shadow-inner">
" {{ answer.text_content || 'Aucune réponse fournie.' }} "
</div>
<!-- Score Input for Open Question -->
<div class="flex items-center gap-4 bg-slate-50 dark:bg-slate-900/50 p-4 rounded-2xl border border-dashed border-slate-200 dark:border-slate-700">
<div class="text-[10px] font-black uppercase tracking-widest text-slate-400">Note Attribuée :</div>
<div class="flex items-center gap-2">
<input
type="number"
v-model="answer.score"
min="0"
:max="answer.question.points"
step="0.5"
@change="updateAnswerScore(answer.id, answer.score)"
class="w-20 bg-white dark:bg-slate-800 border-none rounded-xl py-1 px-3 font-black text-indigo-600 focus:ring-2 focus:ring-indigo-500/20 transition-all text-center"
/>
<span class="text-xs font-bold text-slate-300">/ {{ answer.question.points }}</span>
</div>
<div class="text-[9px] text-slate-400 italic">Enregistré au clic/changement</div>
</div>
</div>
</div>
</div>

View File

@@ -80,6 +80,7 @@ Route::middleware('auth')->group(function () {
Route::resource('quizzes', \App\Http\Controllers\QuizController::class)->only(['index', 'store', 'show', 'update', 'destroy']);
Route::resource('quizzes.questions', \App\Http\Controllers\QuestionController::class)->only(['store', 'update', 'destroy']);
Route::delete('/attempts/{attempt}', [\App\Http\Controllers\AttemptController::class, 'destroy'])->name('attempts.destroy');
Route::patch('/answers/{answer}/score', [\App\Http\Controllers\AttemptController::class, 'updateAnswerScore'])->name('answers.update-score');
});
// Candidate Routes