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:
@@ -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