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" %} + +
+ + + + +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.
+ +Best regards,
+The {{ organization_name }} Team
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: +
+ + + + {% if support_imap_interface %} ++ 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 }} +
+If you didn't sign up for this course, you can safely ignore this email.
+Best regards,
+{{ organization_name }}
+