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 ( + + + {!errorMessage ? ( + + {!submissionSuccess ? ( + <> + + + {assignment?.title} + + {assignment?.description && ( + + {assignment.description} + + )} + + + {formError && ( + + {formError} + + )} + + {requiresTextSubmission && ( + + setTextSubmission(event.target.value) + } + sx={{ mb: 3 }} + /> + )} + + {requiresFileSubmission && ( + + + {localeMessages.file_submission_label || + 'Upload Your File'} + + { + setFilePath(data.file_path || ''); + setFormError(''); + }} + onUploadError={(error) => { + setFormError( + error?.message || localeMessages.submission_error + ); + }} + /> + + )} + + + + + + ) : ( + + + + {submissionMessage} + + + + + {localeMessages.close_window_message} + + + + )} + + ) : ( + + + {localeMessages.error}: {errorMessage}{' '} + {ref && `(Ref: ${ref})`} + + + )} + + + ); +}; + + +render({ children: }); diff --git a/frontend/personalised/assignment_public/index.html b/frontend/personalised/assignment_public/index.html new file mode 100644 index 0000000..21f4f51 --- /dev/null +++ b/frontend/personalised/assignment_public/index.html @@ -0,0 +1,12 @@ + + + + + + Assignment + + +
+ + + diff --git a/frontend/src/components/FileUpload.jsx b/frontend/src/components/FileUpload.jsx new file mode 100644 index 0000000..f0279cb --- /dev/null +++ b/frontend/src/components/FileUpload.jsx @@ -0,0 +1,139 @@ +import { useState } from 'react'; +import { styled } from '@mui/material/styles'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import Box from '@mui/material/Box'; +import Alert from '@mui/material/Alert'; +import CloudUploadIcon from '@mui/icons-material/CloudUpload'; + + +const VisuallyHiddenInput = styled('input')({ + clip: 'rect(0 0 0 0)', + clipPath: 'inset(50%)', + height: 1, + overflow: 'hidden', + position: 'absolute', + bottom: 0, + left: 0, + whiteSpace: 'nowrap', + width: 1, +}); + + +const FileUpload = ({ + uploadApiEndpoint, + token, + csrfToken, + direction = 'ltr', + uploadLabel = 'Upload File', + removeLabel = 'Remove File', + helperText = '', + onUploadSuccess, + onUploadError, +}) => { + const [isUploading, setIsUploading] = useState(false); + const [uploadedFileName, setUploadedFileName] = useState(''); + const [uploadError, setUploadError] = useState(''); + + const uploadFile = (selectedFile) => { + if (!selectedFile || !uploadApiEndpoint) { + return; + } + + setIsUploading(true); + setUploadError(''); + + const formData = new FormData(); + formData.append('file', selectedFile); + formData.append('token', token); + + fetch(uploadApiEndpoint, { + method: 'POST', + headers: { + 'X-CSRFToken': csrfToken, + }, + body: formData, + }) + .then(async (response) => { + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || 'File upload failed.'); + } + return data; + }) + .then((data) => { + setUploadedFileName(data.file_name || selectedFile.name); + if (onUploadSuccess) { + onUploadSuccess(data); + } + }) + .catch((error) => { + setUploadError(error.message || 'File upload failed.'); + if (onUploadError) { + onUploadError(error); + } + }) + .finally(() => { + setIsUploading(false); + }); + }; + + const clearUploadedFile = () => { + setUploadedFileName(''); + setUploadError(''); + if (onUploadSuccess) { + onUploadSuccess({ file_path: null, file_name: null }); + } + }; + + const handleFileChange = (event) => { + const selectedFile = event.target.files?.[0]; + if (!selectedFile) { + return; + } + uploadFile(selectedFile); + event.target.value = ''; + }; + + return ( + + + + {helperText && ( + + {helperText} + + )} + + {uploadedFileName && ( + + {uploadedFileName} + + + )} + + {uploadError && ( + + {uploadError} + + )} + + ); +}; + + +export default FileUpload; diff --git a/frontend/vite.config.js b/frontend/vite.config.js index fa39860..35feed9 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -34,7 +34,7 @@ export default defineConfig({ '@emotion/styled', ], // Force pre-bundling for MPA entry pages. - entries: ['./platform/courses/Courses.jsx', './platform/course/Course.jsx', './platform/organizations/Organizations.jsx', './platform/learners/Learners.jsx', './platform/settings_api_keys/SettingsApiKeys.jsx', './public/organization/Organization.jsx', './personalised/quiz_public/QuizPublic.jsx', './personalised/command_result/CommandResult.jsx'], + entries: ['./platform/courses/Courses.jsx', './platform/course/Course.jsx', './platform/organizations/Organizations.jsx', './platform/learners/Learners.jsx', './platform/settings_api_keys/SettingsApiKeys.jsx', './public/organization/Organization.jsx', './personalised/quiz_public/QuizPublic.jsx', './personalised/assignment_public/Assignment.jsx', './personalised/command_result/CommandResult.jsx'], }, build: { minify: 'terser', @@ -55,6 +55,7 @@ export default defineConfig({ organization: resolve(__dirname, 'public/organization/index.html'), public_course: resolve(__dirname, "public/course/index.html"), quiz_public: resolve(__dirname, "personalised/quiz_public/index.html"), + assignment_public: resolve(__dirname, "personalised/assignment_public/index.html"), certificate: resolve(__dirname, "personalised/certificate/index.html"), certtificate_form: resolve(__dirname, "personalised/certificate_form/index.html"), command_result: resolve(__dirname, "personalised/command_result/index.html"), diff --git a/scripts/build.py b/scripts/build.py index f58115b..5bf06a8 100644 --- a/scripts/build.py +++ b/scripts/build.py @@ -12,6 +12,7 @@ ["platform", "learners"], ["platform", "settings_api_keys"], ["personalised", "quiz_public"], + ["personalised", "assignment_public"], ["personalised", "command_result"], ["personalised", "certificate"], ["personalised", "certificate_form"], diff --git a/tests/personalised/api/test_views/test_assignment_file_upload.py b/tests/personalised/api/test_views/test_assignment_file_upload.py new file mode 100644 index 0000000..8f630b3 --- /dev/null +++ b/tests/personalised/api/test_views/test_assignment_file_upload.py @@ -0,0 +1,58 @@ +from django.test import override_settings +from django.urls import reverse +from django.core.files.uploadedfile import SimpleUploadedFile +from django_email_learning.models import ContentDelivery +from django_email_learning.services import jwt_service + + +URL = reverse("django_email_learning:api_personalised:file_upload") + + +def test_file_upload_valid_token( + active_enrollment, course_assignment_content, anonymous_client +): + with override_settings( + STORAGES={"default": {"BACKEND": "django.core.files.storage.InMemoryStorage"}} + ): + content_delivery = ContentDelivery.objects.create( + enrollment=active_enrollment, + course_content=course_assignment_content, + ) + token = jwt_service.generate_jwt( + { + "delivery_id": content_delivery.id, + "delivery_hash": content_delivery.hash_value, + } + ) + + uploaded_file = SimpleUploadedFile( + "assignment.txt", + b"my submission", + content_type="text/plain", + ) + + response = anonymous_client.post( + URL, + data={"token": token, "file": uploaded_file}, + ) + + assert response.status_code == 201 + data = response.json() + assert data["file_path"].startswith("uploads/") + assert data["file_name"] == "assignment.txt" + + +def test_file_upload_invalid_token(anonymous_client): + uploaded_file = SimpleUploadedFile( + "assignment.txt", + b"my submission", + content_type="text/plain", + ) + + response = anonymous_client.post( + URL, + data={"token": "invalid", "file": uploaded_file}, + ) + + assert response.status_code == 400 + assert "The signature is invalid" in response.json()["error"] diff --git a/tests/personalised/api/test_views/test_assignment_submission.py b/tests/personalised/api/test_views/test_assignment_submission.py new file mode 100644 index 0000000..4dbb3bf --- /dev/null +++ b/tests/personalised/api/test_views/test_assignment_submission.py @@ -0,0 +1,57 @@ +from django.urls import reverse +from django_email_learning.models import AssignmentSubmission, ContentDelivery +from django_email_learning.services import jwt_service + + +URL = reverse("django_email_learning:api_personalised:assignment_submission") + + +def test_assignment_submission_api_valid_token( + active_enrollment, course_assignment_content, anonymous_client +): + content_delivery = ContentDelivery.objects.create( + enrollment=active_enrollment, + course_content=course_assignment_content, + ) + token = jwt_service.generate_jwt( + { + "delivery_id": content_delivery.id, + "delivery_hash": content_delivery.hash_value, + } + ) + + response = anonymous_client.post( + URL, + data={ + "token": token, + "text_submission": "My assignment answer", + }, + content_type="application/json", + ) + + assert response.status_code == 200 + assert "message" in response.json() + + submission = AssignmentSubmission.objects.get(delivery=content_delivery) + assert submission.text_submission == "My assignment answer" + + +def test_assignment_submission_api_invalid_token( + active_enrollment, course_assignment_content, anonymous_client +): + ContentDelivery.objects.create( + enrollment=active_enrollment, + course_content=course_assignment_content, + ) + + response = anonymous_client.post( + URL, + data={ + "token": "Invalid", + "text_submission": "My assignment answer", + }, + content_type="application/json", + ) + + assert response.status_code == 400 + assert "The signature is invalid" in response.json()["error"] diff --git a/tests/personalised/test_views/test_assignment_public_view.py b/tests/personalised/test_views/test_assignment_public_view.py new file mode 100644 index 0000000..21eec4c --- /dev/null +++ b/tests/personalised/test_views/test_assignment_public_view.py @@ -0,0 +1,40 @@ +from django.urls import reverse +from django_email_learning.models import ContentDelivery +from django_email_learning.services import jwt_service + + +URL = reverse("django_email_learning:personalised:assignment_public_view") + + +def test_assignment_public_view_valid_token( + active_enrollment, course_assignment_content, anonymous_client +): + course_assignment_content.is_published = True + course_assignment_content.save() + + content_delivery = ContentDelivery.objects.create( + enrollment=active_enrollment, + course_content=course_assignment_content, + ) + + token = jwt_service.generate_jwt( + { + "delivery_id": content_delivery.id, + "delivery_hash": content_delivery.hash_value, + } + ) + + response = anonymous_client.get(f"{URL}?token={token}") + + assert response.status_code == 200 + assert "assignment" in response.context["appContext"] + + assignment = response.context["appContext"]["assignment"] + assert assignment["id"] == content_delivery.course_content.assignment.id + + +def test_assignment_public_view_invalid_token(anonymous_client): + response = anonymous_client.get(f"{URL}?token=invalidtoken") + + assert response.status_code == 400 + assert "The link is not valid" in response.content.decode() diff --git a/tests/test_models/test_delivery_schedule.py b/tests/test_models/test_delivery_schedule.py index ffc6723..62174c7 100644 --- a/tests/test_models/test_delivery_schedule.py +++ b/tests/test_models/test_delivery_schedule.py @@ -1,6 +1,9 @@ from django_email_learning.models import DeliverySchedule, ContentDelivery from urllib.parse import urlparse, parse_qs from django_email_learning.services import jwt_service +from django.conf import settings +import jwt +from datetime import datetime def test_generate_link(course_quiz_content, enrollment): @@ -18,3 +21,57 @@ def test_generate_link(course_quiz_content, enrollment): decoded_token = jwt_service.decode_jwt(query_params["token"][0]) assert decoded_token["delivery_id"] == delivery_schedule.id assert decoded_token["delivery_hash"] == content_delivery.hash_value + + +def test_generate_link_for_assignment_content(course_assignment_content, enrollment): + content_delivery = ContentDelivery.objects.create( + enrollment=enrollment, + course_content=course_assignment_content, + ) + delivery_schedule = DeliverySchedule.objects.create(delivery=content_delivery) + + link = delivery_schedule.generate_link() + + assert link.startswith("http") + parsed_url = urlparse(link) + query_params = parse_qs(parsed_url.query) + assert "token" in query_params + + decoded_token = jwt_service.decode_jwt(query_params["token"][0]) + assert decoded_token["delivery_id"] == delivery_schedule.id + assert decoded_token["delivery_hash"] == content_delivery.hash_value + + +def test_generate_link_assignment_with_zero_deadline_uses_datetime_max_exp( + course_assignment_content, enrollment +): + course_assignment_content.assignment.deadline_days = 0 + course_assignment_content.assignment.save() + + content_delivery = ContentDelivery.objects.create( + enrollment=enrollment, + course_content=course_assignment_content, + ) + delivery_schedule = DeliverySchedule.objects.create(delivery=content_delivery) + + link = delivery_schedule.generate_link() + token = parse_qs(urlparse(link).query)["token"][0] + decoded_token = jwt.decode( + token, + settings.SECRET_KEY, + algorithms=["HS256"], + options={"verify_exp": False}, + ) + + expected_token = jwt_service.generate_jwt( + payload={"placeholder": 1}, + exp=datetime.max, + ) + expected_decoded = jwt.decode( + expected_token, + settings.SECRET_KEY, + algorithms=["HS256"], + options={"verify_exp": False}, + ) + + assert decoded_token["exp"] == expected_decoded["exp"]