Skip to content

Commit 89e4c80

Browse files
authored
Merge pull request #418 from AvaCodeSolutions/feat/397/platform-assignment-approval
Feat/397/platform assignment approval
2 parents 6512b75 + d7cf5c5 commit 89e4c80

30 files changed

Lines changed: 2629 additions & 145 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ __pycache__/
44
.venv/
55
venv/
66

7+
django_email_learning/private_files/*
8+
79
# Django
810
db.sqlite3
911
.env

django_email_learning/jobs/deactivate_inactive_enrollments_job.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
JobExecution,
66
JobName,
77
JobStatus,
8-
Quiz,
98
)
109
from django_email_learning.jobs.job_metrics import track_job_execution
1110
from django_email_learning.services.metrics_service import MetricsService
@@ -45,15 +44,15 @@ def _run_job(self, job_execution: JobExecution) -> None:
4544
)
4645

4746
for delivery in deliveries:
48-
if not delivery.course_content.quiz:
47+
if delivery.course_content.lesson:
4948
continue
5049

51-
if not delivery.course_content.quiz.is_blocking:
50+
if not delivery.course_content.is_blocking:
5251
# If the quiz is non-blocking, we do not want to deactivate the enrollment.
5352
# Instead, we simply skip to the next delivery.
5453
logger.info(
5554
f"Skipping deactivation for enrollment {delivery.enrollment.id} because \
56-
quiz {delivery.course_content.quiz.title} is non-blocking."
55+
{delivery.course_content.title} is non-blocking."
5756
)
5857
delivery.schedule_next_delivery()
5958
delivery.valid_until = None # Clear the valid_until since we've scheduled the next delivery
@@ -64,16 +63,16 @@ def _run_job(self, job_execution: JobExecution) -> None:
6463
enrollment.status = EnrollmentStatus.DEACTIVATED
6564
enrollment.deactivation_reason = DeactivationReason.INACTIVE
6665
enrollment.save()
67-
quiz = delivery.course_content.quiz
66+
6867
course_title = delivery.course_content.course.title
6968
logger.info(
7069
f"Deactivated enrollment {enrollment.id} for learner {mask_email(enrollment.learner.email)} due to \
71-
missed deadline for quiz {quiz.title} in course {course_title}."
70+
missed deadline for assignment {delivery.course_content.title} in course {course_title}."
7271
)
7372

7473
self.send_deactivation_email(
7574
enrollment.learner.email,
76-
quiz,
75+
delivery,
7776
course_title,
7877
delivery.course_content.course.organization.name,
7978
)
@@ -88,12 +87,16 @@ def _run_job(self, job_execution: JobExecution) -> None:
8887
job_execution.save()
8988

9089
def send_deactivation_email(
91-
self, email: str, quiz: Quiz, course_title: str, organization_name: str
90+
self,
91+
email: str,
92+
delivery: ContentDelivery,
93+
course_title: str,
94+
organization_name: str,
9295
) -> None:
9396
email_service = EmailSenderService()
94-
subject = _("Your quiz deadline has passed — enrollment deactivated")
97+
subject = _("Your deadline has passed — enrollment deactivated")
9598
context = {
96-
"quiz": quiz,
99+
"content_title": f"{delivery.course_content.type} {delivery.course_content.title}",
97100
"course_title": course_title,
98101
"organization_name": organization_name,
99102
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Generated by Django 6.0.4 on 2026-05-06 09:57
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
("django_email_learning", "0026_alter_assignmentsubmission_delivery"),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name="assignmentfeedback",
15+
name="submission",
16+
field=models.ForeignKey(
17+
on_delete=django.db.models.deletion.CASCADE,
18+
related_name="feedbacks",
19+
to="django_email_learning.assignmentsubmission",
20+
),
21+
),
22+
]
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Generated by Django 6.0.4 on 2026-05-06 10:11
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("django_email_learning", "0027_alter_assignmentfeedback_submission"),
9+
]
10+
11+
operations = [
12+
migrations.RenameField(
13+
model_name="assignmentfeedback",
14+
old_name="comments",
15+
new_name="comment",
16+
),
17+
]
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Generated by Django 6.0.4 on 2026-05-07 05:56
2+
3+
import django.core.files.storage
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
("django_email_learning", "0028_rename_comments_assignmentfeedback_comment"),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name="assignmentsubmission",
15+
name="file_submission",
16+
field=models.FileField(
17+
blank=True,
18+
null=True,
19+
storage=django.core.files.storage.FileSystemStorage(
20+
location="/private_files/"
21+
),
22+
upload_to="assignment_submissions/",
23+
),
24+
),
25+
]

django_email_learning/models.py

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from django.utils.translation import ngettext
3232
from datetime import timedelta
3333
from django_email_learning.services import jwt_service
34-
from django_email_learning.services.utils import mask_email
34+
from django_email_learning.services.utils import PRIVATE_FILE_STORAGE, mask_email
3535

3636
from PIL import Image
3737
from typing import Optional
@@ -1115,7 +1115,10 @@ class SubmissionStatus(models.TextChoices):
11151115
)
11161116
text_submission = models.TextField(null=True, blank=True)
11171117
file_submission = models.FileField(
1118-
upload_to="assignment_submissions/", null=True, blank=True
1118+
storage=PRIVATE_FILE_STORAGE,
1119+
upload_to="assignment_submissions/",
1120+
null=True,
1121+
blank=True,
11191122
)
11201123
submitted_at = models.DateTimeField(auto_now_add=True)
11211124
status = models.CharField(
@@ -1135,20 +1138,44 @@ class SubmissionStatus(models.TextChoices):
11351138

11361139
@staticmethod
11371140
def save_file(file_path: str, delivery: ContentDelivery) -> str:
1138-
if default_storage.exists(file_path):
1139-
if default_storage.size(file_path) > 10 * 1024 * 1024:
1141+
if PRIVATE_FILE_STORAGE.exists(file_path):
1142+
if PRIVATE_FILE_STORAGE.size(file_path) > 10 * 1024 * 1024:
11401143
raise ValueError(
11411144
"File size exceeds the maximum allowed limit of 10 MB."
11421145
)
1143-
file = default_storage.open(file_path)
1146+
file = PRIVATE_FILE_STORAGE.open(file_path)
11441147
final_path = f"organizations/{delivery.enrollment.course.organization.id}/assignments/{delivery.id}/{file_path.split('/')[-1]}"
1145-
default_storage.save(final_path, file)
1148+
PRIVATE_FILE_STORAGE.save(final_path, file)
1149+
PRIVATE_FILE_STORAGE.delete(file_path)
11461150
return final_path
11471151
else:
11481152
raise ValueError("File does not exist.")
11491153

1154+
def private_file_url(self) -> Optional[str]:
1155+
if self.file_submission:
1156+
org_id = self.delivery.enrollment.course.organization.id
1157+
payload = {
1158+
"org_id": org_id,
1159+
"file_path": self.file_submission.path,
1160+
}
1161+
token = jwt_service.generate_jwt(
1162+
payload=payload, exp=datetime.now() + timedelta(hours=3)
1163+
)
1164+
url = (
1165+
reverse("django_email_learning:platform:private_file_view")
1166+
+ f"?token={token}"
1167+
)
1168+
return url
1169+
return None
1170+
1171+
@property
1172+
def assignment(self) -> Assignment:
1173+
if self.delivery.course_content.assignment:
1174+
return self.delivery.course_content.assignment # type: ignore[assignment]
1175+
raise ValueError("Associated content is not an assignment.")
1176+
11501177
def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
1151-
if self.delivery.course_content.type != "assignment":
1178+
if not self.delivery.course_content.assignment:
11521179
raise ValidationError(
11531180
"Sent item must be associated with an assignment content."
11541181
)
@@ -1157,17 +1184,28 @@ def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
11571184
"At least one of text submission or file submission must be provided."
11581185
)
11591186
self.full_clean()
1187+
if not self.pk and self.status != self.SubmissionStatus.PENDING_REVIEW:
1188+
raise ValidationError("New submissions must have status 'pending_review'.")
1189+
if (
1190+
self.assignment.is_blocking
1191+
and self.status == self.SubmissionStatus.APPROVED
1192+
):
1193+
current_state = (
1194+
AssignmentSubmission.objects.get(pk=self.pk).status if self.pk else None
1195+
)
1196+
if current_state != self.SubmissionStatus.APPROVED:
1197+
self.delivery.schedule_next_delivery()
11601198
super().save(*args, **kwargs)
11611199

11621200
def __str__(self) -> str:
11631201
return f"{self.delivery.course_content.assignment.title} | {mask_email(self.delivery.enrollment.learner.email)} | Submitted at: {self.submitted_at}" # type: ignore[union-attr]
11641202

11651203

11661204
class AssignmentFeedback(models.Model):
1167-
submission = models.OneToOneField(
1168-
AssignmentSubmission, on_delete=models.CASCADE, related_name="feedback"
1205+
submission = models.ForeignKey(
1206+
AssignmentSubmission, on_delete=models.CASCADE, related_name="feedbacks"
11691207
)
1170-
comments = models.TextField()
1208+
comment = models.TextField()
11711209
provided_at = models.DateTimeField(auto_now_add=True)
11721210
provided_by = models.ForeignKey(
11731211
OrganizationUser,

django_email_learning/personalised/api/views.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@
1818
Quiz,
1919
EnrollmentStatus,
2020
)
21+
from django_email_learning.services.utils import PRIVATE_FILE_STORAGE
2122
from pydantic import ValidationError
2223
import json
2324
import logging
24-
from django.core.files.storage import default_storage
25+
2526

2627
METRIC_SERVICE = MetricsService()
2728

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

6768
date_prefix = timezone.now().strftime("%Y%m%d")
68-
file_path = default_storage.save(
69+
file_path = PRIVATE_FILE_STORAGE.save(
6970
f"uploads/{date_prefix}/{delivery.enrollment.course.organization.id}/{delivery.id}/{uploaded_file.name}",
7071
uploaded_file,
7172
)

django_email_learning/platform/api/serializers.py

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -278,10 +278,8 @@ def to_django_model(self, course_id: int) -> Course:
278278

279279

280280
class InstructorResponse(BaseModel):
281-
id: int
282-
email: str
283-
284-
model_config = ConfigDict(from_attributes=True)
281+
display_name: str
282+
photo: Optional[str] = None
285283

286284

287285
class CourseResponse(BaseModel):
@@ -335,7 +333,11 @@ def from_django_model(
335333
"is_public": course.is_public,
336334
"instructors": [
337335
InstructorResponse(
338-
id=instructor.org_user.id, email=instructor.org_user.user.email
336+
display_name=instructor.org_user.display_name
337+
or instructor.org_user.user.email,
338+
photo=instructor.org_user.photo.name
339+
if instructor.org_user.photo
340+
else None,
339341
)
340342
for instructor in course.instructors.all()
341343
],
@@ -806,6 +808,11 @@ class ReviewResult(enum.StrEnum):
806808
REQUESTING_CHANGES = "requesting_changes"
807809

808810

811+
class ReviewRquest(BaseModel):
812+
review_result: ReviewResult
813+
comment: Optional[str] = None
814+
815+
809816
class DeactivatedEvent(BaseModel):
810817
type: Literal[EventType.DEACTIVATED] = Field(
811818
default=EventType.DEACTIVATED, exclude=True
@@ -1195,3 +1202,84 @@ def serialize_waiting_period(self, waiting_period: int) -> dict:
11951202

11961203
class ReorderCourseContentsRequest(BaseModel):
11971204
ordered_content_ids: list[int] = Field(min_length=2, examples=[[3, 1, 2]])
1205+
1206+
1207+
class FeedbackResponse(BaseModel):
1208+
comment: str
1209+
provided_by: InstructorResponse
1210+
provided_at: datetime
1211+
1212+
1213+
class AssignmentSubmissionResponse(BaseModel):
1214+
id: int
1215+
assignment_title: str
1216+
submitted_at: datetime
1217+
status: AssignmentSubmission.SubmissionStatus
1218+
reviewed_at: Optional[datetime] = None
1219+
reviewed_by: Optional[InstructorResponse] = None
1220+
file_submission: Optional[str] = None
1221+
text_submission: Optional[str] = None
1222+
feedbacks: list[FeedbackResponse] = []
1223+
1224+
@staticmethod
1225+
def from_django_model(
1226+
submission: AssignmentSubmission,
1227+
request: Any,
1228+
) -> "AssignmentSubmissionResponse":
1229+
return AssignmentSubmissionResponse(
1230+
id=submission.id,
1231+
assignment_title=submission.delivery.course_content.assignment.title, # type: ignore[union-attr]
1232+
submitted_at=submission.submitted_at,
1233+
status=AssignmentSubmission.SubmissionStatus(submission.status),
1234+
reviewed_at=submission.reviewed_at,
1235+
reviewed_by=InstructorResponse(
1236+
display_name=submission.reviewer.display_name
1237+
or submission.reviewer.user.email, # type: ignore[union-attr]
1238+
photo=request.build_absolute_uri(submission.reviewer.photo.url)
1239+
if submission.reviewer.photo
1240+
else None,
1241+
)
1242+
if submission.reviewer
1243+
else None, # type: ignore[union-attr]
1244+
file_submission=submission.private_file_url(),
1245+
text_submission=submission.text_submission,
1246+
feedbacks=[
1247+
FeedbackResponse(
1248+
comment=feedback.comment,
1249+
provided_by=InstructorResponse(
1250+
display_name=feedback.provided_by.display_name # type: ignore[union-attr]
1251+
or feedback.provided_by.user.email, # type: ignore[union-attr]
1252+
photo=request.build_absolute_uri(feedback.provided_by.photo.url)
1253+
if feedback.provided_by.photo
1254+
else None,
1255+
),
1256+
provided_at=feedback.provided_at,
1257+
)
1258+
for feedback in submission.feedbacks.all()
1259+
if feedback.provided_by # type: ignore[union-attr]
1260+
],
1261+
)
1262+
1263+
1264+
class AssignmentSubmissionSummaryResponse(BaseModel):
1265+
id: int
1266+
assignment_title: str
1267+
submitted_at: datetime
1268+
status: AssignmentSubmission.SubmissionStatus
1269+
reviewed_at: Optional[datetime] = None
1270+
reviewed_by: Optional[str] = None
1271+
1272+
@staticmethod
1273+
def from_django_model(
1274+
submission: AssignmentSubmission,
1275+
) -> "AssignmentSubmissionSummaryResponse":
1276+
return AssignmentSubmissionSummaryResponse(
1277+
id=submission.id,
1278+
assignment_title=submission.delivery.course_content.assignment.title, # type: ignore[union-attr]
1279+
submitted_at=submission.submitted_at,
1280+
status=AssignmentSubmission.SubmissionStatus(submission.status),
1281+
reviewed_at=submission.reviewed_at,
1282+
reviewed_by=submission.reviewer.display_name
1283+
if submission.reviewer
1284+
else None, # type: ignore[union-attr]
1285+
)

0 commit comments

Comments
 (0)