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:
2026-09-05 13:26:49 +03:30
parent a367685b2e
commit a275eac7fd
7 changed files with 189 additions and 7 deletions

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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()

View File

@@ -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'])

View File

@@ -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)

View File

@@ -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