unlimited quizzes and exams
All checks were successful
Build & Push Docker Image / docker (push) Successful in 3m22s
All checks were successful
Build & Push Docker Image / docker (push) Successful in 3m22s
This commit is contained in:
parent
5b4470a323
commit
70a970cee6
@ -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',
|
||||
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -19,7 +19,9 @@ class ExamAttemptService
|
||||
->where('exam_id', $exam->id)
|
||||
->count();
|
||||
|
||||
if ($previousAttempts >= $exam->max_attempts) {
|
||||
$hasAttemptLimit = $exam->max_attempts > 0;
|
||||
|
||||
if ($hasAttemptLimit && $previousAttempts >= $exam->max_attempts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -63,7 +63,7 @@ class StoreQuizRequest extends FormRequest
|
||||
}
|
||||
}
|
||||
],
|
||||
'retake' => 'required|numeric|min:1',
|
||||
'retake' => 'required|numeric|min:0',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,7 +62,7 @@ class UpdateQuizRequest extends FormRequest
|
||||
}
|
||||
}
|
||||
],
|
||||
'retake' => 'required|numeric|min:1',
|
||||
'retake' => 'required|numeric|min:0',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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">
|
||||
<span>Attempts</span>
|
||||
<span>
|
||||
{totalAttempts} / {exam.max_attempts}
|
||||
{exam.max_attempts === 0 ? `${totalAttempts} / Unlimited` : `${totalAttempts} / ${exam.max_attempts}`}
|
||||
</span>
|
||||
</p>
|
||||
<Progress value={progressPercentage} className="h-1.5" />
|
||||
@ -87,7 +87,7 @@ const ExamCard7 = ({ exam, attempts, bestAttempt, className }: Props) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalAttempts < exam.max_attempts && (
|
||||
{(exam.max_attempts === 0 || totalAttempts < exam.max_attempts) && (
|
||||
<ButtonGradientPrimary
|
||||
asChild
|
||||
shadow={false}
|
||||
|
||||
@ -29,6 +29,8 @@ const QuizViewer = ({ quiz }: QuizViewerProps) => {
|
||||
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) => {
|
||||
</div>
|
||||
<div className="flex gap-2 text-sm">
|
||||
<p className="text-gray-500">{frontend.retake}</p>
|
||||
<p>: {quiz.retake}</p>
|
||||
<p>: {hasAttemptLimit ? quiz.retake : 'Unlimited'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@ -196,7 +198,7 @@ const QuizViewer = ({ quiz }: QuizViewerProps) => {
|
||||
|
||||
<div className="flex gap-2 text-sm">
|
||||
<p className="text-gray-500">{frontend.retake_attempts}</p>
|
||||
<p>: {submissions[0]?.attempts || 0}</p>
|
||||
<p>: {attemptsUsed}</p>
|
||||
</div>
|
||||
<div className="flex gap-2 text-sm">
|
||||
<p className="text-gray-500">{frontend.correct_answers}</p>
|
||||
@ -218,7 +220,7 @@ const QuizViewer = ({ quiz }: QuizViewerProps) => {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center p-6">
|
||||
{submissions[0]?.attempts >= quiz.retake ? (
|
||||
{hasAttemptLimit && attemptsUsed >= quiz.retake ? (
|
||||
<Button type="button" size="lg">
|
||||
{frontend.quiz_submitted}
|
||||
</Button>
|
||||
|
||||
@ -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) => {
|
||||
<div>
|
||||
<Label>{input.retake_attempts}</Label>
|
||||
<Input
|
||||
min="1"
|
||||
min="0"
|
||||
required
|
||||
type="number"
|
||||
name="retake"
|
||||
@ -143,6 +143,7 @@ const QuizForm = ({ title, quiz, handler, sectionId }: Props) => {
|
||||
placeholder="00"
|
||||
onChange={(e) => onHandleChange(e, setData)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">0 = Unlimited attempts</p>
|
||||
<InputError message={errors.retake} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">0 = Unlimited attempts</p>
|
||||
<InputError message={errors.max_attempts} />
|
||||
</div>
|
||||
|
||||
|
||||
@ -12,6 +12,8 @@ interface Props {
|
||||
}
|
||||
|
||||
const ExamSettingsForm = ({ data, setData, errors }: Props) => {
|
||||
const attemptsValue = Number.isFinite(data.max_attempts) ? data.max_attempts : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-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>
|
||||
<div className="space-y-2">
|
||||
<Slider
|
||||
value={[data.max_attempts || 1]}
|
||||
value={[attemptsValue]}
|
||||
onValueChange={(values) => setData('max_attempts', values[0])}
|
||||
min={1}
|
||||
min={0}
|
||||
max={10}
|
||||
step={1}
|
||||
className="py-4"
|
||||
/>
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>1 attempt</span>
|
||||
<span className="font-semibold text-gray-900">{data.max_attempts || 1} attempt(s)</span>
|
||||
<span>0 (Unlimited)</span>
|
||||
<span className="font-semibold text-gray-900">
|
||||
{attemptsValue === 0 ? 'Unlimited attempts' : `${attemptsValue} attempt(s)`}
|
||||
</span>
|
||||
<span>10 attempts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
<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>
|
||||
|
||||
@ -110,7 +110,7 @@ const ShowExam = ({ exam, stats }: Props) => {
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<p className="text-sm text-gray-600">Level</p>
|
||||
|
||||
@ -38,7 +38,9 @@ const Details = () => {
|
||||
<Award className="mt-1 h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<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 className="flex items-start gap-3">
|
||||
|
||||
@ -109,7 +109,7 @@ const CoursePreview = () => {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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 className="flex items-center gap-2">
|
||||
<Target className="h-4 w-4 text-gray-400" />
|
||||
|
||||
Loading…
Reference in New Issue
Block a user