Skip to content

Commit 66021d6

Browse files
authored
Merge pull request #367 from AvaCodeSolutions/feat/245/non-blocking-quiz
feat: #245 Add non blocking quizzes option
2 parents a98d725 + 47eb857 commit 66021d6

22 files changed

Lines changed: 444 additions & 89 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-21 13:56
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("django_email_learning", "0009_quiz_limited_attempts"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="quiz",
14+
name="is_blocking",
15+
field=models.BooleanField(default=True),
16+
),
17+
]

django_email_learning/models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,7 @@ class Quiz(models.Model):
343343
validators=[MinValueValidator(1), MaxValueValidator(30)],
344344
)
345345
limited_attempts = models.BooleanField(default=True)
346+
is_blocking = models.BooleanField(default=True)
346347

347348
class Meta:
348349
verbose_name_plural = "Quizzes"
@@ -444,6 +445,12 @@ def limited_attempts(self) -> Optional[bool]:
444445
return self.quiz.limited_attempts
445446
return None
446447

448+
@property
449+
def is_blocking(self) -> Optional[bool]:
450+
if self.type == "quiz" and self.quiz:
451+
return self.quiz.is_blocking
452+
return None
453+
447454
def human_readable_waiting_period(self) -> str:
448455
if self.waiting_period < 60:
449456
return ngettext(

django_email_learning/personalised/api/views.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
3636
token = serializer.token
3737
answers = serializer.answers
3838

39+
submited_question_ids = {response.id for response in answers}
40+
response_map = {response.id: response.answers for response in answers}
41+
3942
try:
4043
decoded = jwt_service.decode_jwt(token=token)
4144
except jwt_service.InvalidTokenException as jde:
@@ -83,10 +86,19 @@ def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
8386
is_passed=passed,
8487
)
8588

86-
if passed:
87-
delivery.update_hash() # Invalidate the quiz link after successful submission
88-
message = _("Congratulations! You have passed the quiz.")
89-
delivery = delivery.schedule_next_delivery()
89+
if passed or not quiz.is_blocking:
90+
if quiz.is_blocking:
91+
delivery.update_hash() # Invalidate the quiz link after successful submission
92+
message = _("Congratulations! You have passed the quiz.")
93+
else:
94+
message = _("Your quiz submission has been recorded.")
95+
if QuizSubmission.objects.filter(delivery=delivery).count() >= 10:
96+
delivery.update_hash() # Invalidate the quiz link after 10 attempts to prevent abuse
97+
98+
if (quiz.is_blocking and passed) or QuizSubmission.objects.filter(
99+
delivery=delivery
100+
).count() == 1:
101+
delivery = delivery.schedule_next_delivery()
90102

91103
if not delivery:
92104
enrollment.graduate()
@@ -129,6 +141,7 @@ def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
129141
organization_id=enrollment.course.organization.id,
130142
quiz_id=quiz.id,
131143
is_passed=passed,
144+
is_blocking=quiz.is_blocking,
132145
)
133146
return JsonResponse(
134147
{
@@ -137,6 +150,30 @@ def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
137150
"required_score": quiz.required_score,
138151
"message": message,
139152
"is_invalidated": quiz.limited_attempts and not passed,
153+
"is_blocking": quiz.is_blocking,
154+
"quiz_data": {
155+
"id": quiz.id,
156+
"title": quiz.title,
157+
"questions": [
158+
{
159+
"text": question.text,
160+
"answers": [
161+
{
162+
"text": answer.text,
163+
"is_correct": answer.is_correct,
164+
"user_selected": answer.id
165+
in response_map.get(question.id, set()),
166+
}
167+
for answer in question.answers.all()
168+
],
169+
}
170+
for question in quiz.questions.filter(
171+
id__in=submited_question_ids
172+
)
173+
],
174+
}
175+
if not quiz.is_blocking
176+
else None,
140177
},
141178
status=200,
142179
)

django_email_learning/personalised/views.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@ def get(self, request, *args, **kwargs) -> HttpResponse: # type: ignore[no-unty
160160
"submit": _("Submit"),
161161
"try_again": _("Try Again"),
162162
"close_window_message": _("You can now close this window!"),
163+
"non_blocking_quiz_caption": _(
164+
"This quiz is for practice and does not affect your course progress. The course content will be sent to you regardless of your quiz answers."
165+
),
163166
},
164167
}
165168
| self.get_app_context(),

django_email_learning/platform/api/serializers.py

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,7 @@ class UpdateQuiz(BaseModel):
562562
deadline_days: Optional[int] = Field(
563563
ge=MIN_QUIZ_DEADLINE, le=MAX_QUIZ_DEADLINE, examples=[14], default=None
564564
)
565+
is_blocking: Optional[bool] = None
565566

566567
model_config = ConfigDict(extra="forbid")
567568

@@ -576,6 +577,7 @@ class QuizCreate(BaseModel):
576577
questions: list[QuestionCreate] = Field(min_length=1)
577578
type: Literal["quiz"] = "quiz"
578579
limited_attempts: bool = Field(default=True, examples=[True])
580+
is_blocking: bool = Field(default=True, examples=[True])
579581

580582

581583
class QuizResponse(BaseModel):
@@ -586,6 +588,7 @@ class QuizResponse(BaseModel):
586588
deadline_days: int = Field(ge=MIN_QUIZ_DEADLINE, le=MAX_QUIZ_DEADLINE)
587589
questions: Any # Will be converted to list in field_serializer
588590
limited_attempts: bool
591+
is_blocking: bool
589592

590593
@field_serializer("questions")
591594
def serialize_questions(self, questions: Any) -> list[dict]:
@@ -672,6 +675,7 @@ class QuizSubmitedEvent(BaseModel):
672675
score: int
673676
is_passed: bool
674677
attempt_number: int
678+
is_practice: bool
675679

676680

677681
class ContentSentEvent(BaseModel):
@@ -715,7 +719,7 @@ def from_django_model(enrollment: Enrollment) -> "EnrollmentResponse":
715719
event_data=None,
716720
)
717721
)
718-
for delivery in enrollment.content_deliveries.all(): # type: ignore[attr-defined]
722+
for delivery in enrollment.content_deliveries.all().order_by("id"): # type: ignore[attr-defined]
719723
schedule_no = 0
720724
for schedule in delivery.delivery_schedules.filter(
721725
status=DeliveryStatus.DELIVERED
@@ -740,26 +744,34 @@ def from_django_model(enrollment: Enrollment) -> "EnrollmentResponse":
740744
"submitted_at"
741745
)
742746
attempt = None
743-
if schedule_no == 1:
744-
attempt = quiz_attempts.first()
747+
if delivery.course_content.quiz.is_blocking: # type: ignore[union-attr]
748+
if schedule_no == 1:
749+
attempts = [quiz_attempts.first()]
750+
attempt_number = 1
751+
elif schedule_no > 1:
752+
attempt_number = schedule_no
753+
attempts = list(quiz_attempts[1:])
754+
else:
745755
attempt_number = 1
746-
elif schedule_no > 1:
747-
attempt_number = schedule_no
748-
attempt = quiz_attempts[attempt_number - 1 :].first()
749-
if attempt:
750-
events.append(
751-
Event(
752-
type=EventType.QUIZ_SUBMITED,
753-
timestamp=attempt.submitted_at,
754-
event_data=QuizSubmitedEvent(
755-
quiz_id=delivery.course_content.quiz.id, # type: ignore[union-attr]
756-
quiz_title=delivery.course_content.quiz.title, # type: ignore[union-attr]
757-
score=attempt.score,
758-
is_passed=attempt.is_passed,
759-
attempt_number=attempt_number,
760-
),
756+
attempts = list(quiz_attempts)
757+
if attempts:
758+
for attempt in [i for i in attempts if i is not None]: # type: ignore[union-attr]
759+
events.append(
760+
Event(
761+
type=EventType.QUIZ_SUBMITED,
762+
timestamp=attempt.submitted_at,
763+
event_data=QuizSubmitedEvent(
764+
quiz_id=delivery.course_content.quiz.id, # type: ignore[union-attr]
765+
quiz_title=delivery.course_content.quiz.title, # type: ignore[union-attr]
766+
score=attempt.score,
767+
is_passed=attempt.is_passed,
768+
attempt_number=attempt_number,
769+
is_practice=delivery.course_content.quiz.is_blocking # type: ignore[union-attr]
770+
is False, # type: ignore[union-attr]
771+
),
772+
)
761773
)
762-
)
774+
attempt_number += 1
763775
if (
764776
enrollment.status == EnrollmentStatus.COMPLETED
765777
and enrollment.final_state_at
@@ -831,6 +843,7 @@ def to_django_model(self, course: Course) -> CourseContent:
831843
selection_strategy=self.content.selection_strategy.value, # type: ignore[misc]
832844
deadline_days=self.content.deadline_days, # type: ignore[misc]
833845
limited_attempts=self.content.limited_attempts, # type: ignore[misc]
846+
is_blocking=self.content.is_blocking, # type: ignore[misc]
834847
)
835848
quiz.save()
836849
for question_data in self.content.questions:
@@ -910,6 +923,7 @@ class CourseContentSummaryResponse(BaseModel):
910923
is_published: bool
911924
type: str
912925
limited_attempts: Optional[bool] = None
926+
is_blocking: Optional[bool] = None
913927

914928
@field_serializer("waiting_period")
915929
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
@@ -293,6 +293,8 @@ def _update_course_content_atomic(
293293
quiz.deadline_days = quiz_serializer.deadline_days
294294
if quiz_serializer.limited_attempts is not None:
295295
quiz.limited_attempts = quiz_serializer.limited_attempts
296+
if quiz_serializer.is_blocking is not None:
297+
quiz.is_blocking = quiz_serializer.is_blocking
296298
if quiz_serializer.questions is not None:
297299
question_ids = set()
298300
for question_data in quiz_serializer.questions:

django_email_learning/platform/views.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ def get_context_data(self, **kwargs) -> dict: # type: ignore[no-untyped-def]
249249
context["appContext"]["customComponent"] = None
250250
context["appContext"]["quizDefaults"] = {
251251
"limitedAttempts": True,
252+
"isBlocking": True,
252253
}
253254
context["appContext"]["direction"] = (
254255
"rtl" if get_language_info(course.language)["bidi"] else "ltr"
@@ -293,6 +294,11 @@ def get_locale_messages(self) -> Dict[str, str]:
293294
"editing": _("Editing..."),
294295
"edit_with_ai": _("Edit with AI"),
295296
"lesson_title": _("Lesson Title"),
297+
"blocking_quiz": _("Blocking Quiz"),
298+
"blocking_quiz_tooltip": _(
299+
"If enabled, learners must pass the quiz to continue receiving course content. For practice quizzes that don't gate content, "
300+
"you can disable this option so learners can continue with the course regardless of their quiz performance."
301+
),
296302
"lesson_waiting_tooltip": _(
297303
"Set the amount of time that we should wait after the previous lesson or quiz submission before sending this lesson"
298304
),
@@ -326,6 +332,7 @@ def get_locale_messages(self) -> Dict[str, str]:
326332
"limited_attempts": _("Limited Attempts"),
327333
"unlimited_attempts": _("Unlimited Attempts"),
328334
"two_attempts": _("2 Attempts"),
335+
"practice_quiz": _("Practice Quiz"),
329336
"limited_attempts_tooltip": _(
330337
"If limited attempts is enabled, learners only have 2 attempts to pass the quiz. "
331338
"After 2 failed attempts, they will fail the course and need to restart it. If limited attempts is disabled, "
@@ -510,6 +517,7 @@ def get_locale_messages(self) -> Dict[str, str]:
510517
"canceled": _("Canceled"),
511518
"blcoked": _("Blocked"),
512519
"inactive": _("Inactive"),
520+
"practice_attempt": _("Practice Attempt"),
513521
}
514522

515523

django_email_learning/ports/metric_recorder_protocol.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@ def user_completed_course(self, course_slug: str, organization_id: int) -> None:
2626
...
2727

2828
def quiz_submitted(
29-
self, course_slug: str, organization_id: int, quiz_id: int, is_passed: bool
29+
self,
30+
course_slug: str,
31+
organization_id: int,
32+
quiz_id: int,
33+
is_passed: bool,
34+
is_blocking: bool,
3035
) -> None:
3136
...
3237

django_email_learning/services/defaults/log_based_metric_recorder.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,12 @@ def user_completed_course(self, course_slug: str, organization_id: int) -> None:
7474
)
7575

7676
def quiz_submitted(
77-
self, course_slug: str, organization_id: int, quiz_id: int, is_passed: bool
77+
self,
78+
course_slug: str,
79+
organization_id: int,
80+
quiz_id: int,
81+
is_passed: bool,
82+
is_blocking: bool,
7883
) -> None:
7984
logger.info(
8085
"Quiz submitted",
@@ -84,6 +89,7 @@ def quiz_submitted(
8489
"organization_id": organization_id,
8590
"quiz_id": quiz_id,
8691
"is_passed": is_passed,
92+
"is_blocking": is_blocking,
8793
},
8894
)
8995

django_email_learning/services/metrics_service.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,15 @@ def user_completed_course(self, course_slug: str, organization_id: int) -> None:
4848
self.metric_recorder.user_completed_course(course_slug, organization_id)
4949

5050
def quiz_submitted(
51-
self, course_slug: str, organization_id: int, quiz_id: int, is_passed: bool
51+
self,
52+
course_slug: str,
53+
organization_id: int,
54+
quiz_id: int,
55+
is_passed: bool,
56+
is_blocking: bool,
5257
) -> None:
5358
self.metric_recorder.quiz_submitted(
54-
course_slug, organization_id, quiz_id, is_passed
59+
course_slug, organization_id, quiz_id, is_passed, is_blocking
5560
)
5661

5762
def method_executed(self, method_name: str, execution_time: int) -> None:

0 commit comments

Comments
 (0)