serve what the frontend screens actually need

صفحه‌های فرانت خوانده شدند و با API مقایسه شدند. ساختارِ ما از کتابِ واقعی
درآمده و درست‌تر است، پس مدل عوض نشد — ولی چند چیز کم داشت.

مهم‌ترینش یک باگ بود: /api/home پارامتر را `grade` می‌خواند و کلاینتی که به
فرانت فرستادیم `gradeId` می‌فرستد. پارامترِ ناشناخته بی‌صدا نادیده گرفته
می‌شد، یعنی کاربر پایه‌ای را انتخاب می‌کرد و نقشه‌ی پایه‌ی دیگری می‌دید
بدونِ هیچ خطایی. حالا هر دو نام پذیرفته می‌شوند (`gradeId` نامِ درست است،
با بقیه‌ی API یکی). تستِ سرتاسریِ قبلی این را نگرفته بود چون home را بدونِ
آرگومان صدا زده بود.

/api/grades حالا lessonCount می‌دهد. فهرستِ پایه‌های فرانت تا دوازدهم است
ولی محتوای ما تا نهم؛ بدونِ این عدد، کاربر پایه‌ی خالی را انتخاب می‌کند و
صفحه‌ی خالی می‌بیند بی‌آنکه بفهمد چرا.

GET /api/nodes/{id}/lessons اضافه شد. /api/home فقط شناسه‌ی درس‌ها را
می‌دهد، ولی نقشه‌ی مهارت برای هر قدم عنوان، نوع، زیرنویس و «انجام شد» لازم
دارد — بدونش فرانت باید برای هر درس یک intro جدا می‌گرفت.

Lesson حالا kind (آزمون/ویدیو) و متنِ سه‌تکه دارد: توضیح، مثال، حلِ
گام‌به‌گام. ویدیو از قبل سوالِ بازِ محصول بود و فرانت آیتمش را دارد؛ حالا
API می‌تواند بیانش کند، حتی اگر محتوایش بعداً بیاید. Unit یک blurb گرفت و
Node آیکون و رنگ، تا فرانت رنگ‌ها را هاردکد نکند. هیچ‌کدام اجباری نیست، پس
محتوای موجود دست‌نخورده کار می‌کند.

PATCH /api/user/profile حالا age را هم می‌پذیرد (صفحه‌ی ثبت‌نامِ فرانت سن
می‌پرسد). ذخیره‌سازی همچنان birthYear است چون کهنه نمی‌شود؛ این فقط ورودی
را ترجمه می‌کند تا دو طرف حسابِ متفاوت درنیاورند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 01:42:24 +03:30
parent 8effe5b60b
commit 99a0cafe24
7 changed files with 386 additions and 7 deletions

View File

@@ -1,5 +1,6 @@
from django.core.cache import cache
from django.db import IntegrityError, transaction
from django.db.models import Count
from django.http import Http404
from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes
@@ -15,7 +16,7 @@ from progress.models import UserHearts, UserStats
from subscription.services import has_unlimited_hearts
from .models import (
Choice, Grade, Lesson, NodeProgress, Question, QuestionReport, UserAnswer,
Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, UserAnswer,
UserLessonCompletion,
)
from .serializers import (
@@ -27,6 +28,7 @@ from .serializers import (
LessonCompleteResponseSerializer,
LessonIntroResponseSerializer,
LessonQuestionsResponseSerializer,
NodeLessonsResponseSerializer,
NodeProgressListResponseSerializer,
QuestionReportRequestSerializer,
QuestionReportResponseSerializer,
@@ -78,9 +80,15 @@ def _grades_payload() -> dict:
key = f'content:grades:v{curriculum_version()}'
def build():
# lessonCount در یک کوئری می‌آید؛ بدونش فرانت نمی‌فهمد کدام پایه
# هنوز خالی است و کاربر صفحه‌ی خالی می‌بیند.
grades = Grade.objects.annotate(
_lessons=Count('units__nodes__lessons', distinct=True),
).order_by('id')
return {'grades': [
{'id': g.id, 'title': g.title, 'icon': g.icon, 'color': g.color}
for g in Grade.objects.all().order_by('id')
{'id': g.id, 'title': g.title, 'icon': g.icon, 'color': g.color,
'lessonCount': g._lessons}
for g in grades
]}
return cache.get_or_set(key, build, _CACHE_TTL)
@@ -106,10 +114,13 @@ def _grade_tree(grade_id: int):
{
'id': str(unit.id),
'title': unit.title,
'blurb': unit.blurb,
'nodes': [
{
'id': str(node.id),
'title': node.title,
'icon': node.icon,
'color': node.color,
'lesson_ids': [lsn.id for lsn in node.lessons.all()],
}
for node in unit.nodes.all()
@@ -153,7 +164,11 @@ class HomeView(APIView):
@extend_schema(
parameters=[
OpenApiParameter('grade', OpenApiTypes.INT, required=False),
OpenApiParameter('gradeId', OpenApiTypes.INT, required=False,
description='پایه‌ای که نقشه‌اش خواسته می‌شود؛ '
'بدونِ آن، پایه‌ی ذخیره‌شده‌ی خودِ کاربر.'),
OpenApiParameter('grade', OpenApiTypes.INT, required=False,
deprecated=True, description='نامِ قدیمیِ gradeId.'),
],
responses=HomeResponseSerializer,
tags=['Curriculum (content)'],
@@ -165,7 +180,13 @@ class HomeView(APIView):
# خودِ کاربر نادیده می‌ماند؛ نتیجه این بود که اپ در «آشنایی» پایه را ثبت
# می‌کرد و بعد صفحه‌ی خانه خالی برمی‌گشت. حالا پارامتر اولویت دارد (برای
# مرورِ پایه‌ی دیگر) و در نبودش پایه‌ی خودِ کاربر ملاک است.
grade_param = request.query_params.get('grade')
# هر دو نام پذیرفته می‌شوند. نامِ درست `gradeId` است — با بقیه‌ی API
# یکی است (profile و /user/grade هم gradeId می‌گویند) — ولی نسخه‌ی
# اولِ این اندپوینت `grade` بود و کلاینت‌هایی با همان بیرون رفته‌اند.
# پذیرفتنِ هر دو ارزان است؛ نپذیرفتن یعنی انتخابِ پایه بی‌صدا نادیده
# گرفته می‌شود و کاربر نقشه‌ی پایه‌ی دیگری را می‌بیند.
grade_param = (request.query_params.get('gradeId')
or request.query_params.get('grade'))
if grade_param:
try:
grade_id = int(grade_param)
@@ -207,10 +228,17 @@ class HomeView(APIView):
node_payload.append({
'id': node['id'],
'title': node['title'],
'icon': node.get('icon', ''),
'color': node.get('color', ''),
'status': node_status,
'lessonIds': node['lesson_ids'],
})
units_payload.append({'id': unit['id'], 'title': unit['title'], 'nodes': node_payload})
units_payload.append({
'id': unit['id'],
'title': unit['title'],
'blurb': unit.get('blurb', ''),
'nodes': node_payload,
})
stats = UserStats.objects.filter(user_id=user_id).first()
stats_payload = {
@@ -232,7 +260,12 @@ class LessonIntroView(APIView):
data = {
'lessonId': str(lesson.id),
'title': lesson.title,
'kind': lesson.kind,
'introText': lesson.intro_text,
'example': lesson.example,
'steps': lesson.steps or [],
'videoUrl': lesson.video_url,
'durationMinutes': lesson.duration_minutes,
'mascotAnimation': lesson.mascot_animation,
'questionCount': lesson.questions.count(),
}
@@ -589,3 +622,53 @@ class QuestionReportView(APIView):
}).data,
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
)
class NodeLessonsView(APIView):
"""
GET /api/nodes/{id}/lessons — قدم‌های یک مبحث برای نقشه‌ی مهارت.
چرا لازم بود: /api/home کلِ درخت را می‌دهد ولی فقط شناسه‌ی درس‌ها را؛
صفحه‌ی نقشه‌ی مهارت برای هر قدم عنوان، نوع (آزمون/ویدیو)، زیرنویس و
«انجام شد یا نه» می‌خواهد. بدونِ این، فرانت باید برای هر درس یک
درخواستِ intro جدا می‌زد — یعنی ده‌ها درخواست برای یک صفحه.
"""
throttle_scope = 'read'
throttle_classes = [GuestAwareContentThrottle]
@extend_schema(responses=NodeLessonsResponseSerializer, tags=['Curriculum (content)'])
def get(self, request, node_id: int):
node = get_object_or_404(
Node.objects.prefetch_related('lessons__questions'), pk=node_id,
)
completed = set()
user_id = getattr(request.user, 'id', None)
if user_id is not None:
completed = set(
UserLessonCompletion.objects
.filter(user_id=user_id, lesson__node_id=node_id)
.values_list('lesson_id', flat=True)
)
lessons = []
for lesson in node.lessons.all():
if lesson.kind == 'video':
meta = f'{lesson.duration_minutes} دقیقه' if lesson.duration_minutes else ''
else:
count = lesson.questions.count()
meta = f'{count} سوال' if count else ''
lessons.append({
'id': str(lesson.id),
'kind': lesson.kind,
'title': lesson.title,
'meta': meta,
'done': lesson.id in completed,
})
return Response(NodeLessonsResponseSerializer({
'nodeId': str(node.id),
'title': node.title,
'lessons': lessons,
}).data)