diff --git a/django_email_learning/apps.py b/django_email_learning/apps.py index 5eee8362..469f14ab 100644 --- a/django_email_learning/apps.py +++ b/django_email_learning/apps.py @@ -1,4 +1,23 @@ from django.apps import AppConfig +from django.core import checks + + +def check_site_base_url_config(app_configs, **kwargs): # type: ignore[no-untyped-def] + errors = [] + from django.conf import settings + + if ( + not hasattr(settings, "DJANGO_EMAIL_LEARNING") + or "SITE_BASE_URL" not in settings.DJANGO_EMAIL_LEARNING + ): + errors.append( + checks.Error( + "DJANGO_EMAIL_LEARNING['SITE_BASE_URL'] is not set in settings.", + hint="Please set DJANGO_EMAIL_LEARNING['SITE_BASE_URL'] to the base URL of your site.", + id="django_email_learning.E001", + ) + ) + return errors class EmailLearningConfig(AppConfig): @@ -8,3 +27,5 @@ class EmailLearningConfig(AppConfig): def ready(self) -> None: import django_email_learning.signals # noqa + + checks.register(check_site_base_url_config) diff --git a/django_email_learning/migrations/0002_remove_enrollment_next_send_timestamp.py b/django_email_learning/migrations/0002_remove_enrollment_next_send_timestamp.py new file mode 100644 index 00000000..a3d8879e --- /dev/null +++ b/django_email_learning/migrations/0002_remove_enrollment_next_send_timestamp.py @@ -0,0 +1,16 @@ +# Generated by Django 6.0 on 2026-01-07 06:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("django_email_learning", "0001_initial"), + ] + + operations = [ + migrations.RemoveField( + model_name="enrollment", + name="next_send_timestamp", + ), + ] diff --git a/django_email_learning/migrations/0003_remove_contentdelivery_delivery_schedules_and_more.py b/django_email_learning/migrations/0003_remove_contentdelivery_delivery_schedules_and_more.py new file mode 100644 index 00000000..34dbdd68 --- /dev/null +++ b/django_email_learning/migrations/0003_remove_contentdelivery_delivery_schedules_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0 on 2026-01-07 06:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("django_email_learning", "0002_remove_enrollment_next_send_timestamp"), + ] + + operations = [ + migrations.RemoveField( + model_name="contentdelivery", + name="delivery_schedules", + ), + migrations.AddField( + model_name="deliveryschedule", + name="delivery", + field=models.ForeignKey( + default=1, + on_delete=django.db.models.deletion.CASCADE, + related_name="delivery_schedules", + to="django_email_learning.contentdelivery", + ), + preserve_default=False, + ), + ] diff --git a/django_email_learning/migrations/0004_remove_lesson_is_published_remove_quiz_is_published_and_more.py b/django_email_learning/migrations/0004_remove_lesson_is_published_remove_quiz_is_published_and_more.py new file mode 100644 index 00000000..0d560bf4 --- /dev/null +++ b/django_email_learning/migrations/0004_remove_lesson_is_published_remove_quiz_is_published_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0 on 2026-01-07 07:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "django_email_learning", + "0003_remove_contentdelivery_delivery_schedules_and_more", + ), + ] + + operations = [ + migrations.RemoveField( + model_name="lesson", + name="is_published", + ), + migrations.RemoveField( + model_name="quiz", + name="is_published", + ), + migrations.AddField( + model_name="coursecontent", + name="is_published", + field=models.BooleanField(default=False), + ), + ] diff --git a/django_email_learning/migrations/0005_alter_enrollment_activation_code.py b/django_email_learning/migrations/0005_alter_enrollment_activation_code.py new file mode 100644 index 00000000..59e7c7ce --- /dev/null +++ b/django_email_learning/migrations/0005_alter_enrollment_activation_code.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0 on 2026-01-07 10:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "django_email_learning", + "0004_remove_lesson_is_published_remove_quiz_is_published_and_more", + ), + ] + + operations = [ + migrations.AlterField( + model_name="enrollment", + name="activation_code", + field=models.CharField(blank=True, max_length=6, null=True), + ), + ] diff --git a/django_email_learning/models.py b/django_email_learning/models.py index 4dcc76a8..c47ec68b 100644 --- a/django_email_learning/models.py +++ b/django_email_learning/models.py @@ -1,11 +1,12 @@ import base64 import ipaddress import re +import random import uuid from enum import StrEnum from typing import Any from django.conf import settings -from django.db import models +from django.db import models, transaction from django.core.validators import MaxValueValidator from django.core.exceptions import ImproperlyConfigured from cryptography.fernet import Fernet @@ -14,6 +15,7 @@ from django.forms import ValidationError from django.contrib.auth.models import User from django.utils import timezone +from datetime import timedelta FIXED_SALT = b"\x00" * 16 @@ -132,7 +134,6 @@ def delete( class Lesson(models.Model): title = models.CharField(max_length=200) content = models.TextField() - is_published = models.BooleanField(default=False) def __str__(self) -> str: return self.title @@ -141,7 +142,6 @@ def __str__(self) -> str: class Quiz(models.Model): title = models.CharField(max_length=500) required_score = models.IntegerField(validators=[MaxValueValidator(100)]) - is_published = models.BooleanField(default=False) class Meta: verbose_name_plural = "Quizzes" @@ -159,24 +159,6 @@ def validate_questions(self) -> None: except ValidationError as e: raise ValidationError(f"For question '{question.text}', {e.message}") - def full_clean(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] - if self.is_published: - try: - self.validate_questions() - except ValueError as e: - if not self.pk: - raise ValidationError( - "Quiz can not be saved as published the first time. " - "please save unpublished and try to publish again." - ) - raise e - - super().full_clean(*args, **kwargs) - - def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] - self.full_clean() - super().save(*args, **kwargs) - class Question(models.Model): quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE, related_name="questions") @@ -208,7 +190,7 @@ def __str__(self) -> str: return self.text def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]: # type: ignore[no-untyped-def] - if self.question.quiz.is_published: + if self.question.quiz.coursecontent_set.filter(is_published=True).exists(): raise ValidationError("Cannot delete answers from a published quiz.") return super().delete(*args, **kwargs) @@ -228,6 +210,7 @@ class CourseContent(models.Model): waiting_period = models.IntegerField( help_text="Waiting period in seconds after previous content is sent or submited." ) + is_published = models.BooleanField(default=False) def __str__(self) -> str: if self.type == "lesson" and self.lesson: @@ -244,14 +227,6 @@ def title(self) -> str: return self.quiz.title return "Untitled Content" - @property - def is_published(self) -> bool: - if self.type == "lesson" and self.lesson: - return self.lesson.is_published - elif self.type == "quiz" and self.quiz: - return self.quiz.is_published - return False - def _validate_content(self) -> None: if self.type == "lesson" and not self.lesson: raise ValidationError("Lesson must be provided for lesson content.") @@ -318,6 +293,13 @@ class EnrollmentStatus(StrEnum): DEACTIVATED = "deactivated" +class DeactivationReason(StrEnum): + CANCELED = "canceled" + BLOCKED = "blocked" + FAILED = "failed" + INACTIVE = "inactive" + + class Enrollment(models.Model): state_transitions = { EnrollmentStatus.UNVERIFIED: [ @@ -334,7 +316,6 @@ class Enrollment(models.Model): learner = models.ForeignKey(Learner, on_delete=models.CASCADE) course = models.ForeignKey(Course, on_delete=models.CASCADE) enrolled_at = models.DateTimeField(auto_now_add=True) - next_send_timestamp = models.DateTimeField(null=True, blank=True) status = models.CharField( max_length=50, choices=[ @@ -349,14 +330,14 @@ class Enrollment(models.Model): null=True, blank=True, choices=[ - ("canceled", "Canceled"), - ("blocked", "Blocked"), - ("failed", "Failed"), - ("inactive", "Inactive"), + (DeactivationReason.CANCELED, "Canceled"), + (DeactivationReason.BLOCKED, "Blocked"), + (DeactivationReason.FAILED, "Failed"), + (DeactivationReason.INACTIVE, "Inactive"), ], max_length=50, ) - activation_code = models.CharField(max_length=100, null=True, blank=True) + activation_code = models.CharField(max_length=6, null=True, blank=True) def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] if self.pk: @@ -368,6 +349,8 @@ def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] raise ValidationError( f"Invalid status transition from {old_status} to {self.status}." ) + else: + self.activation_code = "".join(random.choices("0123456789", k=6)) if self.status != "deactivated" and self.deactivation_reason is not None: raise ValidationError( "Deactivation reason must be null unless status is 'deactivated'." @@ -391,19 +374,29 @@ class Meta: ) ] - -class DeliverySchedule(models.Model): - time = models.DateTimeField(default=timezone.now, db_index=True) - is_delivered = models.BooleanField(default=False, db_index=True) - - def __str__(self) -> str: - return f"Delivery at {self.time} - Delivered: {self.is_delivered}" + @transaction.atomic() + def schedule_first_content_delivery(self) -> None: + first_content = ( + CourseContent.objects.filter(course=self.course, is_published=True) + .order_by("priority") + .first() + ) + if first_content: + delivery = ContentDelivery.objects.create( + enrollment=self, + course_content=first_content, + ) + DeliverySchedule.objects.create( + time=timezone.now() + timedelta(seconds=first_content.waiting_period), + delivery=delivery, + ) + else: + raise ValidationError("No published content available to schedule.") class ContentDelivery(models.Model): enrollment = models.ForeignKey(Enrollment, on_delete=models.CASCADE) course_content = models.ForeignKey(CourseContent, on_delete=models.CASCADE) - delivery_schedules = models.ManyToManyField(DeliverySchedule) hash_value = models.CharField(max_length=64, null=True, blank=True) class Meta: @@ -431,6 +424,17 @@ def __str__(self) -> str: return f"Delivery of {self.course_content.title} to {self.enrollment.learner.email}" +class DeliverySchedule(models.Model): + delivery = models.ForeignKey( + ContentDelivery, on_delete=models.CASCADE, related_name="delivery_schedules" + ) + time = models.DateTimeField(default=timezone.now, db_index=True) + is_delivered = models.BooleanField(default=False, db_index=True) + + def __str__(self) -> str: + return f"Delivery for {self.delivery.course_content.title} to {self.delivery.enrollment.learner.email} at {self.time} - Delivered: {self.is_delivered}" + + class QuizSubmission(models.Model): delivery = models.ForeignKey(ContentDelivery, on_delete=models.CASCADE) score = models.IntegerField() diff --git a/django_email_learning/personalised/urls.py b/django_email_learning/personalised/urls.py index 93df5f41..cca3fc22 100644 --- a/django_email_learning/personalised/urls.py +++ b/django_email_learning/personalised/urls.py @@ -5,4 +5,7 @@ urlpatterns = [ path("quiz/", QuizPublicView.as_view(), name="quiz_public_view"), + path( + "verify-enrollment/", QuizPublicView.as_view(), name="verify_enrollment" + ), # TODO: Replace with actual view ] diff --git a/django_email_learning/personalised/views.py b/django_email_learning/personalised/views.py index d1761c81..c9dbdd25 100644 --- a/django_email_learning/personalised/views.py +++ b/django_email_learning/personalised/views.py @@ -31,7 +31,7 @@ def get(self, request, *args, **kwargs) -> HttpResponse: # type: ignore[no-unty return self.errr_response( message="No quiz associated with this link", exception=None ) - if not quiz.is_published: + if not delivery.course_content.is_published: return self.errr_response( message="No valid quiz associated with this link", exception=ValueError("Quiz is not published"), diff --git a/django_email_learning/platform/api/serializers.py b/django_email_learning/platform/api/serializers.py index b6900464..345a3430 100644 --- a/django_email_learning/platform/api/serializers.py +++ b/django_email_learning/platform/api/serializers.py @@ -191,7 +191,6 @@ class LessonResponse(BaseModel): id: int title: str content: str - is_published: bool model_config = ConfigDict(from_attributes=True) @@ -260,7 +259,6 @@ class QuizResponse(BaseModel): title: str required_score: int questions: Any # Will be converted to list in field_serializer - is_published: bool @field_serializer("questions") def serialize_questions(self, questions: Any) -> list[dict]: @@ -389,6 +387,7 @@ class CourseContentResponse(BaseModel): type: str lesson: Optional[LessonResponse] = None quiz: Optional[QuizResponse] = None + is_published: bool @field_serializer("waiting_period") def serialize_waiting_period(self, waiting_period: int) -> dict: diff --git a/django_email_learning/platform/api/views.py b/django_email_learning/platform/api/views.py index 1720f069..c292b59d 100644 --- a/django_email_learning/platform/api/views.py +++ b/django_email_learning/platform/api/views.py @@ -219,14 +219,8 @@ def _update_course_content_atomic( course_content.waiting_period = serializer.waiting_period.to_seconds() if serializer.is_published is not None: - if course_content.type == "lesson" and course_content.lesson is not None: - lesson = course_content.lesson - lesson.is_published = serializer.is_published - lesson.save() - elif course_content.type == "quiz" and course_content.quiz is not None: - quiz = course_content.quiz - quiz.is_published = serializer.is_published - quiz.save() + course_content.is_published = serializer.is_published + course_content.save() if serializer.lesson is not None and course_content.lesson is not None: lesson_serializer = serializer.lesson diff --git a/django_email_learning/services/command_handler_service.py b/django_email_learning/services/command_handler_service.py new file mode 100644 index 00000000..8f4452d6 --- /dev/null +++ b/django_email_learning/services/command_handler_service.py @@ -0,0 +1,26 @@ +from django_email_learning.services.command_models.command_request import CommandRequest +from django_email_learning.services.command_models.enroll_command import EnrollCommand +from django_email_learning.services.command_models.unsubscribe_command import ( + UnsubscribeCommand, +) +from pydantic import ValidationError + + +class InvalidCommandError(Exception): + pass + + +class CommandHandlerService: + def handle_command(self, command: EnrollCommand | UnsubscribeCommand) -> None: + try: + request = CommandRequest(command=command) + request.command.execute() + except ValidationError as e: + raise InvalidCommandError("Invalid command") from e + + def handle_json_command(self, json_command: dict) -> None: + try: + request = CommandRequest.model_validate(json_command) + request.command.execute() + except ValidationError as e: + raise InvalidCommandError("Invalid command JSON") from e diff --git a/django_email_learning/services/deafults/__init__.py b/django_email_learning/services/command_models/__init__.py similarity index 100% rename from django_email_learning/services/deafults/__init__.py rename to django_email_learning/services/command_models/__init__.py diff --git a/django_email_learning/services/command_models/abstract_command.py b/django_email_learning/services/command_models/abstract_command.py new file mode 100644 index 00000000..fbdf62ad --- /dev/null +++ b/django_email_learning/services/command_models/abstract_command.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod +from pydantic import BaseModel +import logging + + +class AbstractCommand(ABC, BaseModel): + @property + def logger(self) -> logging.Logger: + return logging.getLogger(self.__class__.__name__) + + @abstractmethod + def execute(self) -> None: + raise NotImplementedError("Subclasses must implement the execute method") diff --git a/django_email_learning/services/command_models/command_request.py b/django_email_learning/services/command_models/command_request.py new file mode 100644 index 00000000..14842cc5 --- /dev/null +++ b/django_email_learning/services/command_models/command_request.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel, Field +from django_email_learning.services.command_models.enroll_command import EnrollCommand +from django_email_learning.services.command_models.unsubscribe_command import ( + UnsubscribeCommand, +) + + +class CommandRequest(BaseModel): + command: EnrollCommand | UnsubscribeCommand = Field( + ..., discriminator="command_name" + ) diff --git a/django_email_learning/services/command_models/enroll_command.py b/django_email_learning/services/command_models/enroll_command.py new file mode 100644 index 00000000..88773e13 --- /dev/null +++ b/django_email_learning/services/command_models/enroll_command.py @@ -0,0 +1,131 @@ +from django_email_learning.services.command_models.abstract_command import ( + AbstractCommand, +) +from django_email_learning.services.command_models.exceptions.invalid_course_slug_error import ( + InvalidCourseSlugError, +) +from django_email_learning.models import ( + BlockedEmail, + Learner, + Enrollment, + Course, + EnrollmentStatus, +) +from django_email_learning.services.utils import mask_email +from django_email_learning.services.email_sender_service import EmailSenderService +from django_email_learning.services import jwt_service +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.conf import settings +from django.urls import reverse +from typing import Literal + + +class EnrollCommand(AbstractCommand): + command_name: Literal["enroll"] + email: str + course_slug: str + organization_id: int + + def execute(self) -> None: + # Check if the email is blocked + if BlockedEmail.objects.filter(email=self.email).exists(): + self.logger.info( + f"Enrollment Rejected: {mask_email(self.email)} is blocked" + ) + return + + # Check if Learner with the email exists, if not create one + learner, created = Learner.objects.get_or_create(email=self.email) + if created: + self.logger.info( + f"Created new Learner for email: {mask_email(self.email)}. Learner ID: {learner.id}" + ) + + try: + course = Course.objects.get( + slug=self.course_slug, organization_id=self.organization_id + ) + except Course.DoesNotExist: + self.logger.error( + f"Enrollment Failed: Invalid course slug '{self.course_slug}' for organization ID {self.organization_id}" + ) + raise InvalidCourseSlugError( + f"Course with slug '{self.course_slug}' does not exist for organization ID {self.organization_id}" + ) + + # Check if an enrollment already exists + if ( + Enrollment.objects.filter(learner=learner, course=course) + .exclude(status=EnrollmentStatus.DEACTIVATED) + .exists() + ): + self.logger.info( + f"Enrollment Skipped: Learner ID {learner.id} is already enrolled in course '{self.course_slug}'" + ) + return + + # Create the enrollment + enrollment = Enrollment.objects.create( + learner=learner, course=course, status=EnrollmentStatus.UNVERIFIED + ) + + self.logger.info( + f"Enrollment Successful: Learner ID {learner.id} enrolled in course '{self.course_slug}'. Enrollment ID: {enrollment.id}" + ) + + # Send verification email + + token = jwt_service.generate_jwt( + { + "verification_code": enrollment.activation_code, + "enrollment_id": enrollment.id, + } + ) + + verification_relative_path = ( + reverse("django_email_learning:personalised:verify_enrollment") + + f"?token={token}" + ) + verification_link = ( + settings.DJANGO_EMAIL_LEARNING["SITE_BASE_URL"] + verification_relative_path + ) + + template_context = { + "course_title": course.title, + "verification_link": verification_link, + "verification_code": enrollment.activation_code, + "organization_name": course.organization.name, + "support_imap_interface": course.imap_connection is not None, + "imap_email_address": course.imap_connection.email + if course.imap_connection + else None, + } + email_service = EmailSenderService() + subject = "Verify your enrollment" + body = render_to_string( + "emails/enrolment_verification.txt", + template_context, + ) + + to_emails = [self.email] + + html_content = render_to_string( + "emails/enrolment_verification.html", + template_context, + ) + + # TODO: Add AMP content/type to activate directly in email clients that support it + + email = EmailMultiAlternatives( + subject=subject, + body=body, + from_email=email_service.from_email, + to=to_emails, + ) + email.attach_alternative(html_content, "text/html") + email_service.send(email) + + self.logger.info( + f"Verification email sent to {mask_email(self.email)} for Enrollment ID: {enrollment.id}" + ) diff --git a/django_email_learning/services/command_models/exceptions/__init__.py b/django_email_learning/services/command_models/exceptions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django_email_learning/services/command_models/exceptions/invalid_course_slug_error.py b/django_email_learning/services/command_models/exceptions/invalid_course_slug_error.py new file mode 100644 index 00000000..53d9f5bc --- /dev/null +++ b/django_email_learning/services/command_models/exceptions/invalid_course_slug_error.py @@ -0,0 +1,4 @@ +class InvalidCourseSlugError(Exception): + """Exception raised when a course slug is invalid.""" + + pass diff --git a/django_email_learning/services/command_models/exceptions/invalid_enrollment_error.py b/django_email_learning/services/command_models/exceptions/invalid_enrollment_error.py new file mode 100644 index 00000000..29770c37 --- /dev/null +++ b/django_email_learning/services/command_models/exceptions/invalid_enrollment_error.py @@ -0,0 +1,4 @@ +class InvalidEnrollmentError(Exception): + """Exception raised when an enrollment is invalid.""" + + pass diff --git a/django_email_learning/services/command_models/exceptions/invalid_verification_code_error.py b/django_email_learning/services/command_models/exceptions/invalid_verification_code_error.py new file mode 100644 index 00000000..0073802b --- /dev/null +++ b/django_email_learning/services/command_models/exceptions/invalid_verification_code_error.py @@ -0,0 +1,4 @@ +class InvalidVerificationCodeError(Exception): + """Exception raised when a verification code is invalid.""" + + pass diff --git a/django_email_learning/services/command_models/unsubscribe_command.py b/django_email_learning/services/command_models/unsubscribe_command.py new file mode 100644 index 00000000..c522a9ac --- /dev/null +++ b/django_email_learning/services/command_models/unsubscribe_command.py @@ -0,0 +1,56 @@ +from typing import Literal +from django_email_learning.models import ( + Course, + Enrollment, + Learner, + EnrollmentStatus, + DeactivationReason, +) +from django_email_learning.services.command_models.abstract_command import ( + AbstractCommand, +) +from django_email_learning.services.command_models.exceptions.invalid_course_slug_error import ( + InvalidCourseSlugError, +) + + +class UnsubscribeCommand(AbstractCommand): + command_name: Literal["unsubscribe"] + email: str + course_slug: str + organization_id: int + + def execute(self) -> None: + try: + course = Course.objects.get( + slug=self.course_slug, organization_id=self.organization_id + ) + except Course.DoesNotExist: + self.logger.error( + f"Unsubscribe Failed: Invalid course slug '{self.course_slug}' for organization ID {self.organization_id}" + ) + raise InvalidCourseSlugError( + f"Course with slug '{self.course_slug}' does not exist for organization ID {self.organization_id}" + ) + + try: + learner = Learner.objects.get(email=self.email) + except Learner.DoesNotExist: + self.logger.warning( + f"Unsubscribe Skipped: No learner found with email {self.email}" + ) + return + + enrollments = Enrollment.objects.filter(learner=learner, course=course).exclude( + status=EnrollmentStatus.DEACTIVATED + ) + if not enrollments.exists(): + self.logger.warning( + f"Unsubscribe Skipped: No active enrollment found for learner {learner.id} in course {course.slug}" + ) + return + + enrollments.update( + status=EnrollmentStatus.DEACTIVATED, + deactivation_reason=DeactivationReason.CANCELED, + ) diff --git a/django_email_learning/services/command_models/verify_enrollment_command.py b/django_email_learning/services/command_models/verify_enrollment_command.py new file mode 100644 index 00000000..13fd9770 --- /dev/null +++ b/django_email_learning/services/command_models/verify_enrollment_command.py @@ -0,0 +1,82 @@ +from django_email_learning.services.command_models.abstract_command import ( + AbstractCommand, +) +from django_email_learning.models import Enrollment, EnrollmentStatus +from pydantic import Field + +from django_email_learning.services.command_models.exceptions.invalid_enrollment_error import ( + InvalidEnrollmentError, +) +from django_email_learning.services.command_models.exceptions.invalid_verification_code_error import ( + InvalidVerificationCodeError, +) +from django_email_learning.services.email_sender_service import EmailSenderService +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string + + +class VerifyEnrollmentCommand(AbstractCommand): + command_name: str = "verify_enrollment" + enrollment_id: int = Field(..., gt=0) + verification_code: int = Field(..., ge=100000, le=999999) + + def execute(self) -> None: + try: + enrollment = Enrollment.objects.get( + id=self.enrollment_id, status=EnrollmentStatus.UNVERIFIED + ) + except Enrollment.DoesNotExist: + self.logger.error( + f"Verification Failed: No unverified enrollment found with ID {self.enrollment_id}" + ) + raise InvalidEnrollmentError( + f"No unverified enrollment found with ID {self.enrollment_id}" + ) + + if str(enrollment.activation_code) != str(self.verification_code): + self.logger.error( + f"Verification Failed: Invalid verification code for Enrollment ID {self.enrollment_id}" + ) + raise InvalidVerificationCodeError( + f"Invalid verification code for Enrollment ID {self.enrollment_id}" + ) + + enrollment.status = EnrollmentStatus.ACTIVE + enrollment.activation_code = None + enrollment.save() + self.logger.info( + f"Enrollment Verified: Enrollment ID {self.enrollment_id} has been activated" + ) + + enrollment.schedule_first_content_delivery() + self.logger.info( + f"Content Delivery Scheduled: First content delivery scheduled for Enrollment ID {self.enrollment_id}" + ) + + # Send confirmation email + email_service = EmailSenderService() + subject = "Enrollment Verified" + body = render_to_string( + "emails/enrollment_verified.txt", + { + "course_title": enrollment.course.title, + "organization_name": enrollment.course.organization.name, + }, + ) + + email = EmailMultiAlternatives( + subject=subject, + body=body, + from_email=email_service.from_email, + to=[enrollment.learner.email], + ) + html_content = render_to_string( + "emails/enrollment_verified.html", + { + "course_title": enrollment.course.title, + "organization_name": enrollment.course.organization.name, + }, + ) + email.attach_alternative(html_content, "text/html") + + email_service.send(email=email) diff --git a/django_email_learning/services/defaults/__init__.py b/django_email_learning/services/defaults/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django_email_learning/services/deafults/email_sender.py b/django_email_learning/services/defaults/email_sender.py similarity index 68% rename from django_email_learning/services/deafults/email_sender.py rename to django_email_learning/services/defaults/email_sender.py index 5ff215cf..93caaa4a 100644 --- a/django_email_learning/services/deafults/email_sender.py +++ b/django_email_learning/services/defaults/email_sender.py @@ -1,25 +1,17 @@ import logging from django_email_learning.ports.email_sender_protocol import EmailSenderProtocol +from django_email_learning.services.utils import mask_email from django.core.mail import EmailMultiAlternatives logger = logging.getLogger(__name__) class DjangoEmailSender(EmailSenderProtocol): - def _mask_email(self, email_address: str) -> str: - """Mask email address for logging privacy.""" - try: - username, domain = email_address.split("@") - masked_username = username[0] + "***" - return f"{masked_username}@{domain}" - except ValueError: - return "***@***" - def _mask_recipients(self, recipients: list[str]) -> str: """Mask all recipient email addresses for logging.""" if not recipients: return "no recipients" - masked = [self._mask_email(recipient) for recipient in recipients] + masked = [mask_email(recipient) for recipient in recipients] return ", ".join(masked) def send_email(self, email: EmailMultiAlternatives) -> None: diff --git a/django_email_learning/services/email_sender_service.py b/django_email_learning/services/email_sender_service.py new file mode 100644 index 00000000..04097d5c --- /dev/null +++ b/django_email_learning/services/email_sender_service.py @@ -0,0 +1,32 @@ +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.utils.module_loading import import_string + + +class EmailSenderService: + def __init__(self) -> None: + try: + self.email_sender = import_string( + settings.DJANGO_EMAIL_LEARNING["EMAIL_SENDER"] + ) + except (AttributeError, KeyError): + from django_email_learning.services.defaults.email_sender import ( + DjangoEmailSender, + ) + + self.email_sender = DjangoEmailSender() + + try: + self.from_email = settings.DJANGO_EMAIL_LEARNING["FROM_EMAIL"] + except (AttributeError, KeyError): + try: + self.from_email = settings.DEFAULT_FROM_EMAIL + except AttributeError: + self.from_email = "" + if not self.from_email: + raise ValueError( + "Either set DJANGO_EMAIL_LEARNING['FROM_EMAIL'] or DEFAULT_FROM_EMAIL." + ) + + def send(self, email: EmailMultiAlternatives) -> None: + self.email_sender.send_email(email) diff --git a/django_email_learning/services/utils.py b/django_email_learning/services/utils.py new file mode 100644 index 00000000..4e8534dd --- /dev/null +++ b/django_email_learning/services/utils.py @@ -0,0 +1,8 @@ +def mask_email(email_address: str) -> str: + """Mask email address for logging privacy.""" + try: + username, domain = email_address.split("@") + masked_username = username[0] + "***" + return f"{masked_username}@{domain}" + except ValueError: + return "***@***" diff --git a/django_email_learning/templates/emails/base.html b/django_email_learning/templates/emails/base.html new file mode 100644 index 00000000..6125c0ac --- /dev/null +++ b/django_email_learning/templates/emails/base.html @@ -0,0 +1,64 @@ +{% with brand_color="#7c86ff" %} + + + + + + +
+
+ {% block content %}{% endblock %} +
+
+ + +{% endwith %} diff --git a/django_email_learning/templates/emails/enrollment_verified.html b/django_email_learning/templates/emails/enrollment_verified.html new file mode 100644 index 00000000..60116882 --- /dev/null +++ b/django_email_learning/templates/emails/enrollment_verified.html @@ -0,0 +1,22 @@ +{% extends "emails/base.html" %} + +{% block content %} +

Hello there,

+ +

Congratulations! Your enrollment for "{{ course_title }}" has been successfully verified and confirmed.

+ +

We're excited to have you join our learning community. Here's what you can expect:

+ + + +

Your learning adventure begins soon! Keep an eye on your inbox for the first lesson and welcome materials.

+ +

Welcome aboard!

+ +

Best regards,
+The {{ organization_name }} Team

+{% endblock %} diff --git a/django_email_learning/templates/emails/enrollment_verified.txt b/django_email_learning/templates/emails/enrollment_verified.txt new file mode 100644 index 00000000..9edcbb51 --- /dev/null +++ b/django_email_learning/templates/emails/enrollment_verified.txt @@ -0,0 +1,16 @@ +Hello there, + +Congratulations! Your enrollment for "{{ course_title }}" has been successfully verified and confirmed. + +We're excited to have you join our learning community. Here's what you can expect: + +• Course materials will be delivered directly to this email address +• You'll receive structured lessons and assignments on a regular schedule +• Interactive content and assessments will help track your progress + +Your learning adventure begins soon! Keep an eye on your inbox for the first lesson and welcome materials. + +Welcome aboard! + +Best regards, +The {{ organization_name }} Team diff --git a/django_email_learning/templates/emails/enrolment_verification.html b/django_email_learning/templates/emails/enrolment_verification.html new file mode 100644 index 00000000..578719a9 --- /dev/null +++ b/django_email_learning/templates/emails/enrolment_verification.html @@ -0,0 +1,48 @@ +{% extends "emails/base.html" %} + +{% block content %} +

Welcome!

+ +

Hello there,

+ +

+ Congratulations on enrolling in {{ course_title }}! + We're excited to have you join our learning community. +

+ +

+ To get started, please verify your email address by clicking the button below: +

+ +
+ + Verify Your Email Address + +
+ + {% if support_imap_interface %} +
+

Alternative Verification Method

+

+ You can also verify your email by sending a message to + {{ imap_email_address }} + with your verification code in the subject line or email body. + Simply forward this email to complete the verification process. +

+

+ Verification Code: + {{ verification_code }} +

+
+ {% endif %} + +
+

If you didn't sign up for this course, you can safely ignore this email.

+
+ +
+

Best regards,

+

{{ organization_name }}

+
+{% endblock %} diff --git a/django_email_learning/templates/emails/enrolment_verification.txt b/django_email_learning/templates/emails/enrolment_verification.txt new file mode 100644 index 00000000..13be1435 --- /dev/null +++ b/django_email_learning/templates/emails/enrolment_verification.txt @@ -0,0 +1,16 @@ +Hello there, + +Congratulations on enrolling in {{ course_title }}! +We're excited to have you join our learning community. + +To get started, please verify your email address by visiting the link below: +{{ verification_link }} + +{% if support_imap_interface %} +Alternatively, you can also verify your email by sending the verification code {{ verification_code }} via an email to {{ imap_email_address }}. +{% endif %} + +If you didn't sign up for this course, you can safely ignore this email. + +Best Regards, +{{ organization_name }} diff --git a/django_service/settings.py b/django_service/settings.py index 685ea22e..0129f909 100644 --- a/django_service/settings.py +++ b/django_service/settings.py @@ -11,6 +11,8 @@ """ from pathlib import Path +import os + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -33,6 +35,7 @@ INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", + "django.contrib.sites", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", @@ -65,7 +68,6 @@ CORS_ALLOW_CREDENTIALS = True CSRF_COOKIE_SECURE = False -# CSRF_COOKIE_SAMESITE = "None" ROOT_URLCONF = "django_service.urls" @@ -99,9 +101,21 @@ } DJANGO_EMAIL_LEARNING = { + "SITE_BASE_URL": "http://localhost:8000", "ENCRYPTION_SECRET_KEY": "your-very-secure-and-random-key", + "FROM_EMAIL": os.environ.get("FROM_EMAIL", "webmaster@localhost"), } + +# EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +EMAIL_HOST = os.environ.get("EMAIL_HOST") +EMAIL_PORT = int(os.environ.get("EMAIL_PORT", 587)) +EMAIL_USE_TLS = True +EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER") +EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD") + LOGGING = { "version": 1, "disable_existing_loggers": False, diff --git a/tests/conftest.py b/tests/conftest.py index b9205ac9..3d3f22b4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -130,7 +130,7 @@ def quiz(db) -> Quiz: @pytest.fixture() def lesson(db) -> Lesson: - lesson = Lesson(title="Sample Lesson", content="Lesson Content", is_published=True) + lesson = Lesson(title="Sample Lesson", content="Lesson Content") lesson.save() return lesson @@ -170,7 +170,12 @@ def enrollment(db, learner, course) -> Enrollment: @pytest.fixture def course_lesson_content(db, course, lesson) -> CourseContent: content = CourseContent.objects.create( - course=course, priority=1, type="lesson", lesson=lesson, waiting_period=3600 + course=course, + priority=1, + type="lesson", + lesson=lesson, + waiting_period=3600, + is_published=True, ) return content @@ -198,7 +203,6 @@ def quiz_with_questions(db, quiz) -> Quiz: is_correct=(i == 0), # First answer is correct ) questions.append(question) - quiz.is_published = True quiz.save() return quiz @@ -214,6 +218,7 @@ def active_enrollment(db, learner, course): @pytest.fixture def content_delivery(db, active_enrollment, course_quiz_content, quiz_with_questions): course_quiz_content.quiz = quiz_with_questions + course_quiz_content.is_published = True course_quiz_content.save() delivery = ContentDelivery.objects.create( @@ -221,5 +226,7 @@ def content_delivery(db, active_enrollment, course_quiz_content, quiz_with_quest course_content_id=course_quiz_content.id, hash_value="testhash", ) - delivery.delivery_schedules.add(DeliverySchedule.objects.create(is_delivered=True)) + delivery.delivery_schedules.add( + DeliverySchedule.objects.create(is_delivered=True, delivery=delivery) + ) return delivery diff --git a/tests/platform/api/test_views/test_course_content_view.py b/tests/platform/api/test_views/test_course_content_view.py index 150d65d8..012afa29 100644 --- a/tests/platform/api/test_views/test_course_content_view.py +++ b/tests/platform/api/test_views/test_course_content_view.py @@ -72,7 +72,7 @@ def test_create_course_lesson_content(superadmin_client, create_course): assert data["lesson"]["id"] is not None assert data["lesson"]["title"] == LESSON_TITLE assert data["lesson"]["content"] == LESSON_CONTENT - assert data["lesson"]["is_published"] is False + assert data["is_published"] is False assert data["type"] == "lesson" assert data["priority"] == 1 assert data["waiting_period"] == {"period": 2, "type": "days"} @@ -195,7 +195,7 @@ def test_create_quiz_content(superadmin_client, create_course): assert data["id"] is not None assert data["quiz"]["id"] is not None assert data["type"] == "quiz" - assert data["quiz"]["is_published"] is False + assert data["is_published"] is False assert data["priority"] == 2 assert data["waiting_period"] == {"period": 1, "type": "hours"} assert data["quiz"]["title"] == "Quiz 1" @@ -614,7 +614,7 @@ def test_update_content_is_published(superadmin_client, course_lesson_content): assert response.status_code == 200 data = response.json() assert data["id"] == course_lesson_content.id - assert data["lesson"]["is_published"] is True + assert data["is_published"] is True payload = {"is_published": False} response = superadmin_client.post( @@ -623,4 +623,4 @@ def test_update_content_is_published(superadmin_client, course_lesson_content): assert response.status_code == 200 data = response.json() assert data["id"] == course_lesson_content.id - assert data["lesson"]["is_published"] is False + assert data["is_published"] is False diff --git a/tests/services/command_models/test_enroll_command.py b/tests/services/command_models/test_enroll_command.py new file mode 100644 index 00000000..ee3d0d27 --- /dev/null +++ b/tests/services/command_models/test_enroll_command.py @@ -0,0 +1,88 @@ +from django_email_learning.services.command_models.enroll_command import ( + EnrollCommand, + InvalidCourseSlugError, +) +from django_email_learning.models import Enrollment, Learner, EnrollmentStatus +from django.core import mail +import pytest + + +def test_enroll_command(db, course): + command = EnrollCommand( + command_name="enroll", + email="test@example.com", + course_slug=course.slug, + organization_id=course.organization.id, + ) + command.execute() + + # check learner and enrollment created + learner = Learner.objects.get(email="test@example.com") + enrollment = Enrollment.objects.get( + learner=learner, course=course, status=EnrollmentStatus.UNVERIFIED + ) + + # check verification email sent + assert len(mail.outbox) == 1 + sent_email = mail.outbox[0] + assert sent_email.to == ["test@example.com"] + assert sent_email.from_email # check from_email is set + assert "Verify your enrollment" in sent_email.subject + assert enrollment.activation_code in sent_email.body + + +def test_enroll_command_for_blocked_email(db, blocked_email, course): + command = EnrollCommand( + command_name="enroll", + email=blocked_email.email, + course_slug=course.slug, + organization_id=course.organization.id, + ) + command.execute() + + # check no learner or enrollment created + assert not Learner.objects.filter(email=blocked_email.email).exists() + assert not Enrollment.objects.filter(course=course).exists() + + # check no email sent + assert len(mail.outbox) == 0 + + +def test_existing_enrollment_skipped(db, learner, course): + # Create an existing enrollment + Enrollment.objects.create( + learner=learner, course=course, status=EnrollmentStatus.UNVERIFIED + ) + + command = EnrollCommand( + command_name="enroll", + email=learner.email, + course_slug=course.slug, + organization_id=course.organization.id, + ) + command.execute() + + # Check that no new enrollment is created + enrollments = Enrollment.objects.filter(learner=learner, course=course) + assert enrollments.count() == 1 # Still only one enrollment + + # Check that no email is sent + assert len(mail.outbox) == 0 + + +def test_enroll_command_nonexistent_course(db, learner): + command = EnrollCommand( + command_name="enroll", + email=learner.email, + course_slug="nonexistent-course", + organization_id=1, + ) + + with pytest.raises(InvalidCourseSlugError): + command.execute() + + # Check that no enrollment is created + assert not Enrollment.objects.filter(learner=learner).exists() + + # Check that no email is sent + assert len(mail.outbox) == 0 diff --git a/tests/services/command_models/test_unsubscribe_command.py b/tests/services/command_models/test_unsubscribe_command.py new file mode 100644 index 00000000..ebf1a41d --- /dev/null +++ b/tests/services/command_models/test_unsubscribe_command.py @@ -0,0 +1,47 @@ +from django_email_learning.services.command_models.unsubscribe_command import ( + UnsubscribeCommand, +) +from django_email_learning.services.command_models.exceptions.invalid_course_slug_error import ( + InvalidCourseSlugError, +) +from django_email_learning.models import EnrollmentStatus +import pytest + + +def test_unsubscribe_command(db, enrollment): + command = UnsubscribeCommand( + command_name="unsubscribe", + email=enrollment.learner.email, + course_slug=enrollment.course.slug, + organization_id=enrollment.course.organization.id, + ) + command.execute() + + # check enrollment deactivated + enrollment.refresh_from_db() + assert enrollment.status == EnrollmentStatus.DEACTIVATED + assert enrollment.deactivation_reason == "canceled" + + +def test_unsubscribe_nonexistent_course(db, learner): + command = UnsubscribeCommand( + command_name="unsubscribe", + email=learner.email, + course_slug="nonexistent-course", + organization_id=1, + ) + with pytest.raises(InvalidCourseSlugError): + command.execute() + + +def test_unsubscribe_nonexistent_learner(db, course, caplog): + command = UnsubscribeCommand( + command_name="unsubscribe", + email="nonexistent@example.com", + course_slug=course.slug, + organization_id=course.organization.id, + ) + command.execute() + # No error should be raised, but logger should note no learner found + caplog_text = caplog.text + assert "No learner found with email" in caplog_text diff --git a/tests/services/command_models/test_verify_enrollment_command.py b/tests/services/command_models/test_verify_enrollment_command.py new file mode 100644 index 00000000..4fba8635 --- /dev/null +++ b/tests/services/command_models/test_verify_enrollment_command.py @@ -0,0 +1,91 @@ +from django_email_learning.services.command_models.verify_enrollment_command import ( + VerifyEnrollmentCommand, +) +from django_email_learning.models import ( + EnrollmentStatus, + ContentDelivery, + DeliverySchedule, +) +from django_email_learning.services.command_models.exceptions.invalid_enrollment_error import ( + InvalidEnrollmentError, +) +from django_email_learning.services.command_models.exceptions.invalid_verification_code_error import ( + InvalidVerificationCodeError, +) +from pydantic import ValidationError +from django.core import mail +import pytest + + +def test_verify_enrollment_command_initialization(): + command = VerifyEnrollmentCommand( + enrollment_id=123, + verification_code=321456, + ) + + assert command.enrollment_id == 123 + assert command.verification_code == 321456 + + +@pytest.mark.parametrize( + "enrollment_id,verification_code", + [ + (0, 123456), # Invalid enrollment_id (too low) + (-1, 123456), # Invalid enrollment_id (negative) + (1, 99999), # Invalid verification_code (too low) + (1, 1000000), # Invalid verification_code (too high) + (1, "abcdef"), # Invalid verification_code (not an integer) + ], +) +def test_verify_enrollment_command_invalid_fields(enrollment_id, verification_code): + with pytest.raises(ValidationError): + VerifyEnrollmentCommand( + enrollment_id=enrollment_id, + verification_code=verification_code, + ) + + +def test_verify_enrollment_command_execute(db, enrollment, course_lesson_content): + command = VerifyEnrollmentCommand( + enrollment_id=enrollment.id, + verification_code=enrollment.activation_code, + ) + + assert enrollment.status == EnrollmentStatus.UNVERIFIED + + command.execute() + + enrollment.refresh_from_db() + assert enrollment.status == EnrollmentStatus.ACTIVE + assert enrollment.activation_code is None + + delivery = ContentDelivery.objects.get( + enrollment=enrollment, course_content=course_lesson_content + ) + assert DeliverySchedule.objects.filter(delivery=delivery).exists() + + # Check that a confirmation email was sent + assert len(mail.outbox) == 1 + sent_email = mail.outbox[0] + assert sent_email.to == [enrollment.learner.email] + assert "Enrollment Verified" in sent_email.subject + + +def test_verify_enrollment_command_invalid_enrollment(db): + command = VerifyEnrollmentCommand( + enrollment_id=9999, # Non-existent enrollment ID + verification_code=123456, + ) + + with pytest.raises(InvalidEnrollmentError): + command.execute() + + +def test_verify_enrollment_command_invalid_verification_code(db, enrollment): + command = VerifyEnrollmentCommand( + enrollment_id=enrollment.id, + verification_code=999999, # Incorrect code + ) + + with pytest.raises(InvalidVerificationCodeError): + command.execute() diff --git a/tests/services/defaults/test_email_sender.py b/tests/services/defaults/test_email_sender.py index 47ab5368..6200a214 100644 --- a/tests/services/defaults/test_email_sender.py +++ b/tests/services/defaults/test_email_sender.py @@ -1,4 +1,4 @@ -from django_email_learning.services.deafults.email_sender import DjangoEmailSender +from django_email_learning.services.defaults.email_sender import DjangoEmailSender from django.core.mail import EmailMultiAlternatives from unittest.mock import Mock import pytest diff --git a/tests/test_models/test_enrollment.py b/tests/test_models/test_enrollment.py index d790161b..0d5bdc28 100644 --- a/tests/test_models/test_enrollment.py +++ b/tests/test_models/test_enrollment.py @@ -1,5 +1,7 @@ from django_email_learning.models import Enrollment from django.core.exceptions import ValidationError +from freezegun import freeze_time +from unittest.mock import patch import pytest @@ -10,6 +12,8 @@ def test_enrollment_minimal_save(learner, course): assert fetched_enrollment.course == course assert fetched_enrollment.enrolled_at is not None assert fetched_enrollment.status == "unverified" + assert fetched_enrollment.activation_code is not None + assert len(fetched_enrollment.activation_code) == 6 @pytest.mark.parametrize( @@ -142,3 +146,42 @@ def test_deactivation_reason_null_when_not_deactivated(db, learner, course): assert "Deactivation reason must be null unless status is 'deactivated'." in str( exc_info.value ) + + +def test_schedule_first_content_delivery_creates_delivery_and_schedule( + db, enrollment, course_lesson_content +): + course_lesson_content.waiting_period = 3600 # 1 hour + course_lesson_content.save() + + with freeze_time("2024-01-01 10:00:00"): + enrollment.schedule_first_content_delivery() + + deliveries = enrollment.contentdelivery_set.all() + assert deliveries.count() == 1 + delivery = deliveries.first() + assert delivery.course_content == course_lesson_content + + schedules = delivery.delivery_schedules.all() + assert schedules.count() == 1 + schedule = schedules.first() + assert schedule.time.isoformat() == "2024-01-01T11:00:00+00:00" # 1 hour later + + +@patch("django_email_learning.models.DeliverySchedule.objects.create") +def test_schedule_first_content_delivery_atomic_transaction( + mock_create_schedule, db, enrollment, course_lesson_content +): + mock_create_schedule.side_effect = Exception("Failed to create schedule") + course_lesson_content.waiting_period = 3600 # 1 hour + course_lesson_content.save() + + with freeze_time("2024-01-01 10:00:00"): + # Simulate failure to create schedule by not adding any schedule after delivery creation + with pytest.raises(Exception): + enrollment.schedule_first_content_delivery() + + deliveries = enrollment.contentdelivery_set.all() + assert ( + deliveries.count() == 0 + ) # Delivery should be deleted if no schedule created diff --git a/tests/test_models/test_quiz_submission.py b/tests/test_models/test_quiz_submission.py index ec8b16de..9873db39 100644 --- a/tests/test_models/test_quiz_submission.py +++ b/tests/test_models/test_quiz_submission.py @@ -12,7 +12,8 @@ def test_quiz_submission_creation(db, course_quiz_content, enrollment): enrollment=enrollment, course_content=course_quiz_content, ) - delivery.delivery_schedules.add(DeliverySchedule.objects.create(is_delivered=True)) + + DeliverySchedule.objects.create(is_delivered=True, delivery=delivery) submission = QuizSubmission.objects.create( delivery=delivery, score=85, @@ -29,7 +30,9 @@ def test_quiz_submission_for_lesson_content(db, course_lesson_content, enrollmen enrollment=enrollment, course_content=course_lesson_content, ) - delivery.delivery_schedules.add(DeliverySchedule.objects.create(is_delivered=True)) + delivery.delivery_schedules.add( + DeliverySchedule.objects.create(is_delivered=True, delivery=delivery) + ) with pytest.raises(Exception) as exc_info: QuizSubmission.objects.create( delivery=delivery, @@ -53,7 +56,9 @@ def test_invalid_quiz_submission_fields( enrollment=enrollment, course_content=course_quiz_content, ) - delivery.delivery_schedules.add(DeliverySchedule.objects.create(is_delivered=True)) + delivery.delivery_schedules.add( + DeliverySchedule.objects.create(is_delivered=True, delivery=delivery) + ) with pytest.raises(ValidationError): QuizSubmission.objects.create(