/api/user/summary: streak.activeToday and streak.lost for the app's lit / at-risk / frozen flame
activeToday: activity recorded today (Tehran date). lost: length of a streak that broke (last active day before yesterday), so the app can show it frozen. Same rule as effective_current_streak; the weekly report keeps its own two-field streak serializer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,14 @@ class StreakSummarySerializer(serializers.Serializer):
|
|||||||
longest = serializers.IntegerField()
|
longest = serializers.IntegerField()
|
||||||
|
|
||||||
|
|
||||||
|
class UserStreakSerializer(StreakSummarySerializer):
|
||||||
|
"""زنجیره در /api/user/summary: بهعلاوهی وضعیتی که خانه با آن آتش/در خطر/یخزده را نشان میدهد."""
|
||||||
|
activeToday = serializers.BooleanField(help_text='امروز (وقتِ تهران) فعالیتی ثبت شده؛ زنجیره امروز روشن است.')
|
||||||
|
lost = serializers.IntegerField(
|
||||||
|
help_text='طولِ زنجیرهای که با نیامدنِ دیروز شکست؛ صفر یعنی چیزی نشکسته. اپ آن را «یخزده» نشان میدهد.',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UserSummaryResponseSerializer(serializers.Serializer):
|
class UserSummaryResponseSerializer(serializers.Serializer):
|
||||||
userId = serializers.CharField()
|
userId = serializers.CharField()
|
||||||
name = serializers.CharField()
|
name = serializers.CharField()
|
||||||
@@ -13,7 +21,7 @@ class UserSummaryResponseSerializer(serializers.Serializer):
|
|||||||
gradeId = serializers.IntegerField(allow_null=True)
|
gradeId = serializers.IntegerField(allow_null=True)
|
||||||
xp = serializers.IntegerField()
|
xp = serializers.IntegerField()
|
||||||
coins = serializers.IntegerField()
|
coins = serializers.IntegerField()
|
||||||
streak = StreakSummarySerializer()
|
streak = UserStreakSerializer()
|
||||||
subscription = serializers.CharField(allow_null=True)
|
subscription = serializers.CharField(allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -57,3 +57,21 @@ def effective_current_streak(streak, today=None):
|
|||||||
if last is None or last < today - timedelta(days=1):
|
if last is None or last < today - timedelta(days=1):
|
||||||
return 0
|
return 0
|
||||||
return streak.current_streak
|
return streak.current_streak
|
||||||
|
|
||||||
|
|
||||||
|
def streak_state(streak, today=None):
|
||||||
|
"""
|
||||||
|
وضعیتِ زنجیره برای نمایش: (current, active_today, lost).
|
||||||
|
|
||||||
|
lost طولِ زنجیرهای است که شکسته (آخرین روزِ فعال پیش از دیروز)؛ current_streakِ دیتابیس تا فعالیتِ بعدی
|
||||||
|
همان عددِ قبلی را نگه میدارد (record_activity آن را به ۱ برمیگرداند)، پس همین عدد است.
|
||||||
|
"""
|
||||||
|
if streak is None:
|
||||||
|
return 0, False, 0
|
||||||
|
today = today or timezone.localdate()
|
||||||
|
last = streak.active_days.order_by('-date').values_list('date', flat=True).first()
|
||||||
|
if last is None:
|
||||||
|
return 0, False, 0
|
||||||
|
if last < today - timedelta(days=1):
|
||||||
|
return 0, False, streak.current_streak
|
||||||
|
return streak.current_streak, last == today, 0
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from accounts.authentication import issue_access_token
|
|||||||
from accounts.models import User
|
from accounts.models import User
|
||||||
from content.models import Choice, Grade, Lesson, Node, Question, Unit
|
from content.models import Choice, Grade, Lesson, Node, Question, Unit
|
||||||
from progress.models import StreakRecord
|
from progress.models import StreakRecord
|
||||||
from progress.services import effective_current_streak, record_activity
|
from progress.services import effective_current_streak, record_activity, streak_state
|
||||||
|
|
||||||
|
|
||||||
class StreakTests(TestCase):
|
class StreakTests(TestCase):
|
||||||
@@ -50,6 +50,19 @@ class StreakTests(TestCase):
|
|||||||
self.assertEqual(effective_current_streak(streak, today=date(2026, 9, 23)), 0)
|
self.assertEqual(effective_current_streak(streak, today=date(2026, 9, 23)), 0)
|
||||||
self.assertEqual(effective_current_streak(None), 0)
|
self.assertEqual(effective_current_streak(None), 0)
|
||||||
|
|
||||||
|
def test_state_tells_lit_at_risk_and_frozen_apart(self):
|
||||||
|
"""اپ سه حالت نشان میدهد: امروز روشن، امروز هنوز نه (در خطر)، و شکسته («یخزده» با طولِ قبلی)."""
|
||||||
|
for day in (20, 21, 22):
|
||||||
|
streak = record_activity(1, today=date(2026, 9, day))
|
||||||
|
|
||||||
|
self.assertEqual(streak_state(streak, today=date(2026, 9, 22)), (3, True, 0))
|
||||||
|
self.assertEqual(streak_state(streak, today=date(2026, 9, 23)), (3, False, 0))
|
||||||
|
self.assertEqual(streak_state(streak, today=date(2026, 9, 24)), (0, False, 3))
|
||||||
|
self.assertEqual(streak_state(None), (0, False, 0))
|
||||||
|
# فعالیتِ بعد از شکستن زنجیرهی تازه میسازد و «یخ» میرود.
|
||||||
|
streak = record_activity(1, today=date(2026, 9, 24))
|
||||||
|
self.assertEqual(streak_state(streak, today=date(2026, 9, 24)), (1, True, 0))
|
||||||
|
|
||||||
|
|
||||||
class ActivityFeedsStreakAndLeaderboardTests(TestCase):
|
class ActivityFeedsStreakAndLeaderboardTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -74,6 +87,8 @@ class ActivityFeedsStreakAndLeaderboardTests(TestCase):
|
|||||||
|
|
||||||
summary = self.client.get('/api/user/summary', **self.auth).json()
|
summary = self.client.get('/api/user/summary', **self.auth).json()
|
||||||
self.assertEqual(summary['streak']['current'], 1)
|
self.assertEqual(summary['streak']['current'], 1)
|
||||||
|
self.assertIs(summary['streak']['activeToday'], True)
|
||||||
|
self.assertEqual(summary['streak']['lost'], 0)
|
||||||
calendar = self.client.get('/api/user/streak/calendar', **self.auth).json()
|
calendar = self.client.get('/api/user/streak/calendar', **self.auth).json()
|
||||||
self.assertEqual(len(calendar['activeDays']), 1)
|
self.assertEqual(len(calendar['activeDays']), 1)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from rest_framework.throttling import ScopedRateThrottle
|
|||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from .models import Character, StreakRecord, UserHearts, UserStats
|
from .models import Character, StreakRecord, UserHearts, UserStats
|
||||||
from .services import effective_current_streak
|
from .services import effective_current_streak, streak_state
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
CharacterListResponseSerializer,
|
CharacterListResponseSerializer,
|
||||||
LeaderboardResponseSerializer,
|
LeaderboardResponseSerializer,
|
||||||
@@ -47,6 +47,7 @@ class UserSummaryView(APIView):
|
|||||||
user = request.user # accounts.User، از طریق TadrisinoTokenAuthentication
|
user = request.user # accounts.User، از طریق TadrisinoTokenAuthentication
|
||||||
stats = UserStats.objects.filter(user_id=user.id).first()
|
stats = UserStats.objects.filter(user_id=user.id).first()
|
||||||
streak = StreakRecord.objects.filter(user_id=user.id).first()
|
streak = StreakRecord.objects.filter(user_id=user.id).first()
|
||||||
|
current, active_today, lost = streak_state(streak)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'userId': str(user.id),
|
'userId': str(user.id),
|
||||||
@@ -56,8 +57,10 @@ class UserSummaryView(APIView):
|
|||||||
'xp': stats.xp if stats else 0,
|
'xp': stats.xp if stats else 0,
|
||||||
'coins': stats.coins if stats else 0,
|
'coins': stats.coins if stats else 0,
|
||||||
'streak': {
|
'streak': {
|
||||||
'current': effective_current_streak(streak),
|
'current': current,
|
||||||
'longest': streak.longest_streak if streak else 0,
|
'longest': streak.longest_streak if streak else 0,
|
||||||
|
'activeToday': active_today,
|
||||||
|
'lost': lost,
|
||||||
},
|
},
|
||||||
# عنوانِ پلنِ اشتراکِ فعال، یا null. تا ۲۰۲۶-۰۹-۲۴ همیشه null بود
|
# عنوانِ پلنِ اشتراکِ فعال، یا null. تا ۲۰۲۶-۰۹-۲۴ همیشه null بود
|
||||||
# (از زمانی که اپِ subscription هنوز وجود نداشت).
|
# (از زمانی که اپِ subscription هنوز وجود نداشت).
|
||||||
|
|||||||
Reference in New Issue
Block a user