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

View File

@@ -39,3 +39,4 @@ class UserHeartsResponseSerializer(serializers.Serializer):
currentHearts = serializers.IntegerField()
maxHearts = serializers.IntegerField()
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)'])
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)