fix heart refill and grant unlimited hearts to active subscribers
next_refill_at was set on UserHearts but never read or recalculated, so a heart lost to zero never came back. UserHearts.apply_regen() now catches up missed 24h intervals on read/deduct, and lose_heart() starts the timer only when it isn't already running. Any user with an active (non-expired) subscription now skips heart deduction entirely, regardless of which plan they're on — good enough for now until per-plan perks are needed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,15 @@
|
|||||||
|
from datetime import timedelta
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
from rest_framework.throttling import ScopedRateThrottle
|
from rest_framework.throttling import ScopedRateThrottle
|
||||||
|
|
||||||
from accounts.authentication import issue_access_token
|
from accounts.authentication import issue_access_token
|
||||||
from accounts.models import User
|
from accounts.models import User
|
||||||
from progress.models import UserHearts, UserStats
|
from progress.models import UserHearts, UserStats
|
||||||
|
from subscription.models import Plan, Subscription
|
||||||
|
|
||||||
from .models import Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserAnswer, UserLessonCompletion
|
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)
|
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):
|
class ReadThrottleTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
_django_cache.clear()
|
_django_cache.clear()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from rest_framework.throttling import ScopedRateThrottle
|
|||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from progress.models import UserHearts, UserStats
|
from progress.models import UserHearts, UserStats
|
||||||
|
from subscription.services import has_unlimited_hearts
|
||||||
|
|
||||||
from .models import Choice, Grade, Lesson, NodeProgress, Question, UserAnswer, UserLessonCompletion
|
from .models import Choice, Grade, Lesson, NodeProgress, Question, UserAnswer, UserLessonCompletion
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
@@ -308,9 +309,10 @@ class QuestionAnswerView(APIView):
|
|||||||
if not choice.is_correct:
|
if not choice.is_correct:
|
||||||
UserHearts.objects.get_or_create(user_id=user_id)
|
UserHearts.objects.get_or_create(user_id=user_id)
|
||||||
hearts = UserHearts.objects.select_for_update().get(user_id=user_id)
|
hearts = UserHearts.objects.select_for_update().get(user_id=user_id)
|
||||||
if hearts.current_hearts > 0:
|
hearts.apply_regen()
|
||||||
hearts.current_hearts -= 1
|
if not has_unlimited_hearts(user_id):
|
||||||
hearts.save(update_fields=['current_hearts'])
|
hearts.lose_heart()
|
||||||
|
hearts.save(update_fields=['current_hearts', 'next_refill_at'])
|
||||||
hearts_remaining = hearts.current_hearts
|
hearts_remaining = hearts.current_hearts
|
||||||
else:
|
else:
|
||||||
hearts = UserHearts.objects.filter(user_id=user_id).first()
|
hearts = UserHearts.objects.filter(user_id=user_id).first()
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
HEART_REFILL_INTERVAL = timedelta(hours=24)
|
||||||
|
|
||||||
|
|
||||||
class UserStats(models.Model):
|
class UserStats(models.Model):
|
||||||
@@ -54,3 +59,36 @@ class UserHearts(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"hearts-user{self.user_id}"
|
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
|
||||||
|
|||||||
@@ -39,3 +39,4 @@ class UserHeartsResponseSerializer(serializers.Serializer):
|
|||||||
currentHearts = serializers.IntegerField()
|
currentHearts = serializers.IntegerField()
|
||||||
maxHearts = serializers.IntegerField()
|
maxHearts = serializers.IntegerField()
|
||||||
nextRefillAt = serializers.DateTimeField(allow_null=True)
|
nextRefillAt = serializers.DateTimeField(allow_null=True)
|
||||||
|
isUnlimited = serializers.BooleanField()
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
|||||||
@@ -122,11 +122,18 @@ class UserHeartsView(APIView):
|
|||||||
|
|
||||||
@extend_schema(responses=UserHeartsResponseSerializer, tags=['Progress & Gamification (progress)'])
|
@extend_schema(responses=UserHeartsResponseSerializer, tags=['Progress & Gamification (progress)'])
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
|
from subscription.services import has_unlimited_hearts
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
hearts, _ = UserHearts.objects.get_or_create(user_id=user.id)
|
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 = {
|
data = {
|
||||||
'currentHearts': hearts.current_hearts,
|
'currentHearts': hearts.max_hearts if unlimited else hearts.current_hearts,
|
||||||
'maxHearts': hearts.max_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)
|
return Response(UserHeartsResponseSerializer(data).data)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from django.db import transaction
|
from django.db import models, transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from statuslog.signals import status_changed
|
from statuslog.signals import status_changed
|
||||||
@@ -6,6 +6,19 @@ from statuslog.signals import status_changed
|
|||||||
from .models import Subscription
|
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):
|
def activate_subscription(subscription_id: int):
|
||||||
"""
|
"""
|
||||||
بعد از تایید پرداخت (payment webhook) صدا زده میشود؛ اشتراک را active
|
بعد از تایید پرداخت (payment webhook) صدا زده میشود؛ اشتراک را active
|
||||||
|
|||||||
Reference in New Issue
Block a user