Skip to content

Commit c502716

Browse files
committed
feat: #129 Add enrollment API
1 parent 6ce7af3 commit c502716

12 files changed

Lines changed: 270 additions & 83 deletions

File tree

django_email_learning/migrations/0001_initial.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Generated by Django 6.0.1 on 2026-01-09 11:59
1+
# Generated by Django 6.0.1 on 2026-01-17 05:42
22

33
import django.core.validators
44
import django.db.models.deletion
@@ -286,7 +286,7 @@ class Migration(migrations.Migration):
286286
db_index=True, default=django.utils.timezone.now
287287
),
288288
),
289-
("link", models.URLField(blank=True, null=True)),
289+
("link", models.URLField(blank=True, max_length=500, null=True)),
290290
(
291291
"status",
292292
models.CharField(
@@ -309,6 +309,14 @@ class Migration(migrations.Migration):
309309
],
310310
"Delivered",
311311
),
312+
(
313+
django_email_learning.models.DeliveryStatus["CANCELED"],
314+
"Canceled",
315+
),
316+
(
317+
django_email_learning.models.DeliveryStatus["BLOCKED"],
318+
"Blocked",
319+
),
312320
],
313321
db_index=True,
314322
default=django_email_learning.models.DeliveryStatus[
@@ -317,6 +325,7 @@ class Migration(migrations.Migration):
317325
max_length=50,
318326
),
319327
),
328+
("failed_attempts", models.IntegerField(default=0)),
320329
(
321330
"delivery",
322331
models.ForeignKey(
@@ -340,6 +349,8 @@ class Migration(migrations.Migration):
340349
),
341350
),
342351
("enrolled_at", models.DateTimeField(auto_now_add=True)),
352+
("activated_at", models.DateTimeField(blank=True, null=True)),
353+
("final_state_at", models.DateTimeField(blank=True, null=True)),
343354
(
344355
"status",
345356
models.CharField(
@@ -432,6 +443,7 @@ class Migration(migrations.Migration):
432443
name="enrollment",
433444
field=models.ForeignKey(
434445
on_delete=django.db.models.deletion.CASCADE,
446+
related_name="content_deliveries",
435447
to="django_email_learning.enrollment",
436448
),
437449
),
@@ -445,6 +457,14 @@ class Migration(migrations.Migration):
445457
to="django_email_learning.imapconnection",
446458
),
447459
),
460+
migrations.AddField(
461+
model_name="learner",
462+
name="organization",
463+
field=models.ForeignKey(
464+
on_delete=django.db.models.deletion.CASCADE,
465+
to="django_email_learning.organization",
466+
),
467+
),
448468
migrations.AddField(
449469
model_name="imapconnection",
450470
name="organization",
@@ -554,6 +574,7 @@ class Migration(migrations.Migration):
554574
"delivery",
555575
models.ForeignKey(
556576
on_delete=django.db.models.deletion.CASCADE,
577+
related_name="quiz_submissions",
557578
to="django_email_learning.contentdelivery",
558579
),
559580
),

django_email_learning/migrations/0002_learner_organization_alter_deliveryschedule_link.py

Lines changed: 0 additions & 28 deletions
This file was deleted.

django_email_learning/migrations/0003_deliveryschedule_failed_attempts_and_more.py

Lines changed: 0 additions & 49 deletions
This file was deleted.

django_email_learning/models.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,8 @@ class Enrollment(models.Model):
356356
learner = models.ForeignKey(Learner, on_delete=models.CASCADE)
357357
course = models.ForeignKey(Course, on_delete=models.CASCADE)
358358
enrolled_at = models.DateTimeField(auto_now_add=True)
359+
activated_at = models.DateTimeField(null=True, blank=True)
360+
final_state_at = models.DateTimeField(null=True, blank=True)
359361
status = models.CharField(
360362
max_length=50,
361363
choices=[
@@ -400,6 +402,12 @@ def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
400402
"Deactivation reason must be provided when status is 'deactivated'."
401403
)
402404
self.full_clean()
405+
if self.status == EnrollmentStatus.ACTIVE and self.activated_at is None:
406+
self.activated_at = timezone.now()
407+
if self.status in [EnrollmentStatus.COMPLETED, EnrollmentStatus.DEACTIVATED]:
408+
if self.final_state_at is None:
409+
self.final_state_at = timezone.now()
410+
403411
super().save(*args, **kwargs)
404412

405413
def __str__(self) -> str:
@@ -418,6 +426,7 @@ def graduate(self) -> None:
418426
if self.status != EnrollmentStatus.ACTIVE:
419427
raise ValidationError("Only active enrollments can be marked as completed.")
420428
self.status = EnrollmentStatus.COMPLETED
429+
self.final_state_at = timezone.now()
421430
logger.info(
422431
f"Learner ID {self.learner.id} has completed the course {self.course.title}."
423432
)
@@ -430,6 +439,7 @@ def fail(self) -> None:
430439
raise ValidationError("Only active enrollments can be marked as failed.")
431440
self.status = EnrollmentStatus.DEACTIVATED
432441
self.deactivation_reason = DeactivationReason.FAILED
442+
self.final_state_at = timezone.now()
433443
logger.info(
434444
f"Learner ID {self.learner.id} has failed the course {self.course.title}."
435445
)
@@ -457,7 +467,9 @@ def schedule_first_content_delivery(self) -> None:
457467

458468

459469
class ContentDelivery(models.Model):
460-
enrollment = models.ForeignKey(Enrollment, on_delete=models.CASCADE)
470+
enrollment = models.ForeignKey(
471+
Enrollment, on_delete=models.CASCADE, related_name="content_deliveries"
472+
)
461473
course_content = models.ForeignKey(CourseContent, on_delete=models.CASCADE)
462474
hash_value = models.CharField(max_length=64, null=True, blank=True)
463475
valid_until = models.DateTimeField(null=True, blank=True)
@@ -586,7 +598,9 @@ def __str__(self) -> str:
586598

587599

588600
class QuizSubmission(models.Model):
589-
delivery = models.ForeignKey(ContentDelivery, on_delete=models.CASCADE)
601+
delivery = models.ForeignKey(
602+
ContentDelivery, on_delete=models.CASCADE, related_name="quiz_submissions"
603+
)
590604
score = models.IntegerField()
591605
is_passed = models.BooleanField()
592606
submitted_at = models.DateTimeField(auto_now_add=True)

django_email_learning/platform/api/serializers.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
field_validator,
77
model_validator,
88
)
9+
from datetime import datetime
910
from typing import Optional, Literal, Any
1011
from django_email_learning.models import (
12+
DeliveryStatus,
1113
Organization,
1214
ImapConnection,
1315
Lesson,
@@ -17,6 +19,7 @@
1719
CourseContent,
1820
Course,
1921
QuizSelectionStrategy,
22+
Enrollment,
2023
EnrollmentStatus,
2124
)
2225
import enum
@@ -112,6 +115,14 @@ class CourseResponse(BaseModel):
112115
model_config = ConfigDict(from_attributes=True)
113116

114117

118+
class CourseSummaryResponse(BaseModel):
119+
id: int
120+
title: str
121+
slug: str
122+
123+
model_config = ConfigDict(from_attributes=True)
124+
125+
115126
class CreateImapConnectionRequest(BaseModel):
116127
email: str = Field(min_length=1, examples=["user@example.com"])
117128
password: str = Field(min_length=1, examples=["aSafePassword123!"])
@@ -328,6 +339,145 @@ class LearnerResponse(BaseModel):
328339
email: str
329340

330341

342+
class EventType(enum.StrEnum):
343+
REGISTERED = "registered"
344+
VERIFIED = "verified"
345+
DEACTIVATED = "deactivated"
346+
QUIZ_SUBMITED = "quiz_submitted"
347+
CONTENT_SENT = "content_sent"
348+
COURSE_COMPLETED = "course_completed"
349+
350+
351+
class DeactivatedEvent(BaseModel):
352+
type: Literal[EventType.DEACTIVATED] = Field(
353+
default=EventType.DEACTIVATED, exclude=True
354+
)
355+
reason: str
356+
357+
358+
class QuizSubmitedEvent(BaseModel):
359+
type: Literal[EventType.QUIZ_SUBMITED] = Field(
360+
default=EventType.QUIZ_SUBMITED, exclude=True
361+
)
362+
quiz_id: int
363+
quiz_title: str
364+
score: int
365+
is_passed: bool
366+
attempt_number: int
367+
368+
369+
class ContentSentEvent(BaseModel):
370+
type: Literal[EventType.CONTENT_SENT] = Field(
371+
default=EventType.CONTENT_SENT, exclude=True
372+
)
373+
course_content_id: int
374+
course_content_title: str
375+
course_content_type: str
376+
377+
378+
class Event(BaseModel):
379+
type: EventType
380+
timestamp: datetime
381+
event_data: DeactivatedEvent | QuizSubmitedEvent | ContentSentEvent | None = Field(
382+
discriminator="type"
383+
) # REGISTERED, VERIFIED, COURSE_COMPLETED have no additional data
384+
385+
386+
class EnrollmentResponse(BaseModel):
387+
id: int
388+
learner: LearnerResponse
389+
course: CourseSummaryResponse
390+
status: EnrollmentStatus
391+
events: list[Event]
392+
393+
@staticmethod
394+
def from_django_model(enrollment: Enrollment) -> "EnrollmentResponse":
395+
events = [
396+
Event(
397+
type=EventType.REGISTERED,
398+
timestamp=enrollment.enrolled_at,
399+
event_data=None,
400+
)
401+
]
402+
if enrollment.activated_at:
403+
events.append(
404+
Event(
405+
type=EventType.VERIFIED,
406+
timestamp=enrollment.activated_at,
407+
event_data=None,
408+
)
409+
)
410+
for delivery in enrollment.content_deliveries.all(): # type: ignore[attr-defined]
411+
for schedule in delivery.delivery_schedules.filter(
412+
status=DeliveryStatus.DELIVERED
413+
):
414+
events.append(
415+
Event(
416+
type=EventType.CONTENT_SENT,
417+
timestamp=schedule.time,
418+
event_data=ContentSentEvent(
419+
course_content_id=delivery.course_content.id,
420+
course_content_title=delivery.course_content.lesson.title
421+
if delivery.course_content.lesson
422+
else delivery.course_content.quiz.title,
423+
course_content_type=delivery.course_content.type,
424+
),
425+
)
426+
)
427+
if delivery.course_content.type == "quiz":
428+
attempt_number = 0
429+
quiz_attempts = delivery.quiz_submissions.all().order_by(
430+
"submitted_at"
431+
)
432+
for attempt in quiz_attempts:
433+
attempt_number += 1
434+
events.append(
435+
Event(
436+
type=EventType.QUIZ_SUBMITED,
437+
timestamp=attempt.submitted_at,
438+
event_data=QuizSubmitedEvent(
439+
quiz_id=delivery.course_content.quiz.id,
440+
quiz_title=delivery.course_content.quiz.title,
441+
score=attempt.score,
442+
is_passed=attempt.is_passed,
443+
attempt_number=attempt_number,
444+
),
445+
)
446+
)
447+
if (
448+
enrollment.status == EnrollmentStatus.COMPLETED
449+
and enrollment.final_state_at
450+
):
451+
events.append(
452+
Event(
453+
type=EventType.COURSE_COMPLETED,
454+
timestamp=enrollment.final_state_at,
455+
event_data=None,
456+
)
457+
)
458+
elif (
459+
enrollment.status == EnrollmentStatus.DEACTIVATED
460+
and enrollment.final_state_at
461+
):
462+
events.append(
463+
Event(
464+
type=EventType.DEACTIVATED,
465+
timestamp=enrollment.final_state_at,
466+
event_data=DeactivatedEvent(reason=enrollment.deactivation_reason), # type: ignore[arg-type]
467+
)
468+
)
469+
470+
return EnrollmentResponse.model_validate(
471+
{
472+
"id": enrollment.id,
473+
"learner": enrollment.learner,
474+
"course": enrollment.course,
475+
"status": enrollment.status,
476+
"events": events,
477+
}
478+
)
479+
480+
331481
class LearnerDetailResponse(BaseModel):
332482
id: int
333483
email: str

0 commit comments

Comments
 (0)