@@ -859,6 +859,149 @@ def __str__(self):
859859 return f"{ self .user .username } - { self .score } points"
860860
861861
862+ class DailyChallenge (models .Model ):
863+ """
864+ Represents a daily challenge type that can be assigned to users.
865+ Each challenge type has specific criteria for completion.
866+ """
867+
868+ CHALLENGE_TYPE_CHOICES = [
869+ ("early_checkin" , "Early Check-in" ),
870+ ("positive_mood" , "Positive Mood" ),
871+ ("complete_all_fields" , "Complete All Fields" ),
872+ ("streak_milestone" , "Streak Milestone" ),
873+ ("no_blockers" , "No Blockers" ),
874+ ]
875+
876+ challenge_type = models .CharField (
877+ max_length = 50 ,
878+ choices = CHALLENGE_TYPE_CHOICES ,
879+ unique = True ,
880+ help_text = "Type of daily challenge" ,
881+ )
882+ title = models .CharField (
883+ max_length = 255 ,
884+ help_text = "Display title for the challenge" ,
885+ )
886+ description = models .TextField (
887+ help_text = "Description of what the challenge requires" ,
888+ )
889+ points_reward = models .IntegerField (
890+ default = 10 ,
891+ help_text = "Points awarded for completing this challenge" ,
892+ validators = [MinValueValidator (0 )],
893+ )
894+ is_active = models .BooleanField (
895+ default = True ,
896+ help_text = "Whether this challenge type is currently active" ,
897+ )
898+ created_at = models .DateTimeField (auto_now_add = True )
899+ updated_at = models .DateTimeField (auto_now = True )
900+
901+ class Meta :
902+ ordering = ["title" ]
903+ verbose_name = "Daily Challenge"
904+ verbose_name_plural = "Daily Challenges"
905+
906+ def __str__ (self ):
907+ return self .title
908+
909+
910+ class UserDailyChallenge (models .Model ):
911+ """
912+ Tracks a user's assigned daily challenge and its completion status.
913+ """
914+
915+ STATUS_CHOICES = [
916+ ("assigned" , "Assigned" ),
917+ ("completed" , "Completed" ),
918+ ("expired" , "Expired" ),
919+ ]
920+
921+ user = models .ForeignKey (
922+ User ,
923+ on_delete = models .CASCADE ,
924+ related_name = "daily_challenges" ,
925+ )
926+ challenge = models .ForeignKey (
927+ DailyChallenge ,
928+ on_delete = models .CASCADE ,
929+ related_name = "user_assignments" ,
930+ )
931+ challenge_date = models .DateField (
932+ help_text = "Date for which this challenge is assigned" ,
933+ db_index = True ,
934+ )
935+ status = models .CharField (
936+ max_length = 20 ,
937+ choices = STATUS_CHOICES ,
938+ default = "assigned" ,
939+ )
940+ points_awarded = models .IntegerField (
941+ default = 0 ,
942+ help_text = "Points actually awarded (may differ from challenge.points_reward)" ,
943+ )
944+ completed_at = models .DateTimeField (
945+ null = True ,
946+ blank = True ,
947+ help_text = "When the challenge was completed" ,
948+ )
949+ next_challenge_at = models .DateTimeField (
950+ null = True ,
951+ blank = True ,
952+ help_text = "When the next challenge will be available (24 hours from last check-in submission)" ,
953+ db_index = True ,
954+ )
955+ created_at = models .DateTimeField (auto_now_add = True )
956+ updated_at = models .DateTimeField (auto_now = True )
957+
958+ class Meta :
959+ unique_together = [["user" , "challenge_date" ]]
960+ ordering = ["-challenge_date" , "status" ]
961+ indexes = [
962+ models .Index (fields = ["user" , "challenge_date" ]),
963+ models .Index (fields = ["status" , "challenge_date" ]),
964+ ]
965+
966+ def mark_completed (self ):
967+ """
968+ Mark challenge as completed and award points.
969+ Uses transaction to ensure atomicity.
970+ """
971+ if self .status == "completed" :
972+ return False
973+
974+ try :
975+ with transaction .atomic ():
976+ self .refresh_from_db ()
977+ if self .status == "completed" :
978+ return False
979+
980+ self .status = "completed"
981+ self .completed_at = timezone .now ()
982+ self .points_awarded = self .challenge .points_reward
983+ self .save ()
984+
985+ Points .objects .create (
986+ user = self .user ,
987+ score = self .challenge .points_reward ,
988+ reason = f"Completed daily challenge: { self .challenge .title } " ,
989+ )
990+
991+ return True
992+ except Exception as e :
993+ logger .error (
994+ "Error completing challenge %s for user %s: %s" ,
995+ self .id ,
996+ self .user .username ,
997+ e ,
998+ )
999+ return False
1000+
1001+ def __str__ (self ):
1002+ return f"{ self .user .username } - { self .challenge .title } ({ self .challenge_date } )"
1003+
1004+
8621005class InviteFriend (models .Model ):
8631006 sender = models .ForeignKey (User , related_name = "sent_invites" , on_delete = models .CASCADE )
8641007 recipients = models .ManyToManyField (User , related_name = "received_invites" , blank = True )
@@ -968,6 +1111,11 @@ def check_verifier_permission(self):
9681111 current_streak = models .IntegerField (default = 0 )
9691112 longest_streak = models .IntegerField (default = 0 )
9701113 last_check_in = models .DateField (null = True , blank = True )
1114+ timezone = models .CharField (
1115+ max_length = 50 ,
1116+ default = "UTC" ,
1117+ help_text = "User's timezone (e.g., 'Asia/Kolkata', 'America/New_York'). Defaults to UTC." ,
1118+ )
9711119
9721120 def avatar (self , size = 36 ):
9731121 if self .user_avatar :
@@ -1562,6 +1710,10 @@ class DailyStatusReport(models.Model):
15621710 current_mood = models .CharField (max_length = 50 , default = "Happy 😊" )
15631711 created = models .DateTimeField (auto_now_add = True )
15641712
1713+ # unique_together removed for testing - allows multiple check-ins per day
1714+ # class Meta:
1715+ # unique_together = [["user", "date"]] # One check-in per user per day
1716+
15651717 def __str__ (self ):
15661718 return f"Daily Status Report by { self .user .username } on { self .date } "
15671719
0 commit comments