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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ __pycache__/
.venv/
venv/

django_email_learning/private_files/*

# Django
db.sqlite3
.env
Expand Down
23 changes: 13 additions & 10 deletions django_email_learning/jobs/deactivate_inactive_enrollments_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
JobExecution,
JobName,
JobStatus,
Quiz,
)
from django_email_learning.jobs.job_metrics import track_job_execution
from django_email_learning.services.metrics_service import MetricsService
Expand Down Expand Up @@ -45,15 +44,15 @@ def _run_job(self, job_execution: JobExecution) -> None:
)

for delivery in deliveries:
if not delivery.course_content.quiz:
if delivery.course_content.lesson:
continue

if not delivery.course_content.quiz.is_blocking:
if not delivery.course_content.is_blocking:
# If the quiz is non-blocking, we do not want to deactivate the enrollment.
# Instead, we simply skip to the next delivery.
logger.info(
f"Skipping deactivation for enrollment {delivery.enrollment.id} because \
quiz {delivery.course_content.quiz.title} is non-blocking."
{delivery.course_content.title} is non-blocking."
)
delivery.schedule_next_delivery()
delivery.valid_until = None # Clear the valid_until since we've scheduled the next delivery
Expand All @@ -64,16 +63,16 @@ def _run_job(self, job_execution: JobExecution) -> None:
enrollment.status = EnrollmentStatus.DEACTIVATED
enrollment.deactivation_reason = DeactivationReason.INACTIVE
enrollment.save()
quiz = delivery.course_content.quiz

course_title = delivery.course_content.course.title
logger.info(
f"Deactivated enrollment {enrollment.id} for learner {mask_email(enrollment.learner.email)} due to \
missed deadline for quiz {quiz.title} in course {course_title}."
missed deadline for assignment {delivery.course_content.title} in course {course_title}."
)

self.send_deactivation_email(
enrollment.learner.email,
quiz,
delivery,
course_title,
delivery.course_content.course.organization.name,
)
Expand All @@ -88,12 +87,16 @@ def _run_job(self, job_execution: JobExecution) -> None:
job_execution.save()

def send_deactivation_email(
self, email: str, quiz: Quiz, course_title: str, organization_name: str
self,
email: str,
delivery: ContentDelivery,
course_title: str,
organization_name: str,
) -> None:
email_service = EmailSenderService()
subject = _("Your quiz deadline has passed — enrollment deactivated")
subject = _("Your deadline has passed — enrollment deactivated")
context = {
"quiz": quiz,
"content_title": f"{delivery.course_content.type} {delivery.course_content.title}",
"course_title": course_title,
"organization_name": organization_name,
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 6.0.4 on 2026-05-06 09:57

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("django_email_learning", "0026_alter_assignmentsubmission_delivery"),
]

operations = [
migrations.AlterField(
model_name="assignmentfeedback",
name="submission",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="feedbacks",
to="django_email_learning.assignmentsubmission",
),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 6.0.4 on 2026-05-06 10:11

from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("django_email_learning", "0027_alter_assignmentfeedback_submission"),
]

operations = [
migrations.RenameField(
model_name="assignmentfeedback",
old_name="comments",
new_name="comment",
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Generated by Django 6.0.4 on 2026-05-07 05:56

import django.core.files.storage
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("django_email_learning", "0028_rename_comments_assignmentfeedback_comment"),
]

operations = [
migrations.AlterField(
model_name="assignmentsubmission",
name="file_submission",
field=models.FileField(
blank=True,
null=True,
storage=django.core.files.storage.FileSystemStorage(
location="/private_files/"
),
upload_to="assignment_submissions/",
),
),
]
58 changes: 48 additions & 10 deletions django_email_learning/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +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 django_email_learning.services.utils import PRIVATE_FILE_STORAGE, mask_email

from PIL import Image
from typing import Optional
Expand Down Expand Up @@ -1115,7 +1115,10 @@ class SubmissionStatus(models.TextChoices):
)
text_submission = models.TextField(null=True, blank=True)
file_submission = models.FileField(
upload_to="assignment_submissions/", null=True, blank=True
storage=PRIVATE_FILE_STORAGE,
upload_to="assignment_submissions/",
null=True,
blank=True,
)
submitted_at = models.DateTimeField(auto_now_add=True)
status = models.CharField(
Expand All @@ -1135,20 +1138,44 @@ class SubmissionStatus(models.TextChoices):

@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:
if PRIVATE_FILE_STORAGE.exists(file_path):
if PRIVATE_FILE_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)
file = PRIVATE_FILE_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)
PRIVATE_FILE_STORAGE.save(final_path, file)
PRIVATE_FILE_STORAGE.delete(file_path)
return final_path
else:
raise ValueError("File does not exist.")

def private_file_url(self) -> Optional[str]:
if self.file_submission:
org_id = self.delivery.enrollment.course.organization.id
payload = {
"org_id": org_id,
"file_path": self.file_submission.path,
}
token = jwt_service.generate_jwt(
payload=payload, exp=datetime.now() + timedelta(hours=3)
)
url = (
reverse("django_email_learning:platform:private_file_view")
+ f"?token={token}"
)
return url
return None

@property
def assignment(self) -> Assignment:
if self.delivery.course_content.assignment:
return self.delivery.course_content.assignment # type: ignore[assignment]
raise ValueError("Associated content is not an assignment.")

def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
if self.delivery.course_content.type != "assignment":
if not self.delivery.course_content.assignment:
raise ValidationError(
"Sent item must be associated with an assignment content."
)
Expand All @@ -1157,17 +1184,28 @@ def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
"At least one of text submission or file submission must be provided."
)
self.full_clean()
if not self.pk and self.status != self.SubmissionStatus.PENDING_REVIEW:
raise ValidationError("New submissions must have status 'pending_review'.")
if (
self.assignment.is_blocking
and self.status == self.SubmissionStatus.APPROVED
):
current_state = (
AssignmentSubmission.objects.get(pk=self.pk).status if self.pk else None
)
if current_state != self.SubmissionStatus.APPROVED:
self.delivery.schedule_next_delivery()
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"
submission = models.ForeignKey(
AssignmentSubmission, on_delete=models.CASCADE, related_name="feedbacks"
)
comments = models.TextField()
comment = models.TextField()
provided_at = models.DateTimeField(auto_now_add=True)
provided_by = models.ForeignKey(
OrganizationUser,
Expand Down
5 changes: 3 additions & 2 deletions django_email_learning/personalised/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@
Quiz,
EnrollmentStatus,
)
from django_email_learning.services.utils import PRIVATE_FILE_STORAGE
from pydantic import ValidationError
import json
import logging
from django.core.files.storage import default_storage


METRIC_SERVICE = MetricsService()

Expand Down Expand Up @@ -65,7 +66,7 @@ def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
)

date_prefix = timezone.now().strftime("%Y%m%d")
file_path = default_storage.save(
file_path = PRIVATE_FILE_STORAGE.save(
f"uploads/{date_prefix}/{delivery.enrollment.course.organization.id}/{delivery.id}/{uploaded_file.name}",
uploaded_file,
)
Expand Down
98 changes: 93 additions & 5 deletions django_email_learning/platform/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,10 +278,8 @@ def to_django_model(self, course_id: int) -> Course:


class InstructorResponse(BaseModel):
id: int
email: str

model_config = ConfigDict(from_attributes=True)
display_name: str
photo: Optional[str] = None


class CourseResponse(BaseModel):
Expand Down Expand Up @@ -335,7 +333,11 @@ def from_django_model(
"is_public": course.is_public,
"instructors": [
InstructorResponse(
id=instructor.org_user.id, email=instructor.org_user.user.email
display_name=instructor.org_user.display_name
or instructor.org_user.user.email,
photo=instructor.org_user.photo.name
if instructor.org_user.photo
else None,
)
for instructor in course.instructors.all()
],
Expand Down Expand Up @@ -806,6 +808,11 @@ class ReviewResult(enum.StrEnum):
REQUESTING_CHANGES = "requesting_changes"


class ReviewRquest(BaseModel):
review_result: ReviewResult
comment: Optional[str] = None


class DeactivatedEvent(BaseModel):
type: Literal[EventType.DEACTIVATED] = Field(
default=EventType.DEACTIVATED, exclude=True
Expand Down Expand Up @@ -1195,3 +1202,84 @@ def serialize_waiting_period(self, waiting_period: int) -> dict:

class ReorderCourseContentsRequest(BaseModel):
ordered_content_ids: list[int] = Field(min_length=2, examples=[[3, 1, 2]])


class FeedbackResponse(BaseModel):
comment: str
provided_by: InstructorResponse
provided_at: datetime


class AssignmentSubmissionResponse(BaseModel):
id: int
assignment_title: str
submitted_at: datetime
status: AssignmentSubmission.SubmissionStatus
reviewed_at: Optional[datetime] = None
reviewed_by: Optional[InstructorResponse] = None
file_submission: Optional[str] = None
text_submission: Optional[str] = None
feedbacks: list[FeedbackResponse] = []

@staticmethod
def from_django_model(
submission: AssignmentSubmission,
request: Any,
) -> "AssignmentSubmissionResponse":
return AssignmentSubmissionResponse(
id=submission.id,
assignment_title=submission.delivery.course_content.assignment.title, # type: ignore[union-attr]
submitted_at=submission.submitted_at,
status=AssignmentSubmission.SubmissionStatus(submission.status),
reviewed_at=submission.reviewed_at,
reviewed_by=InstructorResponse(
display_name=submission.reviewer.display_name
or submission.reviewer.user.email, # type: ignore[union-attr]
photo=request.build_absolute_uri(submission.reviewer.photo.url)
if submission.reviewer.photo
else None,
)
if submission.reviewer
else None, # type: ignore[union-attr]
file_submission=submission.private_file_url(),
text_submission=submission.text_submission,
feedbacks=[
FeedbackResponse(
comment=feedback.comment,
provided_by=InstructorResponse(
display_name=feedback.provided_by.display_name # type: ignore[union-attr]
or feedback.provided_by.user.email, # type: ignore[union-attr]
photo=request.build_absolute_uri(feedback.provided_by.photo.url)
if feedback.provided_by.photo
else None,
),
provided_at=feedback.provided_at,
)
for feedback in submission.feedbacks.all()
if feedback.provided_by # type: ignore[union-attr]
],
)


class AssignmentSubmissionSummaryResponse(BaseModel):
id: int
assignment_title: str
submitted_at: datetime
status: AssignmentSubmission.SubmissionStatus
reviewed_at: Optional[datetime] = None
reviewed_by: Optional[str] = None

@staticmethod
def from_django_model(
submission: AssignmentSubmission,
) -> "AssignmentSubmissionSummaryResponse":
return AssignmentSubmissionSummaryResponse(
id=submission.id,
assignment_title=submission.delivery.course_content.assignment.title, # type: ignore[union-attr]
submitted_at=submission.submitted_at,
status=AssignmentSubmission.SubmissionStatus(submission.status),
reviewed_at=submission.reviewed_at,
reviewed_by=submission.reviewer.display_name
if submission.reviewer
else None, # type: ignore[union-attr]
)
Loading
Loading