From 70a970cee6d14082e8314c70559cba0dc9cdcd12 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Fri, 19 Dec 2025 00:59:28 +0100 Subject: [PATCH] unlimited quizzes and exams --- Modules/Exam/app/Http/Requests/ExamRequest.php | 4 ++-- .../Exam/app/Http/Requests/UpdateExamRequest.php | 4 ++-- Modules/Exam/app/Services/ExamAttemptService.php | 6 ++++-- Modules/Exam/app/Services/ExamEnrollmentService.php | 4 +++- app/Http/Requests/StoreQuizRequest.php | 2 +- app/Http/Requests/UpdateQuizRequest.php | 2 +- app/Services/Course/SectionQuizService.php | 8 +++++--- resources/js/components/cards/exam-card-7.tsx | 4 ++-- .../js/pages/course-player/partials/quiz-viewer.tsx | 8 +++++--- .../dashboard/courses/partials/forms/quiz-form.tsx | 5 +++-- resources/js/pages/dashboard/exams/create.tsx | 10 +++++++--- .../exams/partials/forms/exam-settings-form.tsx | 12 ++++++++---- .../exams/partials/tabs-content/settings.tsx | 13 ++++++++----- resources/js/pages/dashboard/exams/show.tsx | 2 +- resources/js/pages/exams/partials/details.tsx | 4 +++- resources/js/pages/exams/partials/exam-preview.tsx | 2 +- 16 files changed, 56 insertions(+), 34 deletions(-) diff --git a/Modules/Exam/app/Http/Requests/ExamRequest.php b/Modules/Exam/app/Http/Requests/ExamRequest.php index b080b6e0..64a65081 100644 --- a/Modules/Exam/app/Http/Requests/ExamRequest.php +++ b/Modules/Exam/app/Http/Requests/ExamRequest.php @@ -24,7 +24,7 @@ class ExamRequest extends FormRequest 'duration_minutes' => request('duration_minutes') ? (int) request('duration_minutes') : 0, 'pass_mark' => request('pass_mark') ? (float) request('pass_mark') : null, 'total_marks' => request('total_marks') ? (float) request('total_marks') : null, - 'max_attempts' => request('max_attempts') ? (int) request('max_attempts') : null, + 'max_attempts' => request()->has('max_attempts') ? (int) request('max_attempts') : 0, ]); } @@ -59,7 +59,7 @@ class ExamRequest extends FormRequest 'duration_minutes' => 'required|integer|min:0|max:59', 'pass_mark' => 'required|numeric|min:0|max:100', 'total_marks' => 'required|numeric|min:1', - 'max_attempts' => 'required|integer|min:1', + 'max_attempts' => 'required|integer|min:0', // Status & Level 'status' => 'nullable|string|in:draft,published,archived', diff --git a/Modules/Exam/app/Http/Requests/UpdateExamRequest.php b/Modules/Exam/app/Http/Requests/UpdateExamRequest.php index e3cd8576..cc0940af 100644 --- a/Modules/Exam/app/Http/Requests/UpdateExamRequest.php +++ b/Modules/Exam/app/Http/Requests/UpdateExamRequest.php @@ -19,7 +19,7 @@ class UpdateExamRequest extends FormRequest 'duration_minutes' => request('duration_minutes') ? (int) request('duration_minutes') : 0, 'pass_mark' => request('pass_mark') ? (float) request('pass_mark') : null, 'total_marks' => request('total_marks') ? (float) request('total_marks') : null, - 'max_attempts' => request('max_attempts') ? (int) request('max_attempts') : null, + 'max_attempts' => request()->has('max_attempts') ? (int) request('max_attempts') : 0, ]); } @@ -103,7 +103,7 @@ class UpdateExamRequest extends FormRequest 'duration_minutes' => 'required|integer|min:0|max:59', 'pass_mark' => 'required|numeric|min:0|max:100', 'total_marks' => 'required|numeric|min:1', - 'max_attempts' => 'required|integer|min:1', + 'max_attempts' => 'required|integer|min:0', ]; } diff --git a/Modules/Exam/app/Services/ExamAttemptService.php b/Modules/Exam/app/Services/ExamAttemptService.php index b975843b..d2ef32e1 100644 --- a/Modules/Exam/app/Services/ExamAttemptService.php +++ b/Modules/Exam/app/Services/ExamAttemptService.php @@ -12,14 +12,16 @@ class ExamAttemptService { /** * Start a new exam attempt - */ + */ public function startAttempt(User $user, Exam $exam): ?ExamAttempt { $previousAttempts = ExamAttempt::where('user_id', $user->id) ->where('exam_id', $exam->id) ->count(); - if ($previousAttempts >= $exam->max_attempts) { + $hasAttemptLimit = $exam->max_attempts > 0; + + if ($hasAttemptLimit && $previousAttempts >= $exam->max_attempts) { return null; } diff --git a/Modules/Exam/app/Services/ExamEnrollmentService.php b/Modules/Exam/app/Services/ExamEnrollmentService.php index 192260bf..f861b094 100644 --- a/Modules/Exam/app/Services/ExamEnrollmentService.php +++ b/Modules/Exam/app/Services/ExamEnrollmentService.php @@ -112,7 +112,9 @@ class ExamEnrollmentService extends MediaService 'enrollment' => $enrollment, 'is_active' => $enrollment->isActive(), 'attempts_used' => $attempts->count(), - 'attempts_remaining' => max(0, $exam->max_attempts - $attempts->count()), + 'attempts_remaining' => $exam->max_attempts > 0 + ? max(0, $exam->max_attempts - $attempts->count()) + : null, 'completed_attempts' => $completedAttempts, 'best_score' => $bestScore, 'has_passed' => $hasPassed, diff --git a/app/Http/Requests/StoreQuizRequest.php b/app/Http/Requests/StoreQuizRequest.php index 3869be42..fbe07f64 100644 --- a/app/Http/Requests/StoreQuizRequest.php +++ b/app/Http/Requests/StoreQuizRequest.php @@ -63,7 +63,7 @@ class StoreQuizRequest extends FormRequest } } ], - 'retake' => 'required|numeric|min:1', + 'retake' => 'required|numeric|min:0', ]; } } diff --git a/app/Http/Requests/UpdateQuizRequest.php b/app/Http/Requests/UpdateQuizRequest.php index 30232e31..2a21cd5a 100644 --- a/app/Http/Requests/UpdateQuizRequest.php +++ b/app/Http/Requests/UpdateQuizRequest.php @@ -62,7 +62,7 @@ class UpdateQuizRequest extends FormRequest } } ], - 'retake' => 'required|numeric|min:1', + 'retake' => 'required|numeric|min:0', ]; } } diff --git a/app/Services/Course/SectionQuizService.php b/app/Services/Course/SectionQuizService.php index 022b16d2..7ed297c6 100644 --- a/app/Services/Course/SectionQuizService.php +++ b/app/Services/Course/SectionQuizService.php @@ -50,13 +50,15 @@ class SectionQuizService extends CourseSectionService ->where('section_quiz_id', $quiz->id) ->first(); + $hasAttemptLimit = $quiz->retake > 0; + // Get or create quiz submission if ($submission) { - if ($submission->attempts >= $quiz->retake) { + if ($hasAttemptLimit && $submission->attempts >= $quiz->retake) { return false; - } else { - $submission->increment('attempts'); } + + $submission->increment('attempts'); } else { $submission = QuizSubmission::create([ 'section_quiz_id' => $quiz->id, diff --git a/resources/js/components/cards/exam-card-7.tsx b/resources/js/components/cards/exam-card-7.tsx index d8ad0156..df439f24 100644 --- a/resources/js/components/cards/exam-card-7.tsx +++ b/resources/js/components/cards/exam-card-7.tsx @@ -62,7 +62,7 @@ const ExamCard7 = ({ exam, attempts, bestAttempt, className }: Props) => {

Attempts - {totalAttempts} / {exam.max_attempts} + {exam.max_attempts === 0 ? `${totalAttempts} / Unlimited` : `${totalAttempts} / ${exam.max_attempts}`}

@@ -87,7 +87,7 @@ const ExamCard7 = ({ exam, attempts, bestAttempt, className }: Props) => { )} - {totalAttempts < exam.max_attempts && ( + {(exam.max_attempts === 0 || totalAttempts < exam.max_attempts) && ( { const [finished, setFinished] = useState(false); const [currentTab, setCurrentTab] = useState('summary'); const submissions = quiz.quiz_submissions; + const attemptsUsed = submissions[0]?.attempts || 0; + const hasAttemptLimit = quiz.retake > 0; const { data, setData, post, reset, processing } = useForm({ submission_id: submissions.length > 0 ? submissions[0].id : null, @@ -188,7 +190,7 @@ const QuizViewer = ({ quiz }: QuizViewerProps) => {

{frontend.retake}

-

: {quiz.retake}

+

: {hasAttemptLimit ? quiz.retake : 'Unlimited'}

@@ -196,7 +198,7 @@ const QuizViewer = ({ quiz }: QuizViewerProps) => {

{frontend.retake_attempts}

-

: {submissions[0]?.attempts || 0}

+

: {attemptsUsed}

{frontend.correct_answers}

@@ -218,7 +220,7 @@ const QuizViewer = ({ quiz }: QuizViewerProps) => {
- {submissions[0]?.attempts >= quiz.retake ? ( + {hasAttemptLimit && attemptsUsed >= quiz.retake ? ( diff --git a/resources/js/pages/dashboard/courses/partials/forms/quiz-form.tsx b/resources/js/pages/dashboard/courses/partials/forms/quiz-form.tsx index 542f5be7..328a9bdb 100644 --- a/resources/js/pages/dashboard/courses/partials/forms/quiz-form.tsx +++ b/resources/js/pages/dashboard/courses/partials/forms/quiz-form.tsx @@ -31,7 +31,7 @@ const QuizForm = ({ title, quiz, handler, sectionId }: Props) => { course_id: props.course.id, total_mark: quiz?.total_mark || '', pass_mark: quiz?.pass_mark || '', - retake: quiz?.retake || 1, + retake: quiz?.retake ?? 1, summary: quiz?.summary || '', hours: quiz?.hours || '', minutes: quiz?.minutes || '', @@ -135,7 +135,7 @@ const QuizForm = ({ title, quiz, handler, sectionId }: Props) => {
{ placeholder="00" onChange={(e) => onHandleChange(e, setData)} /> +

0 = Unlimited attempts

diff --git a/resources/js/pages/dashboard/exams/create.tsx b/resources/js/pages/dashboard/exams/create.tsx index 9f43ad6a..81105fe3 100644 --- a/resources/js/pages/dashboard/exams/create.tsx +++ b/resources/js/pages/dashboard/exams/create.tsx @@ -218,10 +218,14 @@ const CreateExam = (props: Props) => { type="number" name="max_attempts" value={data.max_attempts.toString()} - onChange={(e) => setData('max_attempts', parseInt(e.target.value) || 1)} - placeholder="3" - min="1" + onChange={(e) => { + const value = parseInt(e.target.value, 10); + setData('max_attempts', Number.isNaN(value) ? 0 : value); + }} + placeholder="0" + min="0" /> +

0 = Unlimited attempts

diff --git a/resources/js/pages/dashboard/exams/partials/forms/exam-settings-form.tsx b/resources/js/pages/dashboard/exams/partials/forms/exam-settings-form.tsx index bd94d426..483f6d18 100644 --- a/resources/js/pages/dashboard/exams/partials/forms/exam-settings-form.tsx +++ b/resources/js/pages/dashboard/exams/partials/forms/exam-settings-form.tsx @@ -12,6 +12,8 @@ interface Props { } const ExamSettingsForm = ({ data, setData, errors }: Props) => { + const attemptsValue = Number.isFinite(data.max_attempts) ? data.max_attempts : 0; + return (
@@ -79,16 +81,18 @@ const ExamSettingsForm = ({ data, setData, errors }: Props) => {
setData('max_attempts', values[0])} - min={1} + min={0} max={10} step={1} className="py-4" />
- 1 attempt - {data.max_attempts || 1} attempt(s) + 0 (Unlimited) + + {attemptsValue === 0 ? 'Unlimited attempts' : `${attemptsValue} attempt(s)`} + 10 attempts
diff --git a/resources/js/pages/dashboard/exams/partials/tabs-content/settings.tsx b/resources/js/pages/dashboard/exams/partials/tabs-content/settings.tsx index 51ad97e9..3cdcb25c 100644 --- a/resources/js/pages/dashboard/exams/partials/tabs-content/settings.tsx +++ b/resources/js/pages/dashboard/exams/partials/tabs-content/settings.tsx @@ -15,7 +15,7 @@ const ExamSettings = () => { duration_hours: exam.duration_hours || 1, duration_minutes: exam.duration_minutes || 0, pass_mark: exam.pass_mark || 50, - max_attempts: exam.max_attempts || 3, + max_attempts: exam.max_attempts ?? 3, total_marks: exam.total_marks || 100, }); @@ -77,12 +77,15 @@ const ExamSettings = () => { type="number" name="max_attempts" value={data.max_attempts.toString()} - onChange={(e) => setData('max_attempts', parseInt(e.target.value) || 1)} - placeholder="3" - min="1" + onChange={(e) => { + const value = parseInt(e.target.value, 10); + setData('max_attempts', Number.isNaN(value) ? 0 : value); + }} + placeholder="0" + min="0" /> -

Maximum number of attempts allowed per student

+

0 = Unlimited attempts

diff --git a/resources/js/pages/dashboard/exams/show.tsx b/resources/js/pages/dashboard/exams/show.tsx index 9d5658f6..e6c5be5e 100644 --- a/resources/js/pages/dashboard/exams/show.tsx +++ b/resources/js/pages/dashboard/exams/show.tsx @@ -110,7 +110,7 @@ const ShowExam = ({ exam, stats }: Props) => {

Max Attempts

-

{exam.max_attempts}

+

{exam.max_attempts === 0 ? 'Unlimited' : exam.max_attempts}

Level

diff --git a/resources/js/pages/exams/partials/details.tsx b/resources/js/pages/exams/partials/details.tsx index b5119333..81dc71ef 100644 --- a/resources/js/pages/exams/partials/details.tsx +++ b/resources/js/pages/exams/partials/details.tsx @@ -38,7 +38,9 @@ const Details = () => {

Max Attempts

-

{exam.max_attempts} attempts

+

+ {exam.max_attempts === 0 ? 'Unlimited attempts' : `${exam.max_attempts} attempts`} +

diff --git a/resources/js/pages/exams/partials/exam-preview.tsx b/resources/js/pages/exams/partials/exam-preview.tsx index 7b262eb2..36eee9f8 100644 --- a/resources/js/pages/exams/partials/exam-preview.tsx +++ b/resources/js/pages/exams/partials/exam-preview.tsx @@ -109,7 +109,7 @@ const CoursePreview = () => {
- {exam.max_attempts} attempts allowed + {exam.max_attempts === 0 ? 'Unlimited attempts' : `${exam.max_attempts} attempts allowed`}