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

View File

@@ -51,6 +51,7 @@ class UnitWithNodesSerializer(serializers.Serializer):
id = serializers.CharField()
title = serializers.CharField()
blurb = serializers.CharField(allow_blank=True)
requiresSubscription = serializers.BooleanField(help_text='این کاربر برای بازیِ این فصل اشتراک لازم دارد (فقط فصلِ اولِ هر پایه رایگان است).')
nodes = NodeStatusSerializer(many=True)

View File

@@ -5,6 +5,7 @@
(با correctCount=0)؛ نقشه هم وضعیتِ «باز» را از روی درس‌های تمام‌شده حساب می‌کرد، پس تمام‌کردنِ
درسِ جزیره‌ی قفل آن را باز می‌کرد. فقط قلبِ صفر جلوی بی‌جواب‌تمام‌کردن را می‌گرفت.
"""
from subscription.models import Subscription
from django.core.cache import cache
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
@@ -127,6 +128,9 @@ class FinishWithoutAnswersTests(_World, TestCase):
def test_the_normal_path_still_works_in_order(self):
"""جزیره‌ی اول، بعد دوم، بعد سوم؛ و اولین جزیره‌ی فصلِ دیگر از اول باز است."""
# فصلِ دوم اشتراک می‌خواهد (content.views.unit_is_free)؛ این تست ترتیبِ باز شدن را می‌سنجد، نه پرداخت.
Subscription.objects.create(user_id=self.user.id, status='active', expires_at=None)
self.assertEqual(self.statuses(), {'یک': 'unlocked', 'دو': 'locked', 'سه': 'locked', 'الف': 'unlocked'})
for node in (self.n1, self.n2, self.n3, self.m1):
record_answers(self.user.id, node=node)

View File

@@ -273,8 +273,9 @@ class NodeQuizTests(TestCase):
مبحثی که هنوز چهار سوال ندارد نباید وانمود کند دارد — فرانت باید
بتواند تفاوتِ «آزمونِ کوتاه» و «خطا» را بفهمد.
"""
# پایه‌ی جدا تا فصلِ اولِ (رایگانِ) همان پایه باشد؛ پرداخت اینجا موضوع نیست.
thin_unit = Unit.objects.create(
grade=self.node.unit.grade, title='فصل ۵', order=2)
grade=Grade.objects.create(title='پایه دهم', icon='', color=''), title='فصل ۵', order=1)
thin = Node.objects.create(unit=thin_unit, title='کم‌سوال', order=1)
lesson = Lesson.objects.create(node=thin, title='تنها درس')
question = Question.objects.create(

View File

@@ -5,6 +5,7 @@
می‌کند، ولی جزیره فقط وقتی «تمام» حساب می‌شد که تک‌تکِ درس‌هایش (۸ تا ۱۳
درس) جدا تمام شده باشند؛ دانش‌آموز بعد از جزیره‌ی اولِ هر فصل گیر می‌کرد.
"""
from subscription.models import Subscription
from django.core.cache import cache
from django.test import TestCase
@@ -30,6 +31,8 @@ class NodeQuizCompleteTests(TestCase):
self.other_unit_first = self._node(self.next_unit, 'فصلِ بعد', 1, lessons=2)
self.user = User.objects.create(phone='09120000009', grade_id=self.grade.id)
# فصلِ دوم اشتراک می‌خواهد (content.views.unit_is_free)؛ این تست ترتیبِ باز شدن را می‌سنجد، نه پرداخت.
Subscription.objects.create(user_id=self.user.id, status='active', expires_at=None)
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(self.user.id)}'}
def _node(self, unit, title, order, lessons):

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

View File

@@ -263,6 +263,36 @@ def node_is_unlocked(user_id, node) -> bool:
return False
def unit_is_free(unit_id: int, grade_id: int) -> bool:
"""
فصلِ رایگان: فقط اولین فصلِ هر پایه (به ترتیبِ `Unit.order`، همان ترتیبِ نقشه). بقیه‌ی فصل‌ها
اشتراکِ زمانی می‌خواهند (تصمیمِ سینیور و مدیرعامل، ۲۰۲۶-۱۰-۱۰: «هر کاربر یک درس را ببرد جلو،
برای بقیه اشتراک بخرد»؛ مالکِ پروژه «درس» را «فصل» گرفت).
"""
tree = _grade_tree(grade_id) or []
return bool(tree) and tree[0]['id'] == str(unit_id)
def has_unit_access(user_id, unit_id: int, grade_id: int) -> bool:
from subscription.services import has_active_subscription
if unit_is_free(unit_id, grade_id):
return True
return user_id is not None and has_active_subscription(user_id)
def subscription_required_response() -> Response:
# ۴۰۲ و نه ۴۰۳: فرانت از روی همین کد به صفحه‌ی اشتراک می‌رود، نه پیامِ «قفل است».
return Response(
{'detail': 'این فصل با اشتراک باز می‌شود. فصلِ اولِ هر پایه رایگان است.',
'code': 'subscription_required'},
status=status.HTTP_402_PAYMENT_REQUIRED,
)
def _user_id_or_none(request):
return request.user.id if getattr(request.user, 'is_authenticated', False) else None
def _completion_body(title, stars, xp):
"""
متنِ اعلانِ «تمام کردی». عددها فارسی، چون اپ متنِ اعلان را همان‌طور که
@@ -363,8 +393,10 @@ class HomeView(APIView):
).values_list('node_id', flat=True)
)
from subscription.services import has_active_subscription
subscribed = user_id is not None and has_active_subscription(user_id)
units_payload = []
for unit in tree:
for index, unit in enumerate(tree):
node_payload = []
unlock_next = True # اولین نودِ قفل‌نشده در هر واحد، باز می‌مونه
for node in unit['nodes']:
@@ -395,6 +427,9 @@ class HomeView(APIView):
'id': unit['id'],
'title': unit['title'],
'blurb': unit.get('blurb', ''),
# فقط فصلِ اولِ هر پایه رایگان است (`unit_is_free`)؛ true یعنی همین کاربر برای بازی‌کردنش
# اشتراک لازم دارد. وضعیتِ جزیره‌ها عوض نمی‌شود تا پیشرفتِ کسی که اشتراکش تمام شد گم نشود.
'requiresSubscription': index > 0 and not subscribed,
'nodes': node_payload,
})
@@ -459,7 +494,9 @@ class LessonQuestionsView(APIView):
@extend_schema(responses=LessonQuestionsResponseSerializer, tags=['Curriculum (content)'])
def get(self, request, lesson_id):
lesson = get_object_or_404(Lesson, pk=lesson_id)
lesson = get_object_or_404(Lesson.objects.select_related('node__unit'), pk=lesson_id)
if not has_unit_access(_user_id_or_none(request), lesson.node.unit_id, lesson.node.unit.grade_id):
return subscription_required_response()
questions = lesson.questions.prefetch_related('choices').all()
data = {'questions': [
{
@@ -501,7 +538,10 @@ class QuestionAnswerView(APIView):
tags=['Curriculum (content)'],
)
def post(self, request, question_id):
question = get_object_or_404(Question, pk=question_id)
question = get_object_or_404(Question.objects.select_related('lesson__node__unit'), pk=question_id)
unit = question.lesson.node.unit
if not has_unit_access(request.user.id, unit.id, unit.grade_id):
return subscription_required_response()
serializer = AnswerRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
selected_option_id = serializer.validated_data['selectedOptionId']
@@ -677,6 +717,8 @@ class LessonCompleteView(APIView):
# بی‌امتیاز؛ فقط قفل بودنِ جزیره‌اش بررسی می‌شود.
# جزیره‌ی قفل را با تمام‌کردنِ درس‌هایش هم نمی‌شود باز کرد (وضعیتِ «باز» از روی
# درس‌های تمام‌شده هم حساب می‌شود، پس بدونِ این قفل از همین راه دور زده می‌شد).
if not has_unit_access(user_id, lesson.node.unit_id, lesson.node.unit.grade_id):
return subscription_required_response()
if not node_is_unlocked(user_id, lesson.node):
return node_locked_response()
@@ -885,6 +927,8 @@ class NodeQuizView(APIView):
node = get_object_or_404(
Node.objects.select_related('unit'), pk=node_id,
)
if not has_unit_access(_user_id_or_none(request), node.unit_id, node.unit.grade_id):
return subscription_required_response()
try:
size = int(request.query_params.get('size', self.DEFAULT_SIZE))
except (TypeError, ValueError):
@@ -1033,6 +1077,8 @@ class NodeQuizCompleteView(APIView):
return respond(existing, UserStats.objects.filter(user_id=user_id).first())
# قفل: جزیره‌ی قفل را نمی‌شود تمام کرد (تا ۲۰۲۶-۱۰-۰۷ می‌شد، با correctCount=0).
if not has_unit_access(user_id, node.unit_id, node.unit.grade_id):
return subscription_required_response()
if not node_is_unlocked(user_id, node):
return node_locked_response()