unlimited quizzes and exams
All checks were successful
Build & Push Docker Image / docker (push) Successful in 3m22s

This commit is contained in:
Ahmed Darrazi 2025-12-19 00:59:28 +01:00
parent 5b4470a323
commit 70a970cee6
16 changed files with 56 additions and 34 deletions

View File

@ -24,7 +24,7 @@ class ExamRequest extends FormRequest
'duration_minutes' => request('duration_minutes') ? (int) request('duration_minutes') : 0, 'duration_minutes' => request('duration_minutes') ? (int) request('duration_minutes') : 0,
'pass_mark' => request('pass_mark') ? (float) request('pass_mark') : null, 'pass_mark' => request('pass_mark') ? (float) request('pass_mark') : null,
'total_marks' => request('total_marks') ? (float) request('total_marks') : 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', 'duration_minutes' => 'required|integer|min:0|max:59',
'pass_mark' => 'required|numeric|min:0|max:100', 'pass_mark' => 'required|numeric|min:0|max:100',
'total_marks' => 'required|numeric|min:1', 'total_marks' => 'required|numeric|min:1',
'max_attempts' => 'required|integer|min:1', 'max_attempts' => 'required|integer|min:0',
// Status & Level // Status & Level
'status' => 'nullable|string|in:draft,published,archived', 'status' => 'nullable|string|in:draft,published,archived',

View File

@ -19,7 +19,7 @@ class UpdateExamRequest extends FormRequest
'duration_minutes' => request('duration_minutes') ? (int) request('duration_minutes') : 0, 'duration_minutes' => request('duration_minutes') ? (int) request('duration_minutes') : 0,
'pass_mark' => request('pass_mark') ? (float) request('pass_mark') : null, 'pass_mark' => request('pass_mark') ? (float) request('pass_mark') : null,
'total_marks' => request('total_marks') ? (float) request('total_marks') : 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', 'duration_minutes' => 'required|integer|min:0|max:59',
'pass_mark' => 'required|numeric|min:0|max:100', 'pass_mark' => 'required|numeric|min:0|max:100',
'total_marks' => 'required|numeric|min:1', 'total_marks' => 'required|numeric|min:1',
'max_attempts' => 'required|integer|min:1', 'max_attempts' => 'required|integer|min:0',
]; ];
} }

View File

@ -19,7 +19,9 @@ class ExamAttemptService
->where('exam_id', $exam->id) ->where('exam_id', $exam->id)
->count(); ->count();
if ($previousAttempts >= $exam->max_attempts) { $hasAttemptLimit = $exam->max_attempts > 0;
if ($hasAttemptLimit && $previousAttempts >= $exam->max_attempts) {
return null; return null;
} }

View File

@ -112,7 +112,9 @@ class ExamEnrollmentService extends MediaService
'enrollment' => $enrollment, 'enrollment' => $enrollment,
'is_active' => $enrollment->isActive(), 'is_active' => $enrollment->isActive(),
'attempts_used' => $attempts->count(), '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, 'completed_attempts' => $completedAttempts,
'best_score' => $bestScore, 'best_score' => $bestScore,
'has_passed' => $hasPassed, 'has_passed' => $hasPassed,

View File

@ -63,7 +63,7 @@ class StoreQuizRequest extends FormRequest
} }
} }
], ],
'retake' => 'required|numeric|min:1', 'retake' => 'required|numeric|min:0',
]; ];
} }
} }

View File

@ -62,7 +62,7 @@ class UpdateQuizRequest extends FormRequest
} }
} }
], ],
'retake' => 'required|numeric|min:1', 'retake' => 'required|numeric|min:0',
]; ];
} }
} }

View File

@ -50,13 +50,15 @@ class SectionQuizService extends CourseSectionService
->where('section_quiz_id', $quiz->id) ->where('section_quiz_id', $quiz->id)
->first(); ->first();
$hasAttemptLimit = $quiz->retake > 0;
// Get or create quiz submission // Get or create quiz submission
if ($submission) { if ($submission) {
if ($submission->attempts >= $quiz->retake) { if ($hasAttemptLimit && $submission->attempts >= $quiz->retake) {
return false; return false;
} else {
$submission->increment('attempts');
} }
$submission->increment('attempts');
} else { } else {
$submission = QuizSubmission::create([ $submission = QuizSubmission::create([
'section_quiz_id' => $quiz->id, 'section_quiz_id' => $quiz->id,

View File

@ -62,7 +62,7 @@ const ExamCard7 = ({ exam, attempts, bestAttempt, className }: Props) => {
<p className="text-muted-foreground flex items-center justify-between text-sm font-medium"> <p className="text-muted-foreground flex items-center justify-between text-sm font-medium">
<span>Attempts</span> <span>Attempts</span>
<span> <span>
{totalAttempts} / {exam.max_attempts} {exam.max_attempts === 0 ? `${totalAttempts} / Unlimited` : `${totalAttempts} / ${exam.max_attempts}`}
</span> </span>
</p> </p>
<Progress value={progressPercentage} className="h-1.5" /> <Progress value={progressPercentage} className="h-1.5" />
@ -87,7 +87,7 @@ const ExamCard7 = ({ exam, attempts, bestAttempt, className }: Props) => {
)} )}
</div> </div>
{totalAttempts < exam.max_attempts && ( {(exam.max_attempts === 0 || totalAttempts < exam.max_attempts) && (
<ButtonGradientPrimary <ButtonGradientPrimary
asChild asChild
shadow={false} shadow={false}

View File

@ -29,6 +29,8 @@ const QuizViewer = ({ quiz }: QuizViewerProps) => {
const [finished, setFinished] = useState(false); const [finished, setFinished] = useState(false);
const [currentTab, setCurrentTab] = useState('summary'); const [currentTab, setCurrentTab] = useState('summary');
const submissions = quiz.quiz_submissions; const submissions = quiz.quiz_submissions;
const attemptsUsed = submissions[0]?.attempts || 0;
const hasAttemptLimit = quiz.retake > 0;
const { data, setData, post, reset, processing } = useForm({ const { data, setData, post, reset, processing } = useForm({
submission_id: submissions.length > 0 ? submissions[0].id : null, submission_id: submissions.length > 0 ? submissions[0].id : null,
@ -188,7 +190,7 @@ const QuizViewer = ({ quiz }: QuizViewerProps) => {
</div> </div>
<div className="flex gap-2 text-sm"> <div className="flex gap-2 text-sm">
<p className="text-gray-500">{frontend.retake}</p> <p className="text-gray-500">{frontend.retake}</p>
<p>: {quiz.retake}</p> <p>: {hasAttemptLimit ? quiz.retake : 'Unlimited'}</p>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -196,7 +198,7 @@ const QuizViewer = ({ quiz }: QuizViewerProps) => {
<div className="flex gap-2 text-sm"> <div className="flex gap-2 text-sm">
<p className="text-gray-500">{frontend.retake_attempts}</p> <p className="text-gray-500">{frontend.retake_attempts}</p>
<p>: {submissions[0]?.attempts || 0}</p> <p>: {attemptsUsed}</p>
</div> </div>
<div className="flex gap-2 text-sm"> <div className="flex gap-2 text-sm">
<p className="text-gray-500">{frontend.correct_answers}</p> <p className="text-gray-500">{frontend.correct_answers}</p>
@ -218,7 +220,7 @@ const QuizViewer = ({ quiz }: QuizViewerProps) => {
</div> </div>
<div className="flex justify-center p-6"> <div className="flex justify-center p-6">
{submissions[0]?.attempts >= quiz.retake ? ( {hasAttemptLimit && attemptsUsed >= quiz.retake ? (
<Button type="button" size="lg"> <Button type="button" size="lg">
{frontend.quiz_submitted} {frontend.quiz_submitted}
</Button> </Button>

View File

@ -31,7 +31,7 @@ const QuizForm = ({ title, quiz, handler, sectionId }: Props) => {
course_id: props.course.id, course_id: props.course.id,
total_mark: quiz?.total_mark || '', total_mark: quiz?.total_mark || '',
pass_mark: quiz?.pass_mark || '', pass_mark: quiz?.pass_mark || '',
retake: quiz?.retake || 1, retake: quiz?.retake ?? 1,
summary: quiz?.summary || '', summary: quiz?.summary || '',
hours: quiz?.hours || '', hours: quiz?.hours || '',
minutes: quiz?.minutes || '', minutes: quiz?.minutes || '',
@ -135,7 +135,7 @@ const QuizForm = ({ title, quiz, handler, sectionId }: Props) => {
<div> <div>
<Label>{input.retake_attempts}</Label> <Label>{input.retake_attempts}</Label>
<Input <Input
min="1" min="0"
required required
type="number" type="number"
name="retake" name="retake"
@ -143,6 +143,7 @@ const QuizForm = ({ title, quiz, handler, sectionId }: Props) => {
placeholder="00" placeholder="00"
onChange={(e) => onHandleChange(e, setData)} onChange={(e) => onHandleChange(e, setData)}
/> />
<p className="mt-1 text-xs text-gray-500">0 = Unlimited attempts</p>
<InputError message={errors.retake} /> <InputError message={errors.retake} />
</div> </div>
</div> </div>

View File

@ -218,10 +218,14 @@ const CreateExam = (props: Props) => {
type="number" type="number"
name="max_attempts" name="max_attempts"
value={data.max_attempts.toString()} value={data.max_attempts.toString()}
onChange={(e) => setData('max_attempts', parseInt(e.target.value) || 1)} onChange={(e) => {
placeholder="3" const value = parseInt(e.target.value, 10);
min="1" setData('max_attempts', Number.isNaN(value) ? 0 : value);
}}
placeholder="0"
min="0"
/> />
<p className="mt-1 text-xs text-gray-500">0 = Unlimited attempts</p>
<InputError message={errors.max_attempts} /> <InputError message={errors.max_attempts} />
</div> </div>

View File

@ -12,6 +12,8 @@ interface Props {
} }
const ExamSettingsForm = ({ data, setData, errors }: Props) => { const ExamSettingsForm = ({ data, setData, errors }: Props) => {
const attemptsValue = Number.isFinite(data.max_attempts) ? data.max_attempts : 0;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@ -79,16 +81,18 @@ const ExamSettingsForm = ({ data, setData, errors }: Props) => {
<Label htmlFor="max_attempts">Maximum Attempts Allowed *</Label> <Label htmlFor="max_attempts">Maximum Attempts Allowed *</Label>
<div className="space-y-2"> <div className="space-y-2">
<Slider <Slider
value={[data.max_attempts || 1]} value={[attemptsValue]}
onValueChange={(values) => setData('max_attempts', values[0])} onValueChange={(values) => setData('max_attempts', values[0])}
min={1} min={0}
max={10} max={10}
step={1} step={1}
className="py-4" className="py-4"
/> />
<div className="flex justify-between text-sm text-gray-600"> <div className="flex justify-between text-sm text-gray-600">
<span>1 attempt</span> <span>0 (Unlimited)</span>
<span className="font-semibold text-gray-900">{data.max_attempts || 1} attempt(s)</span> <span className="font-semibold text-gray-900">
{attemptsValue === 0 ? 'Unlimited attempts' : `${attemptsValue} attempt(s)`}
</span>
<span>10 attempts</span> <span>10 attempts</span>
</div> </div>
</div> </div>

View File

@ -15,7 +15,7 @@ const ExamSettings = () => {
duration_hours: exam.duration_hours || 1, duration_hours: exam.duration_hours || 1,
duration_minutes: exam.duration_minutes || 0, duration_minutes: exam.duration_minutes || 0,
pass_mark: exam.pass_mark || 50, pass_mark: exam.pass_mark || 50,
max_attempts: exam.max_attempts || 3, max_attempts: exam.max_attempts ?? 3,
total_marks: exam.total_marks || 100, total_marks: exam.total_marks || 100,
}); });
@ -77,12 +77,15 @@ const ExamSettings = () => {
type="number" type="number"
name="max_attempts" name="max_attempts"
value={data.max_attempts.toString()} value={data.max_attempts.toString()}
onChange={(e) => setData('max_attempts', parseInt(e.target.value) || 1)} onChange={(e) => {
placeholder="3" const value = parseInt(e.target.value, 10);
min="1" setData('max_attempts', Number.isNaN(value) ? 0 : value);
}}
placeholder="0"
min="0"
/> />
<InputError message={errors.max_attempts} /> <InputError message={errors.max_attempts} />
<p className="mt-1 text-xs text-gray-500">Maximum number of attempts allowed per student</p> <p className="mt-1 text-xs text-gray-500">0 = Unlimited attempts</p>
</div> </div>
<div> <div>

View File

@ -110,7 +110,7 @@ const ShowExam = ({ exam, stats }: Props) => {
</div> </div>
<div> <div>
<p className="text-sm text-gray-600">Max Attempts</p> <p className="text-sm text-gray-600">Max Attempts</p>
<p className="font-semibold">{exam.max_attempts}</p> <p className="font-semibold">{exam.max_attempts === 0 ? 'Unlimited' : exam.max_attempts}</p>
</div> </div>
<div> <div>
<p className="text-sm text-gray-600">Level</p> <p className="text-sm text-gray-600">Level</p>

View File

@ -38,7 +38,9 @@ const Details = () => {
<Award className="mt-1 h-5 w-5 text-gray-400" /> <Award className="mt-1 h-5 w-5 text-gray-400" />
<div> <div>
<p className="font-semibold">Max Attempts</p> <p className="font-semibold">Max Attempts</p>
<p className="text-sm text-gray-600">{exam.max_attempts} attempts</p> <p className="text-sm text-gray-600">
{exam.max_attempts === 0 ? 'Unlimited attempts' : `${exam.max_attempts} attempts`}
</p>
</div> </div>
</div> </div>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">

View File

@ -109,7 +109,7 @@ const CoursePreview = () => {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Award className="h-4 w-4 text-gray-400" /> <Award className="h-4 w-4 text-gray-400" />
<span>{exam.max_attempts} attempts allowed</span> <span>{exam.max_attempts === 0 ? 'Unlimited attempts' : `${exam.max_attempts} attempts allowed`}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Target className="h-4 w-4 text-gray-400" /> <Target className="h-4 w-4 text-gray-400" />