Paywall: first unit of each grade free; 1/3/6-month subscriptions; AI sold separately

- content: unit_is_free/has_unit_access; questions, node quiz, answer and lesson/node
  complete return 402 subscription_required outside the first unit without an active
  subscription. /api/home units carry requiresSubscription.
- subscription.0013: monthly 498k, quarterly 798k, six-month 1,298k toman, unlimited
  hearts, 3 AI questions/day; packs stay on sale for more questions.
- AI_DAILY_QUOTA_FREE default 3 -> 2.
- /api/subscription/status adds planTitle, planDurationDays, aiDailyQuota.
- tests: content/tests_paywall.py; existing unlock-order tests get a subscription.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 01:19:02 +03:30
parent 7d81d193bd
commit 64c4c09326
16 changed files with 214 additions and 28 deletions

70
content/tests_paywall.py Normal file
View File

@@ -0,0 +1,70 @@
"""
فصلِ اولِ هر پایه رایگان است و بقیه اشتراکِ زمانی می‌خواهند (۲۰۲۶-۱۰-۱۰).
بازی‌کردنِ فصلِ قفل از هر مسیری (سوال‌های درس، آزمونِ جزیره، جواب، پایانِ درس/جزیره) ۴۰۲ می‌گیرد.
"""
from django.core.cache import cache
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 subscription.models import Subscription
from .models import Choice, Grade, Lesson, Node, Question, Unit
class PaywallTests(TestCase):
def setUp(self):
cache.clear()
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
self.free = self._node(Unit.objects.create(grade=grade, title='فصل ۱', order=1))
self.paid = self._node(Unit.objects.create(grade=grade, title='فصل ۲', order=2))
self.user = User.objects.create(phone='09120007901', grade_id=grade.id)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
def _node(self, unit):
node = Node.objects.create(unit=unit, title=unit.title, order=1)
lesson = Lesson.objects.create(node=node, title='درس')
q = Question.objects.create(lesson=lesson, type='multiple_choice', prompt='؟')
self.right = Choice.objects.create(question=q, value='۱', is_correct=True)
node.q, node.choice, node.lesson = q, self.right, lesson
return node
def _every_route(self, node):
return [
self.client.get(f'/api/lessons/{node.lesson.id}/questions').status_code,
self.client.get(f'/api/nodes/{node.id}/quiz').status_code,
self.client.post(f'/api/questions/{node.q.id}/answer', {'selectedOptionId': str(node.choice.id)},
format='json').status_code,
self.client.post(f'/api/nodes/{node.id}/quiz/complete', {'correctCount': 1, 'totalQuestions': 1},
format='json').status_code,
]
def test_the_second_unit_is_closed_without_a_subscription(self):
self.assertEqual(self._every_route(self.paid), [402, 402, 402, 402])
home = self.client.get('/api/home').json()
self.assertEqual([u['requiresSubscription'] for u in home['units']], [False, True])
def test_the_first_unit_is_free(self):
self.assertNotIn(402, self._every_route(self.free))
def test_an_active_subscription_opens_everything_and_an_expired_one_does_not(self):
sub = Subscription.objects.create(user_id=self.user.id, status='active',
expires_at=timezone.now() + timezone.timedelta(days=3))
self.assertNotIn(402, self._every_route(self.paid))
self.assertEqual([u['requiresSubscription'] for u in self.client.get('/api/home').json()['units']],
[False, False])
sub.expires_at = timezone.now() - timezone.timedelta(minutes=1)
sub.save()
self.assertEqual(self.client.get(f'/api/nodes/{self.paid.id}/quiz').status_code, 402)
def test_status_reports_plan_title_and_quota(self):
from subscription.models import Plan
plan = Plan.objects.get(kind='subscription', on_sale=True, duration_days=90)
Subscription.objects.create(user_id=self.user.id, plan=plan, status='active',
expires_at=timezone.now() + timezone.timedelta(days=10))
body = self.client.get('/api/subscription/status').json()
self.assertEqual((body['active'], body['planTitle'], body['planDurationDays'], body['aiDailyQuota']),
(True, 'سه‌ماهه', 90, 3))