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,
|
'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',
|
||||||
|
|||||||
@ -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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,14 +12,16 @@ class ExamAttemptService
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Start a new exam attempt
|
* Start a new exam attempt
|
||||||
*/
|
*/
|
||||||
public function startAttempt(User $user, Exam $exam): ?ExamAttempt
|
public function startAttempt(User $user, Exam $exam): ?ExamAttempt
|
||||||
{
|
{
|
||||||
$previousAttempts = ExamAttempt::where('user_id', $user->id)
|
$previousAttempts = ExamAttempt::where('user_id', $user->id)
|
||||||
->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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
->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,
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user