diff --git a/content/tests.py b/content/tests.py index 88b30e1..5094a7a 100644 --- a/content/tests.py +++ b/content/tests.py @@ -1,12 +1,15 @@ +from datetime import timedelta from unittest.mock import patch from django.test import TestCase +from django.utils import timezone from rest_framework.test import APIClient from rest_framework.throttling import ScopedRateThrottle from accounts.authentication import issue_access_token from accounts.models import User from progress.models import UserHearts, UserStats +from subscription.models import Plan, Subscription from .models import Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserAnswer, UserLessonCompletion @@ -636,6 +639,63 @@ class GradesListCacheTests(TestCase): self.assertEqual(len(r3.data['grades']), 2) +class UnlimitedHeartsSubscriptionTests(TestCase): + """رگرسیون: هر مشترکِ فعال (فارغ از پلن) نباید با جوابِ غلط هارت از دست بده.""" + + def setUp(self): + self.client = APIClient() + self.user = User.objects.create(device_id='dev-unlimited-hearts', is_guest=True, name='') + self.token = issue_access_token(self.user.id) + + grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff') + unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1) + node = Node.objects.create(unit=unit, title='گره ۱', order=1) + lesson = Lesson.objects.create(node=node, title='درس ۱', intro_text='...') + self.question = Question.objects.create(lesson=lesson, type='multiple_choice', prompt='سوال ۱') + Choice.objects.create(question=self.question, value='درست', is_correct=True) + self.wrong_choice = Choice.objects.create(question=self.question, value='غلط', is_correct=False) + + def _answer_wrong(self): + return self.client.post( + f'/api/questions/{self.question.id}/answer', + {'selectedOptionId': str(self.wrong_choice.id)}, + format='json', + HTTP_AUTHORIZATION=f'Bearer {self.token}', + ) + + def test_wrong_answer_costs_a_heart_without_subscription(self): + UserHearts.objects.create(user_id=self.user.id, current_hearts=5, max_hearts=5) + + response = self._answer_wrong() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['heartsRemaining'], 4) + + def test_wrong_answer_does_not_cost_a_heart_with_active_subscription(self): + UserHearts.objects.create(user_id=self.user.id, current_hearts=5, max_hearts=5) + plan = Plan.objects.create(title='ماهانه', price=100000) + Subscription.objects.create( + user_id=self.user.id, plan=plan, status='active', + expires_at=timezone.now() + timedelta(days=30), + ) + + response = self._answer_wrong() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['heartsRemaining'], 5) + self.assertEqual(UserHearts.objects.get(user_id=self.user.id).current_hearts, 5) + + def test_expired_subscription_still_costs_a_heart(self): + UserHearts.objects.create(user_id=self.user.id, current_hearts=5, max_hearts=5) + plan = Plan.objects.create(title='ماهانه', price=100000) + Subscription.objects.create( + user_id=self.user.id, plan=plan, status='active', + expires_at=timezone.now() - timedelta(days=1), + ) + + response = self._answer_wrong() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['heartsRemaining'], 4) + + class ReadThrottleTests(TestCase): def setUp(self): _django_cache.clear() diff --git a/content/views.py b/content/views.py index 675ce70..fa227a0 100644 --- a/content/views.py +++ b/content/views.py @@ -11,6 +11,7 @@ from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView from progress.models import UserHearts, UserStats +from subscription.services import has_unlimited_hearts from .models import Choice, Grade, Lesson, NodeProgress, Question, UserAnswer, UserLessonCompletion from .serializers import ( @@ -308,9 +309,10 @@ class QuestionAnswerView(APIView): if not choice.is_correct: UserHearts.objects.get_or_create(user_id=user_id) hearts = UserHearts.objects.select_for_update().get(user_id=user_id) - if hearts.current_hearts > 0: - hearts.current_hearts -= 1 - hearts.save(update_fields=['current_hearts']) + hearts.apply_regen() + if not has_unlimited_hearts(user_id): + hearts.lose_heart() + hearts.save(update_fields=['current_hearts', 'next_refill_at']) hearts_remaining = hearts.current_hearts else: hearts = UserHearts.objects.filter(user_id=user_id).first() diff --git a/progress/models.py b/progress/models.py index b678f74..6b8482d 100644 --- a/progress/models.py +++ b/progress/models.py @@ -1,4 +1,9 @@ +from datetime import timedelta + from django.db import models +from django.utils import timezone + +HEART_REFILL_INTERVAL = timedelta(hours=24) class UserStats(models.Model): @@ -54,3 +59,36 @@ class UserHearts(models.Model): def __str__(self): return f"hearts-user{self.user_id}" + + def apply_regen(self): + """اگه از next_refill_at گذشته باشه، هارت(های) گذشته رو پر می‌کنه (چندتا هم + اگه کاربر چند بازه‌ی HEART_REFILL_INTERVAL غایب بوده باشه). True یعنی + state عوض شده و باید save بشه.""" + if self.current_hearts >= self.max_hearts: + if self.next_refill_at is not None: + self.next_refill_at = None + return True + return False + + now = timezone.now() + if self.next_refill_at is None: + self.next_refill_at = now + HEART_REFILL_INTERVAL + return True + if now < self.next_refill_at: + return False + + elapsed_intervals = 1 + (now - self.next_refill_at) // HEART_REFILL_INTERVAL + gained = min(elapsed_intervals, self.max_hearts - self.current_hearts) + self.current_hearts += gained + if self.current_hearts >= self.max_hearts: + self.next_refill_at = None + else: + self.next_refill_at += elapsed_intervals * HEART_REFILL_INTERVAL + return True + + def lose_heart(self): + """یک هارت کم می‌کنه؛ اگه تایمرِ refill از قبل در حال شمارش نیست، از الان شروعش می‌کنه.""" + if self.current_hearts > 0: + self.current_hearts -= 1 + if self.next_refill_at is None and self.current_hearts < self.max_hearts: + self.next_refill_at = timezone.now() + HEART_REFILL_INTERVAL diff --git a/progress/serializers.py b/progress/serializers.py index 4abe634..08fcd30 100644 --- a/progress/serializers.py +++ b/progress/serializers.py @@ -39,3 +39,4 @@ class UserHeartsResponseSerializer(serializers.Serializer): currentHearts = serializers.IntegerField() maxHearts = serializers.IntegerField() nextRefillAt = serializers.DateTimeField(allow_null=True) + isUnlimited = serializers.BooleanField() diff --git a/progress/tests.py b/progress/tests.py index a39b155..07a264b 100644 --- a/progress/tests.py +++ b/progress/tests.py @@ -1 +1,62 @@ -# Create your tests here. +from datetime import timedelta + +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from accounts.authentication import issue_access_token +from accounts.models import User + +from .models import HEART_REFILL_INTERVAL, UserHearts + + +class UserHeartsRegenTests(TestCase): + """رگرسیون: قبلاً next_refill_at هیچ‌وقت واقعاً چک نمی‌شد و هارتِ ازدست‌رفته + برای همیشه صفر می‌موند.""" + + def setUp(self): + self.client = APIClient() + self.user = User.objects.create(device_id='dev-hearts-regen', is_guest=True, name='') + self.token = issue_access_token(self.user.id) + + def _get_hearts(self): + return self.client.get('/api/user/hearts', HTTP_AUTHORIZATION=f'Bearer {self.token}') + + def test_no_refill_before_interval_elapses(self): + hearts = UserHearts.objects.create(user_id=self.user.id, current_hearts=3, max_hearts=5) + hearts.lose_heart() # شبیه‌سازیِ از دست دادنِ یک هارتِ دیگه؛ تایمر رو ست می‌کنه + hearts.save() + + response = self._get_hearts() + self.assertEqual(response.data['currentHearts'], 2) + self.assertIsNotNone(response.data['nextRefillAt']) + + def test_refill_after_interval_elapses(self): + hearts = UserHearts.objects.create( + user_id=self.user.id, current_hearts=3, max_hearts=5, + next_refill_at=timezone.now() - timedelta(seconds=1), + ) + + response = self._get_hearts() + self.assertEqual(response.data['currentHearts'], 4) + + hearts.refresh_from_db() + self.assertEqual(hearts.current_hearts, 4) + self.assertIsNotNone(hearts.next_refill_at) # هنوز به max نرسیده، تایمرِ بعدی ست شده + + def test_refill_catches_up_multiple_missed_intervals(self): + UserHearts.objects.create( + user_id=self.user.id, current_hearts=1, max_hearts=5, + next_refill_at=timezone.now() - 3 * HEART_REFILL_INTERVAL, + ) + + response = self._get_hearts() + self.assertEqual(response.data['currentHearts'], 5) + self.assertIsNone(response.data['nextRefillAt']) # به max رسیده، تایمر خاموش می‌شه + + def test_full_hearts_have_no_refill_timer(self): + UserHearts.objects.create(user_id=self.user.id, current_hearts=5, max_hearts=5) + + response = self._get_hearts() + self.assertEqual(response.data['currentHearts'], 5) + self.assertIsNone(response.data['nextRefillAt']) diff --git a/progress/views.py b/progress/views.py index 9851bfb..084a43a 100644 --- a/progress/views.py +++ b/progress/views.py @@ -122,11 +122,18 @@ class UserHeartsView(APIView): @extend_schema(responses=UserHeartsResponseSerializer, tags=['Progress & Gamification (progress)']) def get(self, request): + from subscription.services import has_unlimited_hearts + user = request.user hearts, _ = UserHearts.objects.get_or_create(user_id=user.id) + if hearts.apply_regen(): + hearts.save(update_fields=['current_hearts', 'next_refill_at']) + + unlimited = has_unlimited_hearts(user.id) data = { - 'currentHearts': hearts.current_hearts, + 'currentHearts': hearts.max_hearts if unlimited else hearts.current_hearts, 'maxHearts': hearts.max_hearts, - 'nextRefillAt': hearts.next_refill_at, + 'nextRefillAt': None if unlimited else hearts.next_refill_at, + 'isUnlimited': unlimited, } return Response(UserHeartsResponseSerializer(data).data) diff --git a/subscription/services.py b/subscription/services.py index 12d9d54..b24d99a 100644 --- a/subscription/services.py +++ b/subscription/services.py @@ -1,4 +1,4 @@ -from django.db import transaction +from django.db import models, transaction from django.utils import timezone from statuslog.signals import status_changed @@ -6,6 +6,19 @@ from statuslog.signals import status_changed from .models import Subscription +def has_unlimited_hearts(user_id: int) -> bool: + """آیا کاربر اشتراکِ فعال داره (progress.UserHearts از این استفاده می‌کنه تا + هارت کم نکنه). فعلاً هر اشتراکِ فعال، فارغ از پلن/مدت، این پرک رو می‌ده — + وقتی محصول پلن‌های متفاوت با پرکِ متفاوت خواست، این تابع دقیق‌تر می‌شه. + expires_at=None یعنی اشتراکِ نامحدود (grant_free_subscription با duration_days=None).""" + now = timezone.now() + return Subscription.objects.filter( + user_id=user_id, status='active', + ).filter( + models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now) + ).exists() + + def activate_subscription(subscription_id: int): """ بعد از تایید پرداخت (payment webhook) صدا زده می‌شود؛ اشتراک را active