diff --git a/progress/serializers.py b/progress/serializers.py index b18354a..e7927f3 100644 --- a/progress/serializers.py +++ b/progress/serializers.py @@ -6,6 +6,14 @@ class StreakSummarySerializer(serializers.Serializer): longest = serializers.IntegerField() +class UserStreakSerializer(StreakSummarySerializer): + """زنجیره در /api/user/summary: به‌علاوه‌ی وضعیتی که خانه با آن آتش/در خطر/یخ‌زده را نشان می‌دهد.""" + activeToday = serializers.BooleanField(help_text='امروز (وقتِ تهران) فعالیتی ثبت شده؛ زنجیره امروز روشن است.') + lost = serializers.IntegerField( + help_text='طولِ زنجیره‌ای که با نیامدنِ دیروز شکست؛ صفر یعنی چیزی نشکسته. اپ آن را «یخ‌زده» نشان می‌دهد.', + ) + + class UserSummaryResponseSerializer(serializers.Serializer): userId = serializers.CharField() name = serializers.CharField() @@ -13,7 +21,7 @@ class UserSummaryResponseSerializer(serializers.Serializer): gradeId = serializers.IntegerField(allow_null=True) xp = serializers.IntegerField() coins = serializers.IntegerField() - streak = StreakSummarySerializer() + streak = UserStreakSerializer() subscription = serializers.CharField(allow_null=True) diff --git a/progress/services.py b/progress/services.py index 4bfa2d5..c821130 100644 --- a/progress/services.py +++ b/progress/services.py @@ -57,3 +57,21 @@ def effective_current_streak(streak, today=None): if last is None or last < today - timedelta(days=1): return 0 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 diff --git a/progress/tests_live_gamification.py b/progress/tests_live_gamification.py index 79e0479..9ae5cb6 100644 --- a/progress/tests_live_gamification.py +++ b/progress/tests_live_gamification.py @@ -15,7 +15,7 @@ from accounts.authentication import issue_access_token from accounts.models import User from content.models import Choice, Grade, Lesson, Node, Question, Unit 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): @@ -50,6 +50,19 @@ class StreakTests(TestCase): self.assertEqual(effective_current_streak(streak, today=date(2026, 9, 23)), 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): def setUp(self): @@ -74,6 +87,8 @@ class ActivityFeedsStreakAndLeaderboardTests(TestCase): summary = self.client.get('/api/user/summary', **self.auth).json() 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() self.assertEqual(len(calendar['activeDays']), 1) diff --git a/progress/views.py b/progress/views.py index 1230ea3..869a10e 100644 --- a/progress/views.py +++ b/progress/views.py @@ -8,7 +8,7 @@ from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView from .models import Character, StreakRecord, UserHearts, UserStats -from .services import effective_current_streak +from .services import effective_current_streak, streak_state from .serializers import ( CharacterListResponseSerializer, LeaderboardResponseSerializer, @@ -47,6 +47,7 @@ class UserSummaryView(APIView): user = request.user # accounts.User، از طریق TadrisinoTokenAuthentication stats = UserStats.objects.filter(user_id=user.id).first() streak = StreakRecord.objects.filter(user_id=user.id).first() + current, active_today, lost = streak_state(streak) data = { 'userId': str(user.id), @@ -56,8 +57,10 @@ class UserSummaryView(APIView): 'xp': stats.xp if stats else 0, 'coins': stats.coins if stats else 0, 'streak': { - 'current': effective_current_streak(streak), + 'current': current, 'longest': streak.longest_streak if streak else 0, + 'activeToday': active_today, + 'lost': lost, }, # عنوانِ پلنِ اشتراکِ فعال، یا null. تا ۲۰۲۶-۰۹-۲۴ همیشه null بود # (از زمانی که اپِ subscription هنوز وجود نداشت).