Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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",
),
),
],
),
]
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
Original file line number Diff line number Diff line change
@@ -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,
),
),
]
99 changes: 94 additions & 5 deletions django_email_learning/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
Expand All @@ -1038,18 +1043,22 @@ 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}"
self.link = link
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 ""
Expand Down Expand Up @@ -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)]
Expand Down
12 changes: 12 additions & 0 deletions django_email_learning/personalised/api/urls.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from django.urls import path
from django_email_learning.personalised.api.views import (
FileUploadView,
AssignmentSubmissionView,
QuizSubmissionView,
SubmitCertificateFormView,
)
Expand All @@ -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(),
Expand Down
Loading