diff --git a/django_email_learning/migrations/0023_assignmentsubmission_assignmentfeedback.py b/django_email_learning/migrations/0023_assignmentsubmission_assignmentfeedback.py
new file mode 100644
index 0000000..4144b67
--- /dev/null
+++ b/django_email_learning/migrations/0023_assignmentsubmission_assignmentfeedback.py
@@ -0,0 +1,103 @@
+# Generated by Django 6.0.4 on 2026-05-04 09:12
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ (
+ "django_email_learning",
+ "0022_organizationuser_display_name_organizationuser_photo",
+ ),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="AssignmentSubmission",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("text_submission", models.TextField(blank=True, null=True)),
+ (
+ "file_submission",
+ models.FileField(
+ blank=True, null=True, upload_to="assignment_submissions/"
+ ),
+ ),
+ ("submitted_at", models.DateTimeField(auto_now_add=True)),
+ (
+ "status",
+ models.CharField(
+ choices=[
+ ("pending_review", "Pending Review"),
+ ("approved", "Approved"),
+ ("rejected", "Rejected"),
+ ],
+ db_index=True,
+ default="pending_review",
+ max_length=50,
+ ),
+ ),
+ ("reviewed_at", models.DateTimeField(blank=True, null=True)),
+ (
+ "delivery",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="assignment_submissions",
+ to="django_email_learning.contentdelivery",
+ ),
+ ),
+ (
+ "reviewer",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="reviewed_assignments",
+ to="django_email_learning.organizationuser",
+ ),
+ ),
+ ],
+ ),
+ migrations.CreateModel(
+ name="AssignmentFeedback",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("comments", models.TextField()),
+ ("provided_at", models.DateTimeField(auto_now_add=True)),
+ (
+ "provided_by",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="provided_feedbacks",
+ to="django_email_learning.organizationuser",
+ ),
+ ),
+ (
+ "submission",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="feedback",
+ to="django_email_learning.assignmentsubmission",
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/django_email_learning/migrations/0024_alter_assignmentsubmission_delivery.py b/django_email_learning/migrations/0024_alter_assignmentsubmission_delivery.py
new file mode 100644
index 0000000..12c1484
--- /dev/null
+++ b/django_email_learning/migrations/0024_alter_assignmentsubmission_delivery.py
@@ -0,0 +1,22 @@
+# Generated by Django 6.0.4 on 2026-05-04 10:45
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("django_email_learning", "0023_assignmentsubmission_assignmentfeedback"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="assignmentsubmission",
+ name="delivery",
+ field=models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="assignment_submissions",
+ to="django_email_learning.contentdelivery",
+ ),
+ ),
+ ]
diff --git a/django_email_learning/migrations/0025_alter_assignmentsubmission_status.py b/django_email_learning/migrations/0025_alter_assignmentsubmission_status.py
new file mode 100644
index 0000000..e812f9c
--- /dev/null
+++ b/django_email_learning/migrations/0025_alter_assignmentsubmission_status.py
@@ -0,0 +1,27 @@
+# Generated by Django 6.0.4 on 2026-05-04 10:47
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("django_email_learning", "0024_alter_assignmentsubmission_delivery"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="assignmentsubmission",
+ name="status",
+ field=models.CharField(
+ choices=[
+ ("pending_review", "Pending Review"),
+ ("requesting_changes", "Requesting Changes"),
+ ("approved", "Approved"),
+ ("rejected", "Rejected"),
+ ],
+ db_index=True,
+ default="pending_review",
+ max_length=50,
+ ),
+ ),
+ ]
diff --git a/django_email_learning/models.py b/django_email_learning/models.py
index 19da716..451d70a 100644
--- a/django_email_learning/models.py
+++ b/django_email_learning/models.py
@@ -31,6 +31,7 @@
from django.utils.translation import ngettext
from datetime import timedelta
from django_email_learning.services import jwt_service
+from django_email_learning.services.utils import mask_email
from PIL import Image
from typing import Optional
@@ -1029,6 +1030,10 @@ def generate_link(self) -> str:
"delivery_id": self.delivery.id,
"delivery_hash": self.delivery.hash_value,
}
+ if self.delivery.course_content.deadline_days:
+ exp = self.time + timedelta(days=self.delivery.course_content.deadline_days)
+ else:
+ exp = datetime.max
if self.delivery.course_content.quiz:
if (
@@ -1038,9 +1043,7 @@ def generate_link(self) -> str:
payload[
"question_ids"
] = self.delivery.course_content.quiz.random_question_ids() # type: ignore[assignment]
- exp = self.time + timedelta(
- days=self.delivery.course_content.quiz.deadline_days
- )
+
token = jwt_service.generate_jwt(payload=payload, exp=exp)
quiz_path = reverse("django_email_learning:personalised:quiz_public_view")
link = f"{settings.DJANGO_EMAIL_LEARNING['SITE_BASE_URL']}{quiz_path}?token={token}"
@@ -1048,8 +1051,14 @@ def generate_link(self) -> str:
self.save()
return link
elif self.delivery.course_content.assignment:
- # TODO: Implement assignment link generation
- return ""
+ token = jwt_service.generate_jwt(payload=payload, exp=exp)
+ assignment_path = reverse(
+ "django_email_learning:personalised:assignment_public_view"
+ )
+ link = f"{settings.DJANGO_EMAIL_LEARNING['SITE_BASE_URL']}{assignment_path}?token={token}"
+ self.link = link
+ self.save()
+ return link
else:
# TODO: Implement lesson link generation
return ""
@@ -1091,6 +1100,86 @@ def __str__(self) -> str:
return f"{self.delivery.course_content.quiz.title} | {self.delivery.enrollment.learner.email} | Score: {self.score} | Passed: {self.is_passed}" # type: ignore[union-attr]
+class AssignmentSubmission(models.Model):
+ class SubmissionStatus(models.TextChoices):
+ PENDING_REVIEW = "pending_review", "Pending Review"
+ REQUESTING_CHANGES = "requesting_changes", "Requesting Changes"
+ APPROVED = "approved", "Approved"
+ REJECTED = "rejected", "Rejected"
+
+ delivery = models.OneToOneField(
+ ContentDelivery,
+ on_delete=models.CASCADE,
+ related_name="assignment_submissions",
+ unique=True,
+ )
+ text_submission = models.TextField(null=True, blank=True)
+ file_submission = models.FileField(
+ upload_to="assignment_submissions/", null=True, blank=True
+ )
+ submitted_at = models.DateTimeField(auto_now_add=True)
+ status = models.CharField(
+ max_length=50,
+ choices=SubmissionStatus.choices,
+ default=SubmissionStatus.PENDING_REVIEW,
+ db_index=True,
+ )
+ reviewed_at = models.DateTimeField(null=True, blank=True)
+ reviewer = models.ForeignKey(
+ OrganizationUser,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="reviewed_assignments",
+ )
+
+ @staticmethod
+ def save_file(file_path: str, delivery: ContentDelivery) -> str:
+ if default_storage.exists(file_path):
+ if default_storage.size(file_path) > 10 * 1024 * 1024:
+ raise ValueError(
+ "File size exceeds the maximum allowed limit of 10 MB."
+ )
+ file = default_storage.open(file_path)
+ final_path = f"organizations/{delivery.enrollment.course.organization.id}/assignments/{delivery.id}/{file_path.split('/')[-1]}"
+ default_storage.save(final_path, file)
+ return final_path
+ else:
+ raise ValueError("File does not exist.")
+
+ def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
+ if self.delivery.course_content.type != "assignment":
+ raise ValidationError(
+ "Sent item must be associated with an assignment content."
+ )
+ if not self.text_submission and not self.file_submission:
+ raise ValidationError(
+ "At least one of text submission or file submission must be provided."
+ )
+ self.full_clean()
+ super().save(*args, **kwargs)
+
+ def __str__(self) -> str:
+ return f"{self.delivery.course_content.assignment.title} | {mask_email(self.delivery.enrollment.learner.email)} | Submitted at: {self.submitted_at}" # type: ignore[union-attr]
+
+
+class AssignmentFeedback(models.Model):
+ submission = models.OneToOneField(
+ AssignmentSubmission, on_delete=models.CASCADE, related_name="feedback"
+ )
+ comments = models.TextField()
+ provided_at = models.DateTimeField(auto_now_add=True)
+ provided_by = models.ForeignKey(
+ OrganizationUser,
+ on_delete=models.SET_NULL,
+ related_name="provided_feedbacks",
+ null=True,
+ )
+
+ def __str__(self) -> str:
+ return f"Feedback for {self.submission}"
+
+
class ApiKey(EncryptionMixin):
key = models.CharField(
max_length=256, unique=True, validators=[MinLengthValidator(50)]
diff --git a/django_email_learning/personalised/api/urls.py b/django_email_learning/personalised/api/urls.py
index 3453eba..1d8c352 100644
--- a/django_email_learning/personalised/api/urls.py
+++ b/django_email_learning/personalised/api/urls.py
@@ -1,5 +1,7 @@
from django.urls import path
from django_email_learning.personalised.api.views import (
+ FileUploadView,
+ AssignmentSubmissionView,
QuizSubmissionView,
SubmitCertificateFormView,
)
@@ -8,6 +10,16 @@
urlpatterns = [
path("quiz/", QuizSubmissionView.as_view(), name="quiz_submission"),
+ path(
+ "assignment/",
+ AssignmentSubmissionView.as_view(),
+ name="assignment_submission",
+ ),
+ path(
+ "file-upload/",
+ FileUploadView.as_view(),
+ name="file_upload",
+ ),
path(
"certificate-form/",
SubmitCertificateFormView.as_view(),
diff --git a/django_email_learning/personalised/api/views.py b/django_email_learning/personalised/api/views.py
index f02a36f..22b0b34 100644
--- a/django_email_learning/personalised/api/views.py
+++ b/django_email_learning/personalised/api/views.py
@@ -1,6 +1,7 @@
from django.http import JsonResponse
from django.views import View
from django.urls import reverse
+from django.utils import timezone
from django_email_learning.personalised.api.serializers import (
QuizSubmissionRequest,
QuestionResponse,
@@ -9,6 +10,7 @@
from django_email_learning.services import jwt_service
from django.utils.translation import gettext as _
from django_email_learning.models import (
+ AssignmentSubmission,
ContentDelivery,
Enrollment,
Certificate,
@@ -19,12 +21,175 @@
from pydantic import ValidationError
import json
import logging
+from django.core.files.storage import default_storage
METRIC_SERVICE = MetricsService()
logger = logging.getLogger(__name__)
+class FileUploadView(View):
+ def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
+ token = request.POST.get("token")
+ uploaded_file = request.FILES.get("file")
+
+ if not token:
+ return JsonResponse({"error": _("Token is required.")}, status=400)
+
+ if not uploaded_file:
+ return JsonResponse({"error": _("No file uploaded.")}, status=400)
+
+ try:
+ decoded = jwt_service.decode_jwt(token=token)
+ except jwt_service.InvalidTokenException as jde:
+ return JsonResponse({"error": str(jde)}, status=400)
+ except jwt_service.ExpiredTokenException as ete:
+ return JsonResponse({"error": str(ete)}, status=410)
+
+ try:
+ delivery = ContentDelivery.objects.get(
+ id=decoded["delivery_id"],
+ hash_value=decoded["delivery_hash"],
+ )
+ except ContentDelivery.DoesNotExist:
+ return JsonResponse(
+ {
+ "error": "The content delivery associated with this token does not exist."
+ },
+ status=500,
+ )
+
+ if delivery.enrollment.status != EnrollmentStatus.ACTIVE:
+ return JsonResponse(
+ {"error": _("File upload is not valid anymore")}, status=400
+ )
+
+ date_prefix = timezone.now().strftime("%Y%m%d")
+ file_path = default_storage.save(
+ f"uploads/{date_prefix}/{delivery.enrollment.course.organization.id}/{delivery.id}/{uploaded_file.name}",
+ uploaded_file,
+ )
+
+ return JsonResponse(
+ {
+ "file_path": file_path,
+ "file_name": uploaded_file.name,
+ },
+ status=201,
+ )
+
+
+class AssignmentSubmissionView(View):
+ def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
+ payload = json.loads(request.body)
+ token = payload.get("token")
+ text_submission = payload.get("text_submission")
+ file_submission = payload.get("file_path")
+
+ if not text_submission and not file_submission:
+ return JsonResponse(
+ {
+ "error": _(
+ "At least one of text submission or file submission is required."
+ )
+ },
+ status=400,
+ )
+
+ try:
+ decoded = jwt_service.decode_jwt(token=token)
+ except jwt_service.InvalidTokenException as jde:
+ return JsonResponse({"error": str(jde)}, status=400)
+ except jwt_service.ExpiredTokenException as ete:
+ return JsonResponse({"error": str(ete)}, status=410)
+
+ delivery_id = decoded["delivery_id"]
+ try:
+ delivery = ContentDelivery.objects.get(
+ id=delivery_id, hash_value=decoded["delivery_hash"]
+ )
+ except ContentDelivery.DoesNotExist:
+ return JsonResponse(
+ {
+ "error": "The content delivery associated with this token does not exist."
+ },
+ status=500,
+ )
+
+ existing_submission = AssignmentSubmission.objects.filter(
+ delivery=delivery
+ ).first()
+ if (
+ existing_submission
+ and existing_submission.status
+ != AssignmentSubmission.SubmissionStatus.REQUESTING_CHANGES
+ ):
+ return JsonResponse(
+ {
+ "error": _(
+ "There is already a submission for this assignment. Please wait for it to be reviewed before submitting again."
+ )
+ },
+ status=400,
+ )
+
+ enrollment = delivery.enrollment
+ if enrollment.status != EnrollmentStatus.ACTIVE:
+ return JsonResponse(
+ {"error": "Assignment submission is not valid anymore"}, status=400
+ )
+
+ assignment = delivery.course_content.assignment
+ if not assignment:
+ return JsonResponse(
+ {"error": "No assignment associated with this link"}, status=500
+ )
+
+ if assignment.requires_text_submission and not text_submission:
+ return JsonResponse(
+ {"error": _("Text submission is required for this assignment.")},
+ status=400,
+ )
+
+ if assignment.requires_file_submission and not file_submission:
+ return JsonResponse(
+ {"error": _("File submission is required for this assignment.")},
+ status=400,
+ )
+
+ file_path = None
+ if file_submission:
+ file_path = AssignmentSubmission.save_file(
+ file_path=file_submission,
+ delivery=delivery,
+ )
+
+ submission, created = AssignmentSubmission.objects.update_or_create(
+ delivery=delivery,
+ defaults={
+ "file_submission": file_path if file_submission else None,
+ "text_submission": text_submission if text_submission else None,
+ },
+ )
+ if not created:
+ submission.status = AssignmentSubmission.SubmissionStatus.PENDING_REVIEW
+ submission.save()
+
+ METRIC_SERVICE.assignment_submitted(
+ course_slug=enrollment.course.slug,
+ organization_id=enrollment.course.organization.id,
+ assignment_id=assignment.id,
+ )
+
+ return JsonResponse(
+ {
+ "message": _("Your assignment submission has been recorded."),
+ "status": submission.status,
+ },
+ status=200,
+ )
+
+
class QuizSubmissionView(View):
def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
payload = json.loads(request.body)
diff --git a/django_email_learning/personalised/urls.py b/django_email_learning/personalised/urls.py
index d9e00ec..1257851 100644
--- a/django_email_learning/personalised/urls.py
+++ b/django_email_learning/personalised/urls.py
@@ -1,6 +1,7 @@
from django.urls import path
from django_email_learning.personalised.views import (
QuizPublicView,
+ AssignmentPublicView,
VerifyEnrollmentView,
CertificateFormView,
CertificateView,
@@ -11,6 +12,7 @@
urlpatterns = [
path("quiz/", QuizPublicView.as_view(), name="quiz_public_view"),
+ path("assignment/", AssignmentPublicView.as_view(), name="assignment_public_view"),
path(
"verify-enrollment/", VerifyEnrollmentView.as_view(), name="verify_enrollment"
),
diff --git a/django_email_learning/personalised/views.py b/django_email_learning/personalised/views.py
index 3f9dcc7..309747d 100644
--- a/django_email_learning/personalised/views.py
+++ b/django_email_learning/personalised/views.py
@@ -84,6 +84,12 @@ def get_decoded_token(self, request) -> dict | HttpResponse: # type: ignore[no-
title=_("Expired Link"),
)
+ def get_token_and_decoded_token(self, request) -> tuple[str, dict] | HttpResponse: # type: ignore[no-untyped-def]
+ decoded_token = self.get_decoded_token(request)
+ if isinstance(decoded_token, HttpResponse):
+ return decoded_token
+ return request.GET.get("token", ""), decoded_token
+
def get_app_context(self) -> dict: # type: ignore[no-untyped-def]
current_lang_code = get_language()
lang_info = get_language_info(current_lang_code)
@@ -91,16 +97,133 @@ def get_app_context(self) -> dict: # type: ignore[no-untyped-def]
"direction": "rtl" if lang_info["bidi"] else "ltr",
}
+ def delivery_from_token(self, request) -> ContentDelivery | HttpResponse: # type: ignore[no-untyped-def]
+ decoded_token = self.get_decoded_token(request)
+ if isinstance(decoded_token, HttpResponse):
+ return decoded_token # Return error response if token is invalid
+ try:
+ delivery = ContentDelivery.objects.get(
+ id=decoded_token["delivery_id"],
+ hash_value=decoded_token["delivery_hash"],
+ )
+ return delivery
+ except ContentDelivery.DoesNotExist as e:
+ return self.error_response(
+ message=_("The delivery does not exist."),
+ exception=e,
+ status_code=404,
+ title=_("Invalid Delivery"),
+ )
+
+
+class AssignmentPublicView(BaseTemplateView):
+ template_name = "personalised/assignment_public.html"
+
+ def get(self, request, *args, **kwargs) -> HttpResponse: # type: ignore[no-untyped-def]
+ token_and_decoded = self.get_token_and_decoded_token(request)
+ if isinstance(token_and_decoded, HttpResponse):
+ return token_and_decoded
+
+ token, decoded_token = token_and_decoded
+ try:
+ delivery = ContentDelivery.objects.get(
+ id=decoded_token["delivery_id"],
+ hash_value=decoded_token["delivery_hash"],
+ )
+ enrollment = delivery.enrollment
+ if enrollment.status != EnrollmentStatus.ACTIVE:
+ return self.error_response(
+ message=_("This assignment is no longer valid."),
+ exception=ValueError("Enrollment is not active"),
+ title=_("Invalid Assignment"),
+ )
+ assignment = delivery.course_content.assignment
+ if not assignment:
+ return self.error_response(
+ message=_("There is no assignment associated with this link."),
+ exception=None,
+ title=_("Invalid Assignment"),
+ )
+ if not delivery.course_content.is_published:
+ return self.error_response(
+ message=_(
+ "There is no valid assignment associated with this link."
+ ),
+ exception=ValueError("Assignment is not published"),
+ title=_("Invalid Assignment"),
+ )
+ assignment_data = {
+ "id": assignment.id,
+ "title": assignment.title,
+ "description": assignment.description,
+ "requires_file_submission": assignment.requires_file_submission,
+ "requires_text_submission": assignment.requires_text_submission,
+ }
+ return self.render_to_response(
+ context={
+ "appContext": {
+ "assignment": assignment_data,
+ "token": token,
+ "csrfToken": request.META.get("CSRF_COOKIE", ""),
+ "apiEndpoint": reverse(
+ "django_email_learning:api_personalised:assignment_submission"
+ ),
+ "fileUploadApiEndpoint": reverse(
+ "django_email_learning:api_personalised:file_upload"
+ ),
+ "localeMessages": {
+ "text_submission_label": _("Your Answer"),
+ "file_submission_label": _("Upload Your File"),
+ "submission_success": _(
+ "Your assignment has been submitted successfully!"
+ ),
+ "submission_error": _(
+ "An error occurred while submitting your assignment. Please try again later."
+ ),
+ "submit": _("Submit"),
+ "close_window_message": _("You can now close this window!"),
+ },
+ }
+ | self.get_app_context(),
+ }
+ )
+
+ except ContentDelivery.DoesNotExist as e:
+ if ContentDelivery.objects.filter( # type: ignore[misc]
+ id=decoded_token.get("delivery_id")
+ ).exists():
+ return self.render_to_response(
+ context={
+ "appContext": {
+ "errorMessage": _(
+ "The assignment link has already been used and is not valid anymore."
+ ),
+ "localeMessages": {
+ "close_window_message": _(
+ "You can now close this window!"
+ ),
+ },
+ }
+ }
+ )
+ return self.error_response(
+ message=_("An error occurred while retrieving the assignment"),
+ exception=e,
+ title=_("Error"),
+ status_code=410,
+ )
+
class QuizPublicView(BaseTemplateView):
template_name = "personalised/quiz_public.html"
def get(self, request, *args, **kwargs) -> HttpResponse: # type: ignore[no-untyped-def]
+ token_and_decoded = self.get_token_and_decoded_token(request)
+ if isinstance(token_and_decoded, HttpResponse):
+ return token_and_decoded
+
+ token, decoded_token = token_and_decoded
try:
- token = request.GET["token"]
- decoded_token = self.get_decoded_token(request)
- if isinstance(decoded_token, HttpResponse):
- return decoded_token # Return error response if token is invalid
question_ids = decoded_token.get("question_ids", [])
delivery = ContentDelivery.objects.get(
id=decoded_token["delivery_id"],
@@ -193,27 +316,6 @@ def get(self, request, *args, **kwargs) -> HttpResponse: # type: ignore[no-unty
title=_("Error"),
status_code=410,
)
- except KeyError as e:
- return self.error_response(
- message=_("The link is not valid"),
- exception=e,
- status_code=400,
- title=_("Invalid Link"),
- )
- except jwt_service.InvalidTokenException as e:
- return self.error_response(
- message=_("The link is not valid"),
- exception=e,
- status_code=400,
- title=_("Invalid Link"),
- )
- except jwt_service.ExpiredTokenException as e:
- return self.error_response(
- message=_("The link has expired"),
- exception=e,
- status_code=410,
- title=_("Expired Link"),
- )
class CertificateFormView(BaseTemplateView):
diff --git a/django_email_learning/ports/metric_recorder_protocol.py b/django_email_learning/ports/metric_recorder_protocol.py
index a51b6c1..3cad634 100644
--- a/django_email_learning/ports/metric_recorder_protocol.py
+++ b/django_email_learning/ports/metric_recorder_protocol.py
@@ -30,6 +30,14 @@ def user_enrollment_deactivated(
def user_completed_course(self, course_slug: str, organization_id: int) -> None:
...
+ def assignment_submitted(
+ self,
+ course_slug: str,
+ organization_id: int,
+ assignment_id: int,
+ ) -> None:
+ ...
+
def quiz_submitted(
self,
course_slug: str,
diff --git a/django_email_learning/services/defaults/log_based_metric_recorder.py b/django_email_learning/services/defaults/log_based_metric_recorder.py
index 21c7ee2..7f9aa3d 100644
--- a/django_email_learning/services/defaults/log_based_metric_recorder.py
+++ b/django_email_learning/services/defaults/log_based_metric_recorder.py
@@ -86,6 +86,19 @@ def user_completed_course(self, course_slug: str, organization_id: int) -> None:
},
)
+ def assignment_submitted(
+ self, course_slug: str, organization_id: int, assignment_id: int
+ ) -> None:
+ logger.info(
+ "Assignment Submitted",
+ extra={
+ "metric": "assignment_submitted",
+ "course_slug": course_slug,
+ "organization_id": organization_id,
+ "assignment_id": assignment_id,
+ },
+ )
+
def quiz_submitted(
self,
course_slug: str,
diff --git a/django_email_learning/services/metrics_service.py b/django_email_learning/services/metrics_service.py
index 958b870..4c7954b 100644
--- a/django_email_learning/services/metrics_service.py
+++ b/django_email_learning/services/metrics_service.py
@@ -52,6 +52,16 @@ def user_enrollment_deactivated(
def user_completed_course(self, course_slug: str, organization_id: int) -> None:
self.metric_recorder.user_completed_course(course_slug, organization_id)
+ def assignment_submitted(
+ self,
+ course_slug: str,
+ organization_id: int,
+ assignment_id: int,
+ ) -> None:
+ self.metric_recorder.assignment_submitted(
+ course_slug, organization_id, assignment_id
+ )
+
def quiz_submitted(
self,
course_slug: str,
diff --git a/django_email_learning/templates/personalised/assignment_public.html b/django_email_learning/templates/personalised/assignment_public.html
new file mode 100644
index 0000000..62b6eaa
--- /dev/null
+++ b/django_email_learning/templates/personalised/assignment_public.html
@@ -0,0 +1,13 @@
+{% extends "personalised/base.html" %}
+
+{% block head_script %}
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/django_service/admin.py b/django_service/admin.py
index 1e01231..63739d8 100644
--- a/django_service/admin.py
+++ b/django_service/admin.py
@@ -13,6 +13,7 @@
DeliverySchedule,
QuizSubmission,
JobExecution,
+ AssignmentSubmission,
)
from django_email_learning.oauth_integrations.models import Session
@@ -71,3 +72,4 @@ class JobExecutionAdmin(admin.ModelAdmin):
admin.site.register(Lesson)
admin.site.register(Quiz)
admin.site.register(Session, SessionAdmin)
+admin.site.register(AssignmentSubmission)
diff --git a/frontend/personalised/assignment_public/Assignment.jsx b/frontend/personalised/assignment_public/Assignment.jsx
new file mode 100644
index 0000000..77973a7
--- /dev/null
+++ b/frontend/personalised/assignment_public/Assignment.jsx
@@ -0,0 +1,214 @@
+import render, { useAppContext } from '../../src/render.jsx';
+import { useState } from 'react';
+import Layout from '../../public/components/Layout.jsx';
+import FileUpload from '../../src/components/FileUpload.jsx';
+import {
+ Alert,
+ Box,
+ Button,
+ TextField,
+ Typography,
+} from '@mui/material';
+
+
+const Assignment = () => {
+ const {
+ localeMessages,
+ token,
+ csrfToken,
+ apiEndpoint,
+ fileUploadApiEndpoint,
+ errorMessage,
+ assignment,
+ ref,
+ direction,
+ } = useAppContext();
+
+ const [textSubmission, setTextSubmission] = useState('');
+ const [filePath, setFilePath] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [submissionSuccess, setSubmissionSuccess] = useState(false);
+ const [submissionMessage, setSubmissionMessage] = useState('');
+ const [formError, setFormError] = useState('');
+
+ const requiresTextSubmission = Boolean(assignment?.requires_text_submission);
+ const requiresFileSubmission = Boolean(assignment?.requires_file_submission);
+
+ const validateSubmission = () => {
+ if (requiresTextSubmission && !textSubmission.trim()) {
+ return localeMessages.text_submission_required || 'Text submission is required.';
+ }
+ if (requiresFileSubmission && !filePath) {
+ return localeMessages.file_submission_required || 'File submission is required.';
+ }
+ return '';
+ };
+
+ const submitAssignment = () => {
+ const validationError = validateSubmission();
+ if (validationError) {
+ setFormError(validationError);
+ return;
+ }
+
+ setIsSubmitting(true);
+ setFormError('');
+
+ fetch(`${apiEndpoint}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRFToken': csrfToken,
+ },
+ body: JSON.stringify({
+ token,
+ text_submission: textSubmission.trim() || null,
+ file_path: filePath || null,
+ }),
+ })
+ .then(async (response) => {
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || localeMessages.submission_error);
+ }
+ return data;
+ })
+ .then((data) => {
+ setSubmissionSuccess(true);
+ setSubmissionMessage(
+ data.message || localeMessages.submission_success
+ );
+ })
+ .catch((error) => {
+ setFormError(error.message || localeMessages.submission_error);
+ })
+ .finally(() => {
+ setIsSubmitting(false);
+ });
+ };
+
+ return (
+