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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
70
content/tests_paywall.py
Normal 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))
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user