Skip to content

Commit 576d8c7

Browse files
committed
feat: #152 Add AMP handling for quizzes
1 parent 39d4e78 commit 576d8c7

9 files changed

Lines changed: 437 additions & 13 deletions

File tree

django_email_learning/personalised/api/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
FileUploadView,
44
AssignmentSubmissionView,
55
QuizSubmissionView,
6+
AmpQuizSubmissionView,
67
SubmitCertificateFormView,
78
)
89

910
app_name = "django_email_learning"
1011

1112
urlpatterns = [
1213
path("quiz/", QuizSubmissionView.as_view(), name="quiz_submission"),
14+
path("quiz-amp/", AmpQuizSubmissionView.as_view(), name="quiz_amp_submission"),
1315
path(
1416
"assignment/",
1517
AssignmentSubmissionView.as_view(),

django_email_learning/personalised/api/views.py

Lines changed: 126 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
from django.http import JsonResponse
2+
from django.conf import settings
23
from django.views import View
34
from django.urls import reverse
45
from django.utils import timezone
56
from django_email_learning.personalised.api.serializers import (
67
QuizSubmissionRequest,
78
QuestionResponse,
89
)
10+
from django.utils.decorators import method_decorator
11+
from django.views.decorators.csrf import csrf_exempt
912
from django_email_learning.services.metrics_service import metric_service
1013
from django_email_learning.services import jwt_service
1114
from django.utils.translation import gettext as _
@@ -204,15 +207,28 @@ def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
204207
token = serializer.token
205208
answers = serializer.answers
206209

207-
submited_question_ids = {response.id for response in answers}
210+
response_payload, error_response = self.process_quiz_submission(
211+
token=token,
212+
answers=answers,
213+
)
214+
if error_response:
215+
return error_response
216+
217+
return JsonResponse(response_payload, status=200)
218+
219+
@classmethod
220+
def process_quiz_submission(
221+
cls, token: str, answers: list[QuestionResponse]
222+
) -> tuple[dict | None, JsonResponse | None]:
223+
submitted_question_ids = {response.id for response in answers}
208224
response_map = {response.id: response.answers for response in answers}
209225

210226
try:
211227
decoded = jwt_service.decode_jwt(token=token)
212228
except jwt_service.InvalidTokenException as jde:
213-
return JsonResponse({"error": str(jde)}, status=400)
229+
return None, JsonResponse({"error": str(jde)}, status=400)
214230
except jwt_service.ExpiredTokenException as ete:
215-
return JsonResponse({"error": str(ete)}, status=410)
231+
return None, JsonResponse({"error": str(ete)}, status=410)
216232

217233
delivery_id = decoded["delivery_id"]
218234

@@ -221,7 +237,7 @@ def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
221237
id=delivery_id, hash_value=decoded["delivery_hash"]
222238
)
223239
except ContentDelivery.DoesNotExist:
224-
return JsonResponse(
240+
return None, JsonResponse(
225241
{
226242
"error": "The content delivery associated with this token does not exist."
227243
},
@@ -230,23 +246,25 @@ def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
230246

231247
enrollment = delivery.enrollment
232248
if enrollment.status != EnrollmentStatus.ACTIVE:
233-
return JsonResponse({"error": "Quiz is not valid anymore"}, status=400)
249+
return None, JsonResponse(
250+
{"error": "Quiz is not valid anymore"}, status=400
251+
)
234252

235253
quiz = delivery.course_content.quiz
236254
if not quiz:
237-
return JsonResponse(
255+
return None, JsonResponse(
238256
{"error": "No quiz associated with this link"}, status=500
239257
)
240258

241259
try:
242-
score, passed = self.calculate_score_and_passed(
260+
score, passed = cls.calculate_score_and_passed(
243261
quiz, answers, decoded.get("question_ids")
244262
)
245263
logger.info(
246264
f"Learner ID {enrollment.learner.id} submitted quiz for Course {enrollment.course.title} with score {score}. Passed: {passed}"
247265
)
248266
except ValueError as ve:
249-
return JsonResponse({"error": str(ve)}, status=500)
267+
return None, JsonResponse({"error": str(ve)}, status=500)
250268

251269
QuizSubmission.objects.create(
252270
delivery=delivery,
@@ -271,9 +289,9 @@ def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
271289
if (quiz.is_blocking and passed) or QuizSubmission.objects.filter(
272290
delivery=delivery
273291
).count() == 1:
274-
delivery = delivery.schedule_next_delivery()
292+
new_delivery = delivery.schedule_next_delivery()
275293

276-
if not delivery:
294+
if not new_delivery:
277295
enrollment.graduate()
278296
else:
279297
failed_submissions_count = QuizSubmission.objects.filter(
@@ -328,7 +346,7 @@ def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
328346
is_passed=passed,
329347
is_blocking=quiz.is_blocking,
330348
)
331-
return JsonResponse(
349+
return (
332350
{
333351
"score": score,
334352
"passed": passed,
@@ -353,14 +371,14 @@ def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
353371
],
354372
}
355373
for question in quiz.questions.filter(
356-
id__in=submited_question_ids
374+
id__in=submitted_question_ids
357375
)
358376
],
359377
}
360378
if not quiz.is_blocking
361379
else None,
362380
},
363-
status=200,
381+
None,
364382
)
365383

366384
@staticmethod
@@ -420,6 +438,101 @@ def calculate_score_and_passed(
420438
return score, passed
421439

422440

441+
@method_decorator(csrf_exempt, name="dispatch")
442+
class AmpQuizSubmissionView(View):
443+
@staticmethod
444+
def _set_amp_headers(response: JsonResponse, source_origin: str) -> JsonResponse:
445+
response["Access-Control-Allow-Origin"] = source_origin
446+
response["AMP-Access-Control-Allow-Source-Origin"] = source_origin
447+
response["Access-Control-Allow-Credentials"] = "true"
448+
return response
449+
450+
def _amp_json_response(
451+
self, payload: dict, status: int, source_origin: str
452+
) -> JsonResponse:
453+
response = JsonResponse(payload, status=status)
454+
return self._set_amp_headers(response=response, source_origin=source_origin)
455+
456+
def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
457+
source_origin = request.GET.get("__amp_source_origin")
458+
if not source_origin:
459+
return JsonResponse(
460+
{"error": _("Missing __amp_source_origin query parameter.")},
461+
status=400,
462+
)
463+
464+
trusted_origins = {
465+
trusted_origin.rstrip("/")
466+
for trusted_origin in settings.CSRF_TRUSTED_ORIGINS
467+
}
468+
normalized_source_origin = source_origin.rstrip("/")
469+
if normalized_source_origin not in trusted_origins:
470+
return JsonResponse(
471+
{"error": _("Invalid __amp_source_origin. Origin is not trusted.")},
472+
status=400,
473+
)
474+
475+
token = request.POST.get("token")
476+
if not token:
477+
return self._amp_json_response(
478+
{"error": _("Token is required.")},
479+
status=400,
480+
source_origin=normalized_source_origin,
481+
)
482+
483+
answers_map: dict[int, set[int]] = {}
484+
485+
for key, values in request.POST.lists():
486+
if key == "token":
487+
continue
488+
489+
try:
490+
question_id = int(key)
491+
except (TypeError, ValueError):
492+
return self._amp_json_response(
493+
{"error": _("Invalid question ID in form payload.")},
494+
status=400,
495+
source_origin=normalized_source_origin,
496+
)
497+
498+
parsed_answers: set[int] = set()
499+
for value in values:
500+
if value in (None, ""):
501+
continue
502+
503+
try:
504+
parsed_answers.add(int(value))
505+
except (TypeError, ValueError):
506+
return self._amp_json_response(
507+
{"error": _("Invalid answer ID in form payload.")},
508+
status=400,
509+
source_origin=normalized_source_origin,
510+
)
511+
512+
answers_map[question_id] = parsed_answers
513+
514+
answers = [
515+
QuestionResponse(id=question_id, answers=answer_ids)
516+
for question_id, answer_ids in answers_map.items()
517+
]
518+
519+
response_payload, error_response = QuizSubmissionView.process_quiz_submission(
520+
token=token,
521+
answers=answers,
522+
)
523+
if error_response:
524+
return self._set_amp_headers(
525+
response=error_response,
526+
source_origin=normalized_source_origin,
527+
)
528+
529+
return self._amp_json_response(
530+
response_payload,
531+
status=200,
532+
source_origin=normalized_source_origin,
533+
)
534+
535+
423536
class SubmitCertificateFormView(View):
424537
def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
425538
payload = json.loads(request.body)

django_email_learning/services/command_models/send_quiz_command.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from django_email_learning.services.metrics_service import metric_service
77
from django.core.mail import EmailMultiAlternatives
88
from django.template.loader import render_to_string
9+
from django.conf import settings
10+
from django.urls import reverse
911
from typing import Literal
1012

1113
from django_email_learning.services.utils import mask_email
@@ -15,6 +17,9 @@ class QuizNotFoundError(Exception):
1517
pass
1618

1719

20+
DJANGO_EMAIL_LEARNING_CONF = settings.DJANGO_EMAIL_LEARNING
21+
22+
1823
class SendQuizCommand(AbstractCommand):
1924
command_name: Literal["send_quiz"] = "send_quiz"
2025
link: str
@@ -31,11 +36,17 @@ def execute(self) -> None:
3136
f"Sending quiz with ID {content.quiz.id} to email {mask_email(self.email)}"
3237
)
3338

39+
token = self.link.split("token=")[-1] if "token=" in self.link else None
40+
if token:
41+
token = token.split("&")[0] # In case there are other query parameters
42+
3443
quiz = content.quiz
3544
subject = quiz.title
3645
context = {
3746
"quiz": quiz,
3847
"link": self.link,
48+
"amp_action_url": f"{DJANGO_EMAIL_LEARNING_CONF['SITE_BASE_URL']}{reverse('django_email_learning:api_personalised:quiz_amp_submission')}",
49+
"token": token,
3950
"unsubscribe_link": content.course.generate_unsubscribe_link(self.email),
4051
}
4152
payload = render_to_string("emails/quiz.txt", context)
@@ -49,6 +60,10 @@ def execute(self) -> None:
4960
email_message.attach_alternative(
5061
render_to_string("emails/quiz.html", context), "text/html"
5162
)
63+
if DJANGO_EMAIL_LEARNING_CONF.get("AMP_ENABLED"):
64+
email_message.attach_alternative(
65+
render_to_string("emails/quiz_amp.html", context), "text/x-amp-html"
66+
)
5267

5368
email_sender_service.send(email_message)
5469
metric_service.quiz_sent(
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
{% load i18n %}
2+
{% with brand_color="#636eec" %}
3+
<!doctype html>
4+
<html amp4email data-css-strict>
5+
<head>
6+
<meta charset="utf-8">
7+
<script async src="https://cdn.ampproject.org/v0.js"></script>
8+
{% block extra_head %}{% endblock %}
9+
<style amp4email-boilerplate>body{visibility:hidden}</style>
10+
<style amp-custom>
11+
.background {
12+
padding: 10px;
13+
width: 100%;
14+
box-sizing: border-box;
15+
font-family: Helvetica, Arial, sans-serif;
16+
line-height: 1.8;
17+
font-size: 15px;
18+
}
19+
.paper {
20+
margin: 10px;
21+
padding: 8px;
22+
border-radius: 8px;
23+
border: solid 1px #eee;
24+
}
25+
.button {
26+
padding: 14px 30px;
27+
border-radius: 8px;
28+
font-size: 16px;
29+
font-weight: 600;
30+
box-shadow: 0 4px 14px rgba(36, 48, 110, 0.16);
31+
background-color: #636eec;
32+
color: #fff;
33+
}
34+
.button:hover {
35+
background-color: #6e00a5;
36+
cursor: pointer;
37+
}
38+
.email-title {
39+
margin: 10px;
40+
text-align: center;
41+
border-bottom: 2px solid #d8d9ee;
42+
padding-bottom: 10px;
43+
line-height: 1.35;
44+
font-size: 19px;
45+
}
46+
.locale {
47+
{% get_current_language_bidi as IS_RTL %}
48+
{% if IS_RTL %}
49+
direction: rtl;
50+
text-align: right;
51+
{% else %}
52+
direction: ltr;
53+
text-align: left;
54+
{% endif %}
55+
}
56+
p {
57+
padding: 0 15px;
58+
}
59+
{% block extra_styles %}{% endblock %}
60+
</style>
61+
</head>
62+
<body>
63+
<div class="background">
64+
<div class="locale">
65+
{% block content %}{% endblock %}
66+
</div>
67+
{% block footer %}{% endblock %}
68+
</div>
69+
</body>
70+
</html>
71+
{% endwith %}

0 commit comments

Comments
 (0)