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

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