Skip to content

Commit ae2b831

Browse files
committed
Add daily challenges feature
Closes #2572
1 parent 9c1bb99 commit ae2b831

File tree

6 files changed

+1336
-129
lines changed

6 files changed

+1336
-129
lines changed
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Generated by Django 5.2.9 on 2025-12-09 09:17
2+
# Combined migration: Creates DailyChallenge and UserDailyChallenge models, adds next_challenge_at field
3+
4+
import django.core.validators
5+
import django.db.models.deletion
6+
from django.conf import settings
7+
from django.db import migrations, models
8+
9+
10+
class Migration(migrations.Migration):
11+
12+
dependencies = [
13+
("website", "0260_add_username_to_slackbotactivity"),
14+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15+
]
16+
17+
operations = [
18+
migrations.CreateModel(
19+
name="DailyChallenge",
20+
fields=[
21+
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
22+
(
23+
"challenge_type",
24+
models.CharField(
25+
choices=[
26+
("early_checkin", "Early Check-in"),
27+
("positive_mood", "Positive Mood"),
28+
("complete_all_fields", "Complete All Fields"),
29+
("streak_milestone", "Streak Milestone"),
30+
("no_blockers", "No Blockers"),
31+
],
32+
help_text="Type of daily challenge",
33+
max_length=50,
34+
unique=True,
35+
),
36+
),
37+
("title", models.CharField(help_text="Display title for the challenge", max_length=255)),
38+
("description", models.TextField(help_text="Description of what the challenge requires")),
39+
(
40+
"points_reward",
41+
models.IntegerField(
42+
default=10,
43+
help_text="Points awarded for completing this challenge",
44+
validators=[django.core.validators.MinValueValidator(0)],
45+
),
46+
),
47+
(
48+
"is_active",
49+
models.BooleanField(default=True, help_text="Whether this challenge type is currently active"),
50+
),
51+
("created_at", models.DateTimeField(auto_now_add=True)),
52+
("updated_at", models.DateTimeField(auto_now=True)),
53+
],
54+
options={
55+
"verbose_name": "Daily Challenge",
56+
"verbose_name_plural": "Daily Challenges",
57+
"ordering": ["title"],
58+
},
59+
),
60+
migrations.CreateModel(
61+
name="UserDailyChallenge",
62+
fields=[
63+
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
64+
(
65+
"challenge_date",
66+
models.DateField(db_index=True, help_text="Date for which this challenge is assigned"),
67+
),
68+
(
69+
"status",
70+
models.CharField(
71+
choices=[("assigned", "Assigned"), ("completed", "Completed"), ("expired", "Expired")],
72+
default="assigned",
73+
max_length=20,
74+
),
75+
),
76+
(
77+
"points_awarded",
78+
models.IntegerField(
79+
default=0, help_text="Points actually awarded (may differ from challenge.points_reward)"
80+
),
81+
),
82+
(
83+
"completed_at",
84+
models.DateTimeField(blank=True, help_text="When the challenge was completed", null=True),
85+
),
86+
("created_at", models.DateTimeField(auto_now_add=True)),
87+
("updated_at", models.DateTimeField(auto_now=True)),
88+
(
89+
"next_challenge_at",
90+
models.DateTimeField(
91+
blank=True,
92+
db_index=True,
93+
help_text="When the next challenge will be available (24 hours from last check-in submission)",
94+
null=True,
95+
),
96+
),
97+
(
98+
"challenge",
99+
models.ForeignKey(
100+
on_delete=django.db.models.deletion.CASCADE,
101+
related_name="user_assignments",
102+
to="website.dailychallenge",
103+
),
104+
),
105+
(
106+
"user",
107+
models.ForeignKey(
108+
on_delete=django.db.models.deletion.CASCADE,
109+
related_name="daily_challenges",
110+
to=settings.AUTH_USER_MODEL,
111+
),
112+
),
113+
],
114+
options={
115+
"ordering": ["-challenge_date", "status"],
116+
"indexes": [
117+
models.Index(fields=["user", "challenge_date"], name="website_use_user_id_0b6b81_idx"),
118+
models.Index(fields=["status", "challenge_date"], name="website_use_status_cc8b53_idx"),
119+
],
120+
"unique_together": {("user", "challenge_date")},
121+
},
122+
),
123+
]

website/models.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
8621005
class 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

Comments
 (0)