11import base64
22import ipaddress
33import re
4+ import random
45import uuid
56from enum import StrEnum
67from typing import Any
78from django .conf import settings
8- from django .db import models
9+ from django .db import models , transaction
910from django .core .validators import MaxValueValidator
1011from django .core .exceptions import ImproperlyConfigured
1112from cryptography .fernet import Fernet
1415from django .forms import ValidationError
1516from django .contrib .auth .models import User
1617from django .utils import timezone
18+ from datetime import timedelta
1719
1820
1921FIXED_SALT = b"\x00 " * 16
@@ -132,7 +134,6 @@ def delete(
132134class 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:
141142class 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
181163class 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+
321303class 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
403397class 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+
434438class QuizSubmission (models .Model ):
435439 delivery = models .ForeignKey (ContentDelivery , on_delete = models .CASCADE )
436440 score = models .IntegerField ()
0 commit comments