Skip to content

Commit e32e26c

Browse files
committed
feat: #365 allow unlimited attempt for quizzes
1 parent dab23fe commit e32e26c

13 files changed

Lines changed: 255 additions & 34 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Generated by Django 6.0.4 on 2026-04-20 07:55
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("django_email_learning", "0008_organization_is_public"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="quiz",
14+
name="limited_attempts",
15+
field=models.BooleanField(default=True),
16+
),
17+
]

django_email_learning/models.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,7 @@ class Quiz(models.Model):
342342
help_text="Time limit to complete the quiz in days. Minimum is 1 day and maximum is 30 days.",
343343
validators=[MinValueValidator(1), MaxValueValidator(30)],
344344
)
345+
limited_attempts = models.BooleanField(default=True)
345346

346347
class Meta:
347348
verbose_name_plural = "Quizzes"
@@ -437,6 +438,12 @@ def title(self) -> str:
437438
return self.quiz.title
438439
return "Untitled Content"
439440

441+
@property
442+
def limited_attempts(self) -> Optional[bool]:
443+
if self.type == "quiz" and self.quiz:
444+
return self.quiz.limited_attempts
445+
return None
446+
440447
def human_readable_waiting_period(self) -> str:
441448
if self.waiting_period < 60:
442449
return ngettext(
@@ -911,7 +918,10 @@ def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
911918
already_submitted = QuizSubmission.objects.filter(
912919
delivery=self.delivery
913920
).count()
914-
if already_submitted >= self.delivery.times_delivered:
921+
if (
922+
self.delivery.course_content.quiz.limited_attempts # type: ignore[union-attr]
923+
and already_submitted >= self.delivery.times_delivered
924+
):
915925
raise ValidationError(
916926
"Quiz submission count exceeds the number of times the quiz was sent."
917927
)

django_email_learning/personalised/api/views.py

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -82,39 +82,47 @@ def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
8282
score=score,
8383
is_passed=passed,
8484
)
85-
# Updating the ContentDelivery hash value to invalidate the quiz link
86-
delivery.update_hash()
8785

8886
if passed:
87+
delivery.update_hash() # Invalidate the quiz link after successful submission
8988
message = _("Congratulations! You have passed the quiz.")
9089
delivery = delivery.schedule_next_delivery()
90+
9191
if not delivery:
9292
enrollment.graduate()
9393
else:
94-
# Check if it's the second attempt failing
9594
failed_submissions_count = QuizSubmission.objects.filter(
9695
delivery=delivery,
9796
is_passed=False,
9897
).count()
9998

100-
if failed_submissions_count > 1:
101-
message = _(
102-
"You have failed the quiz twice. Unfortunately, you cannot continue this course with this enrollment. You can enroll again to retake the course."
103-
)
104-
logger.info(
105-
f"Learner ID {enrollment.learner.id} has failed the quiz twice for Course {enrollment.course.title}. "
106-
f"Marking enrollment as failed."
107-
)
108-
enrollment.fail()
99+
if quiz.limited_attempts:
100+
delivery.update_hash()
101+
102+
# Check if it's the second attempt failing
103+
104+
if failed_submissions_count > 1:
105+
message = _(
106+
"You have failed the quiz twice. Unfortunately, you cannot continue this course with this enrollment. You can enroll again to retake the course."
107+
)
108+
logger.info(
109+
f"Learner ID {enrollment.learner.id} has failed the quiz twice for Course {enrollment.course.title}. "
110+
f"Marking enrollment as failed."
111+
)
112+
enrollment.fail()
113+
else:
114+
message = _(
115+
"You have failed the quiz. You will receive another chance to retake it tomorrow."
116+
)
117+
logger.info(
118+
f"Learner ID {enrollment.learner.id} has failed the quiz for Course {enrollment.course.title}. "
119+
f"Scheduling a retry for the next day."
120+
)
121+
delivery.repeat_delivery_in_days(1)
109122
else:
110123
message = _(
111-
"You have failed the quiz. You will receive another chance to retake it tomorrow."
112-
)
113-
logger.info(
114-
f"Learner ID {enrollment.learner.id} has failed the quiz for Course {enrollment.course.title}. "
115-
f"Scheduling a retry for the next day."
124+
f"You have failed the quiz with score {score}. Please review the material and try again."
116125
)
117-
delivery.repeat_delivery_in_days(1)
118126

119127
METRIC_SERVICE.quiz_submitted(
120128
course_slug=enrollment.course.slug,
@@ -128,6 +136,7 @@ def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
128136
"passed": passed,
129137
"required_score": quiz.required_score,
130138
"message": message,
139+
"is_invalidated": quiz.limited_attempts and not passed,
131140
},
132141
status=200,
133142
)

django_email_learning/personalised/views.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ def get(self, request, *args, **kwargs) -> HttpResponse: # type: ignore[no-unty
158158
),
159159
"cancel": _("Cancel"),
160160
"submit": _("Submit"),
161+
"try_again": _("Try Again"),
161162
"close_window_message": _("You can now close this window!"),
162163
},
163164
}
@@ -166,6 +167,23 @@ def get(self, request, *args, **kwargs) -> HttpResponse: # type: ignore[no-unty
166167
)
167168

168169
except ContentDelivery.DoesNotExist as e:
170+
if ContentDelivery.objects.filter( # type: ignore[misc]
171+
id=decoded_token.get("delivery_id")
172+
).exists():
173+
return self.render_to_response(
174+
context={
175+
"appContext": {
176+
"errorMessage": _(
177+
"The quiz link has already been used and is not valid anymore."
178+
),
179+
"localeMessages": {
180+
"error": _("Error"),
181+
},
182+
},
183+
"page_title": _("Quiz Link Expired"),
184+
},
185+
status=410,
186+
)
169187
return self.error_response(
170188
message=_("An error occurred while retrieving the quiz"),
171189
exception=e,

django_email_learning/platform/api/serializers.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -554,12 +554,13 @@ def serialize_answers(self, answers: Any) -> list[dict]:
554554

555555

556556
class UpdateQuiz(BaseModel):
557-
questions: Optional[list[QuestionUpdate]] = Field(min_length=1)
557+
questions: Optional[list[QuestionUpdate]] = Field(min_length=1, default=None)
558558
title: Optional[str] = None
559559
required_score: Optional[int] = Field(ge=0, examples=[80], default=None)
560-
selection_strategy: QuizSelectionStrategy
561-
deadline_days: int = Field(
562-
ge=MIN_QUIZ_DEADLINE, le=MAX_QUIZ_DEADLINE, examples=[14]
560+
selection_strategy: Optional[QuizSelectionStrategy] = None
561+
limited_attempts: Optional[bool] = None
562+
deadline_days: Optional[int] = Field(
563+
ge=MIN_QUIZ_DEADLINE, le=MAX_QUIZ_DEADLINE, examples=[14], default=None
563564
)
564565

565566
model_config = ConfigDict(extra="forbid")
@@ -574,6 +575,7 @@ class QuizCreate(BaseModel):
574575
)
575576
questions: list[QuestionCreate] = Field(min_length=1)
576577
type: Literal["quiz"] = "quiz"
578+
limited_attempts: bool = Field(default=True, examples=[True])
577579

578580

579581
class QuizResponse(BaseModel):
@@ -583,6 +585,7 @@ class QuizResponse(BaseModel):
583585
selection_strategy: str
584586
deadline_days: int = Field(ge=MIN_QUIZ_DEADLINE, le=MAX_QUIZ_DEADLINE)
585587
questions: Any # Will be converted to list in field_serializer
588+
limited_attempts: bool
586589

587590
@field_serializer("questions")
588591
def serialize_questions(self, questions: Any) -> list[dict]:
@@ -827,6 +830,7 @@ def to_django_model(self, course: Course) -> CourseContent:
827830
required_score=self.content.required_score,
828831
selection_strategy=self.content.selection_strategy.value, # type: ignore[misc]
829832
deadline_days=self.content.deadline_days, # type: ignore[misc]
833+
limited_attempts=self.content.limited_attempts, # type: ignore[misc]
830834
)
831835
quiz.save()
832836
for question_data in self.content.questions:
@@ -905,6 +909,7 @@ class CourseContentSummaryResponse(BaseModel):
905909
waiting_period: int
906910
is_published: bool
907911
type: str
912+
limited_attempts: Optional[bool] = None
908913

909914
@field_serializer("waiting_period")
910915
def serialize_waiting_period(self, waiting_period: int) -> dict:

django_email_learning/platform/api/views.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,8 @@ def _update_course_content_atomic(
291291
quiz.selection_strategy = quiz_serializer.selection_strategy.value
292292
if quiz_serializer.deadline_days is not None:
293293
quiz.deadline_days = quiz_serializer.deadline_days
294+
if quiz_serializer.limited_attempts is not None:
295+
quiz.limited_attempts = quiz_serializer.limited_attempts
294296
if quiz_serializer.questions is not None:
295297
question_ids = set()
296298
for question_data in quiz_serializer.questions:

django_email_learning/platform/views.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,9 @@ def get_context_data(self, **kwargs) -> dict: # type: ignore[no-untyped-def]
247247
context["appContext"]["courseTitle"] = course.title
248248
context["appContext"]["courseLanguage"] = course.language
249249
context["appContext"]["customComponent"] = None
250+
context["appContext"]["quizDefaults"] = {
251+
"limitedAttempts": True,
252+
}
250253
context["appContext"]["direction"] = (
251254
"rtl" if get_language_info(course.language)["bidi"] else "ltr"
252255
)
@@ -320,6 +323,20 @@ def get_locale_messages(self) -> Dict[str, str]:
320323
"add_question": _("Add Question"),
321324
"quiz_settings": _("Quiz Settings"),
322325
"waiting_period": _("Waiting Period"),
326+
"limited_attempts": _("Limited Attempts"),
327+
"unlimited_attempts": _("Unlimited Attempts"),
328+
"two_attempts": _("2 Attempts"),
329+
"limited_attempts_tooltip": _(
330+
"If limited attempts is enabled, learners only have 2 attempts to pass the quiz. "
331+
"After 2 failed attempts, they will fail the course and need to restart it. If limited attempts is disabled, "
332+
"learners can retry the quiz as many times as needed until they pass."
333+
),
334+
"quiz_2_attempts_sub_note": _(
335+
"Learners only have 2 attempts to pass the quiz. After 2 failed attempts, they will fail the course and need to restart it."
336+
),
337+
"quiz_unlimited_attempts_sub_note": _(
338+
"Learners can retry the quiz as many times as needed until they pass."
339+
),
323340
"quiz_waiting_tooltip": _(
324341
"Time to wait after the previous content delivery before sending this quiz"
325342
),

frontend/personalised/quiz_public/Quiz.jsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import render, { useAppContext } from '../../src/render.jsx';
22
import { use, useEffect, useState } from 'react';
33
import Layout from '../../public/components/Layout.jsx';
4-
import { Alert, Box, Button, Checkbox, FormControlLabel, Typography, Dialog } from '@mui/material';
4+
import { Alert, Box, Button, Checkbox, FormControlLabel, Typography, Dialog, Link } from '@mui/material';
55
import CelebrationIcon from '@mui/icons-material/Celebration';
66
import SentimentVeryDissatisfiedIcon from '@mui/icons-material/SentimentVeryDissatisfied';
77

@@ -14,6 +14,7 @@ const Quiz = () => {
1414
const [showQuestions, setShowQuestions] = useState(true);
1515
const [isPassed, setIsPassed] = useState(null);
1616
const [message, setMessage] = useState("");
17+
const [isInvalidated, setIsInvalidated] = useState(true);
1718
const [score, setScore] = useState(null);
1819

1920

@@ -46,6 +47,7 @@ const Quiz = () => {
4647
setIsPassed(data.passed);
4748
setScore(data.score);
4849
setMessage(data.message);
50+
setIsInvalidated(data.is_invalidated);
4951
})
5052
.catch(() => {
5153
console.error("Error submitting quiz");
@@ -88,8 +90,10 @@ const Quiz = () => {
8890
</Box>
8991
</Box></> : <Box textAlign="center">
9092
{isPassed !== null && (isPassed ? <><CelebrationIcon sx={{mb: 2, color: 'primary.main', fontSize: '3rem'}}/><Alert severity="success" sx={{justifyContent: 'center', alignItems: 'center'}} ><Typography variant='h6'>{message} {localeMessages['your_score']}: {score}%</Typography></Alert></> :
91-
<Alert severity="error" sx={{ justifyContent: 'center', alignItems: 'center' }} ><Typography variant="h6">{message} {localeMessages['your_score']}: {score}%</Typography></Alert>)}
92-
<Box sx={{mt: 6, fontSize: '0.8rem'}}><Typography>{localeMessages['close_window_message']}</Typography></Box>
93+
<><Alert severity="error" sx={{ justifyContent: 'center', alignItems: 'center' }} ><Typography variant="h6">{message} {localeMessages['your_score']}: {score}%</Typography></Alert>
94+
{!isInvalidated && <Box mt={3} textAlign="center"><Button onClick={() => window.location.reload()} variant="contained">{localeMessages['try_again']}</Button></Box>}
95+
</>)}
96+
<Box sx={{mt: 6, fontSize: '0.8rem'}}>{ isInvalidated && <Typography>{localeMessages['close_window_message']}</Typography>}</Box>
9397
</Box>}
9498
</Box> : <Alert severity="error"><Typography variant="h6">{localeMessages['error']}: {errorMessage} {ref && `(Ref: ${ref})`}</Typography></Alert>}
9599
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="sm">

frontend/platform/course/Course.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ function Course() {
233233
initialWaitingPeriod={content.waiting_period}
234234
initialStrategy={content.quiz.selection_strategy}
235235
initialDeadlineDays={content.quiz.deadline_days}
236+
initialLimitedAttempts={content.quiz.limited_attempts}
236237
/></Suspense>);
237238
}
238239
}

frontend/platform/course/components/ContentTable.jsx

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { Alert, CircularProgress, IconButton, Switch, TableContainer, Table, TableHead, TableRow, TableBody, TableCell, Paper, Tooltip, Typography } from '@mui/material';
1+
import { Alert, Box, CircularProgress, Chip, IconButton, Switch, TableContainer, Table, TableHead, TableRow, TableBody, TableCell, Paper, Tooltip, Typography } from '@mui/material';
22
import { useState, useEffect } from 'react';
33
import { getCookie } from '../../../src/utils.js';
44
import DeleteIcon from '@mui/icons-material/Delete';
55
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
66
import ForwardToInboxOutlinedIcon from '@mui/icons-material/ForwardToInboxOutlined';
7+
78
import { useAppContext } from '../../../src/render.jsx';
89

910

@@ -23,6 +24,8 @@ const ContentTable = ({ courseId, eventHandler, loaded = false }) => {
2324
const { apiBaseUrl, userRole, localeMessages, direction } = useAppContext();
2425
const organizationId = localStorage.getItem('activeOrganizationId');
2526
const canSendLesson = userRole === 'admin' || userRole === 'editor';
27+
const showQuizTwoAttemptNote = contentList.some((content) => content.type === 'quiz' && content.limited_attempts == true);
28+
const showQuizUnlimitedAttemptsNote = contentList.some((content) => content.type === 'quiz' && content.limited_attempts == false);
2629

2730
const formatPeriod = (period) => {
2831
if (!period) {
@@ -233,8 +236,11 @@ const ContentTable = ({ courseId, eventHandler, loaded = false }) => {
233236
onMouseDown={(event) => startDrag(event, content.id)}
234237
/></TableCell>}
235238
<TableCell align={direction == 'rtl' ? 'right' : 'left'}><Typography
239+
component="span"
236240
onClick={() => {let event = {type: 'content_clicked', content_id: content.id}; eventHandler(event);}}
237-
sx={{ cursor: 'pointer', color: theme => theme.palette.mode === 'dark' ? theme.palette.secondary.main : theme.palette.secondary.dark }}>{content.title}</Typography></TableCell>
241+
sx={{ cursor: 'pointer', color: theme => theme.palette.mode === 'dark' ? theme.palette.secondary.main : theme.palette.secondary.dark }}>{content.title}
242+
{content.limited_attempts !== null && ( content.limited_attempts ? <Chip label={localeMessages["two_attempts"]} size="small" sx={(theme) => ({ ml: 1, backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255, 152, 0, 0.15)' : 'rgba(255, 203, 71, 0.5)', color: theme.palette.mode === 'dark' ? '#FF9800' : '#9a4208' })}
243+
/> : <Chip label={localeMessages["unlimited_attempts"]} size="small" sx={(theme) => ({ ml: 1, backgroundColor: theme.palette.mode === 'dark' ? 'rgba(76, 175, 80, 0.15)' : 'rgba(129, 199, 132, 0.5)', color: theme.palette.mode === 'dark' ? '#4CAF50' : '#256029' })} />)}</Typography></TableCell>
238244
<TableCell align={direction == 'rtl' ? 'right' : 'left'}>{formatPeriod(content.waiting_period)}</TableCell>
239245
<TableCell align={direction == 'rtl' ? 'right' : 'left'}>{localeMessages[content.type]}</TableCell>
240246
<TableCell align={direction == 'rtl' ? 'right' : 'left'}><Switch checked={content.is_published} onChange={() => TogglePublishContent(content.id, !content.is_published)} disabled={userRole == 'viewer'} /></TableCell>
@@ -265,6 +271,30 @@ const ContentTable = ({ courseId, eventHandler, loaded = false }) => {
265271
</TableBody>
266272
</Table>
267273
</TableContainer>
274+
{(showQuizTwoAttemptNote || showQuizUnlimitedAttemptsNote) && (
275+
<Box
276+
sx={{
277+
mt: 1.5,
278+
px: 1.5,
279+
py: 1,
280+
borderRadius: 1,
281+
backgroundColor: 'action.hover',
282+
border: '1px solid',
283+
borderColor: 'divider',
284+
}}
285+
>
286+
{showQuizTwoAttemptNote && (
287+
<Typography component="div" variant="caption" color="text.secondary" sx={{ display: 'block' }}>
288+
<Box component="span" sx={{ fontWeight: 600 }}>{localeMessages["two_attempts"]}:</Box> {localeMessages["quiz_2_attempts_sub_note"]}
289+
</Typography>
290+
)}
291+
{showQuizUnlimitedAttemptsNote && (
292+
<Typography component="div" variant="caption" color="text.secondary" sx={{ display: 'block', mt: showQuizTwoAttemptNote ? 0.5 : 0 }}>
293+
<Box component="span" sx={{ fontWeight: 600 }}>{localeMessages["unlimited_attempts"]}:</Box> {localeMessages["quiz_unlimited_attempts_sub_note"]}
294+
</Typography>
295+
)}
296+
</Box>
297+
)}
268298
</>
269299
);
270300
}

0 commit comments

Comments
 (0)