Skip to content

Commit fd2cb2e

Browse files
authored
Merge pull request #119 from AvaCodeSolutions/feat/40/command-handler-service
Feat/40/command handler service
2 parents 6c976ec + 08ea3c9 commit fd2cb2e

39 files changed

+1015
-78
lines changed

django_email_learning/apps.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,23 @@
11
from django.apps import AppConfig
2+
from django.core import checks
3+
4+
5+
def check_site_base_url_config(app_configs, **kwargs): # type: ignore[no-untyped-def]
6+
errors = []
7+
from django.conf import settings
8+
9+
if (
10+
not hasattr(settings, "DJANGO_EMAIL_LEARNING")
11+
or "SITE_BASE_URL" not in settings.DJANGO_EMAIL_LEARNING
12+
):
13+
errors.append(
14+
checks.Error(
15+
"DJANGO_EMAIL_LEARNING['SITE_BASE_URL'] is not set in settings.",
16+
hint="Please set DJANGO_EMAIL_LEARNING['SITE_BASE_URL'] to the base URL of your site.",
17+
id="django_email_learning.E001",
18+
)
19+
)
20+
return errors
221

322

423
class EmailLearningConfig(AppConfig):
@@ -8,3 +27,5 @@ class EmailLearningConfig(AppConfig):
827

928
def ready(self) -> None:
1029
import django_email_learning.signals # noqa
30+
31+
checks.register(check_site_base_url_config)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Generated by Django 6.0 on 2026-01-07 06:35
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("django_email_learning", "0001_initial"),
9+
]
10+
11+
operations = [
12+
migrations.RemoveField(
13+
model_name="enrollment",
14+
name="next_send_timestamp",
15+
),
16+
]
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 6.0 on 2026-01-07 06:43
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", "0002_remove_enrollment_next_send_timestamp"),
10+
]
11+
12+
operations = [
13+
migrations.RemoveField(
14+
model_name="contentdelivery",
15+
name="delivery_schedules",
16+
),
17+
migrations.AddField(
18+
model_name="deliveryschedule",
19+
name="delivery",
20+
field=models.ForeignKey(
21+
default=1,
22+
on_delete=django.db.models.deletion.CASCADE,
23+
related_name="delivery_schedules",
24+
to="django_email_learning.contentdelivery",
25+
),
26+
preserve_default=False,
27+
),
28+
]
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 6.0 on 2026-01-07 07:18
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
(
9+
"django_email_learning",
10+
"0003_remove_contentdelivery_delivery_schedules_and_more",
11+
),
12+
]
13+
14+
operations = [
15+
migrations.RemoveField(
16+
model_name="lesson",
17+
name="is_published",
18+
),
19+
migrations.RemoveField(
20+
model_name="quiz",
21+
name="is_published",
22+
),
23+
migrations.AddField(
24+
model_name="coursecontent",
25+
name="is_published",
26+
field=models.BooleanField(default=False),
27+
),
28+
]
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Generated by Django 6.0 on 2026-01-07 10:39
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
(
9+
"django_email_learning",
10+
"0004_remove_lesson_is_published_remove_quiz_is_published_and_more",
11+
),
12+
]
13+
14+
operations = [
15+
migrations.AlterField(
16+
model_name="enrollment",
17+
name="activation_code",
18+
field=models.CharField(blank=True, max_length=6, null=True),
19+
),
20+
]

django_email_learning/models.py

Lines changed: 48 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import base64
22
import ipaddress
33
import re
4+
import random
45
import uuid
56
from enum import StrEnum
67
from typing import Any
78
from django.conf import settings
8-
from django.db import models
9+
from django.db import models, transaction
910
from django.core.validators import MaxValueValidator
1011
from django.core.exceptions import ImproperlyConfigured
1112
from cryptography.fernet import Fernet
@@ -14,6 +15,7 @@
1415
from django.forms import ValidationError
1516
from django.contrib.auth.models import User
1617
from django.utils import timezone
18+
from datetime import timedelta
1719

1820

1921
FIXED_SALT = b"\x00" * 16
@@ -132,7 +134,6 @@ def delete(
132134
class Lesson(models.Model):
133135
title = models.CharField(max_length=200)
134136
content = models.TextField()
135-
is_published = models.BooleanField(default=False)
136137

137138
def __str__(self) -> str:
138139
return self.title
@@ -141,7 +142,6 @@ def __str__(self) -> str:
141142
class Quiz(models.Model):
142143
title = models.CharField(max_length=500)
143144
required_score = models.IntegerField(validators=[MaxValueValidator(100)])
144-
is_published = models.BooleanField(default=False)
145145

146146
class Meta:
147147
verbose_name_plural = "Quizzes"
@@ -159,24 +159,6 @@ def validate_questions(self) -> None:
159159
except ValidationError as e:
160160
raise ValidationError(f"For question '{question.text}', {e.message}")
161161

162-
def full_clean(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
163-
if self.is_published:
164-
try:
165-
self.validate_questions()
166-
except ValueError as e:
167-
if not self.pk:
168-
raise ValidationError(
169-
"Quiz can not be saved as published the first time. "
170-
"please save unpublished and try to publish again."
171-
)
172-
raise e
173-
174-
super().full_clean(*args, **kwargs)
175-
176-
def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
177-
self.full_clean()
178-
super().save(*args, **kwargs)
179-
180162

181163
class Question(models.Model):
182164
quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE, related_name="questions")
@@ -208,7 +190,7 @@ def __str__(self) -> str:
208190
return self.text
209191

210192
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]: # type: ignore[no-untyped-def]
211-
if self.question.quiz.is_published:
193+
if self.question.quiz.coursecontent_set.filter(is_published=True).exists():
212194
raise ValidationError("Cannot delete answers from a published quiz.")
213195
return super().delete(*args, **kwargs)
214196

@@ -228,6 +210,7 @@ class CourseContent(models.Model):
228210
waiting_period = models.IntegerField(
229211
help_text="Waiting period in seconds after previous content is sent or submited."
230212
)
213+
is_published = models.BooleanField(default=False)
231214

232215
def __str__(self) -> str:
233216
if self.type == "lesson" and self.lesson:
@@ -244,14 +227,6 @@ def title(self) -> str:
244227
return self.quiz.title
245228
return "Untitled Content"
246229

247-
@property
248-
def is_published(self) -> bool:
249-
if self.type == "lesson" and self.lesson:
250-
return self.lesson.is_published
251-
elif self.type == "quiz" and self.quiz:
252-
return self.quiz.is_published
253-
return False
254-
255230
def _validate_content(self) -> None:
256231
if self.type == "lesson" and not self.lesson:
257232
raise ValidationError("Lesson must be provided for lesson content.")
@@ -318,6 +293,13 @@ class EnrollmentStatus(StrEnum):
318293
DEACTIVATED = "deactivated"
319294

320295

296+
class DeactivationReason(StrEnum):
297+
CANCELED = "canceled"
298+
BLOCKED = "blocked"
299+
FAILED = "failed"
300+
INACTIVE = "inactive"
301+
302+
321303
class Enrollment(models.Model):
322304
state_transitions = {
323305
EnrollmentStatus.UNVERIFIED: [
@@ -334,7 +316,6 @@ class Enrollment(models.Model):
334316
learner = models.ForeignKey(Learner, on_delete=models.CASCADE)
335317
course = models.ForeignKey(Course, on_delete=models.CASCADE)
336318
enrolled_at = models.DateTimeField(auto_now_add=True)
337-
next_send_timestamp = models.DateTimeField(null=True, blank=True)
338319
status = models.CharField(
339320
max_length=50,
340321
choices=[
@@ -349,14 +330,14 @@ class Enrollment(models.Model):
349330
null=True,
350331
blank=True,
351332
choices=[
352-
("canceled", "Canceled"),
353-
("blocked", "Blocked"),
354-
("failed", "Failed"),
355-
("inactive", "Inactive"),
333+
(DeactivationReason.CANCELED, "Canceled"),
334+
(DeactivationReason.BLOCKED, "Blocked"),
335+
(DeactivationReason.FAILED, "Failed"),
336+
(DeactivationReason.INACTIVE, "Inactive"),
356337
],
357338
max_length=50,
358339
)
359-
activation_code = models.CharField(max_length=100, null=True, blank=True)
340+
activation_code = models.CharField(max_length=6, null=True, blank=True)
360341

361342
def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
362343
if self.pk:
@@ -368,6 +349,8 @@ def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
368349
raise ValidationError(
369350
f"Invalid status transition from {old_status} to {self.status}."
370351
)
352+
else:
353+
self.activation_code = "".join(random.choices("0123456789", k=6))
371354
if self.status != "deactivated" and self.deactivation_reason is not None:
372355
raise ValidationError(
373356
"Deactivation reason must be null unless status is 'deactivated'."
@@ -391,19 +374,29 @@ class Meta:
391374
)
392375
]
393376

394-
395-
class DeliverySchedule(models.Model):
396-
time = models.DateTimeField(default=timezone.now, db_index=True)
397-
is_delivered = models.BooleanField(default=False, db_index=True)
398-
399-
def __str__(self) -> str:
400-
return f"Delivery at {self.time} - Delivered: {self.is_delivered}"
377+
@transaction.atomic()
378+
def schedule_first_content_delivery(self) -> None:
379+
first_content = (
380+
CourseContent.objects.filter(course=self.course, is_published=True)
381+
.order_by("priority")
382+
.first()
383+
)
384+
if first_content:
385+
delivery = ContentDelivery.objects.create(
386+
enrollment=self,
387+
course_content=first_content,
388+
)
389+
DeliverySchedule.objects.create(
390+
time=timezone.now() + timedelta(seconds=first_content.waiting_period),
391+
delivery=delivery,
392+
)
393+
else:
394+
raise ValidationError("No published content available to schedule.")
401395

402396

403397
class ContentDelivery(models.Model):
404398
enrollment = models.ForeignKey(Enrollment, on_delete=models.CASCADE)
405399
course_content = models.ForeignKey(CourseContent, on_delete=models.CASCADE)
406-
delivery_schedules = models.ManyToManyField(DeliverySchedule)
407400
hash_value = models.CharField(max_length=64, null=True, blank=True)
408401

409402
class Meta:
@@ -431,6 +424,17 @@ def __str__(self) -> str:
431424
return f"Delivery of {self.course_content.title} to {self.enrollment.learner.email}"
432425

433426

427+
class DeliverySchedule(models.Model):
428+
delivery = models.ForeignKey(
429+
ContentDelivery, on_delete=models.CASCADE, related_name="delivery_schedules"
430+
)
431+
time = models.DateTimeField(default=timezone.now, db_index=True)
432+
is_delivered = models.BooleanField(default=False, db_index=True)
433+
434+
def __str__(self) -> str:
435+
return f"Delivery for {self.delivery.course_content.title} to {self.delivery.enrollment.learner.email} at {self.time} - Delivered: {self.is_delivered}"
436+
437+
434438
class QuizSubmission(models.Model):
435439
delivery = models.ForeignKey(ContentDelivery, on_delete=models.CASCADE)
436440
score = models.IntegerField()

django_email_learning/personalised/urls.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,7 @@
55

66
urlpatterns = [
77
path("quiz/", QuizPublicView.as_view(), name="quiz_public_view"),
8+
path(
9+
"verify-enrollment/", QuizPublicView.as_view(), name="verify_enrollment"
10+
), # TODO: Replace with actual view
811
]

django_email_learning/personalised/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def get(self, request, *args, **kwargs) -> HttpResponse: # type: ignore[no-unty
3131
return self.errr_response(
3232
message="No quiz associated with this link", exception=None
3333
)
34-
if not quiz.is_published:
34+
if not delivery.course_content.is_published:
3535
return self.errr_response(
3636
message="No valid quiz associated with this link",
3737
exception=ValueError("Quiz is not published"),

django_email_learning/platform/api/serializers.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,6 @@ class LessonResponse(BaseModel):
191191
id: int
192192
title: str
193193
content: str
194-
is_published: bool
195194

196195
model_config = ConfigDict(from_attributes=True)
197196

@@ -260,7 +259,6 @@ class QuizResponse(BaseModel):
260259
title: str
261260
required_score: int
262261
questions: Any # Will be converted to list in field_serializer
263-
is_published: bool
264262

265263
@field_serializer("questions")
266264
def serialize_questions(self, questions: Any) -> list[dict]:
@@ -389,6 +387,7 @@ class CourseContentResponse(BaseModel):
389387
type: str
390388
lesson: Optional[LessonResponse] = None
391389
quiz: Optional[QuizResponse] = None
390+
is_published: bool
392391

393392
@field_serializer("waiting_period")
394393
def serialize_waiting_period(self, waiting_period: int) -> dict:

django_email_learning/platform/api/views.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -219,14 +219,8 @@ def _update_course_content_atomic(
219219
course_content.waiting_period = serializer.waiting_period.to_seconds()
220220

221221
if serializer.is_published is not None:
222-
if course_content.type == "lesson" and course_content.lesson is not None:
223-
lesson = course_content.lesson
224-
lesson.is_published = serializer.is_published
225-
lesson.save()
226-
elif course_content.type == "quiz" and course_content.quiz is not None:
227-
quiz = course_content.quiz
228-
quiz.is_published = serializer.is_published
229-
quiz.save()
222+
course_content.is_published = serializer.is_published
223+
course_content.save()
230224

231225
if serializer.lesson is not None and course_content.lesson is not None:
232226
lesson_serializer = serializer.lesson

0 commit comments

Comments
 (0)