Subjects per grade (math, calculus, geometry, stats, discrete), 5 new textbooks, 202 new islands, long islands split, hand-written figures and lesson texts for the new books

- content.Subject + Unit.subject, /api/home?subject=, tutor grounded in the chosen book
- 5 new books ingested; ~1150 hand-written questions, islands named by hand
- node_split: islands over 7 lessons split into parts of <=6 (lesson ids and progress kept)
- Lesson.figures hand-authored and visually checked for 653 lessons of the new books;
  gen_lesson_figures got --subject and survives network errors
- seed files re-exported

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 05:35:18 +03:30
parent 3d51b3b83a
commit 8cb1ecb85e
32 changed files with 81218 additions and 4486 deletions

View File

@@ -26,7 +26,7 @@ from .services import (
playable_lesson_ids, quiz_completed_node_ids, verified_correct_count,
)
from .models import (
Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, UserAnswer,
Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, Subject, UserAnswer,
UserLessonCompletion, UserNodeCompletion,
)
from .serializers import (
@@ -88,6 +88,29 @@ def _grades_payload() -> dict:
return cache.get_or_set(key, build, _CACHE_TTL)
def _subjects_payload() -> dict:
"""{key: {key,title,icon,color,order}} — کلِ جدولِ کتاب‌ها (چند ردیف)، کش‌شده با نسخه‌ی برنامه‌ی درسی."""
key = f'content:subjects:v{curriculum_version()}'
def build():
return {
s.key: {'key': s.key, 'title': s.title, 'icon': s.icon, 'color': s.color, 'order': s.order}
for s in Subject.objects.all()
}
return cache.get_or_set(key, build, _CACHE_TTL)
def grade_subjects(tree: list) -> list[dict]:
"""کتاب‌هایی که این پایه فصل دارد، به ترتیبِ `Subject.order`."""
table = _subjects_payload()
keys = []
for unit in tree:
if unit['subject'] not in keys:
keys.append(unit['subject'])
return sorted((table[k] for k in keys), key=lambda s: s['order'])
# پالت و آیکونِ پیش‌فرضِ کارتِ مبحث — هر دو از خودِ اپ گرفته شده‌اند
# (رنگ‌های موجود در طرح، و آیکون‌هایی که از lucide وارد می‌شوند)، تا تا وقتی
# کسی از پنلِ ادمین مقدارِ دلخواه نگذاشته، کارت‌ها بی‌رنگ نمانند.
@@ -101,24 +124,24 @@ _NODE_ICONS = ['Grid3x3', 'TrendingUp', 'LineChart', 'Sigma', 'Compass', 'Repeat
def _grade_tree(grade_id: int):
"""اسکلتِ گریدِ [{id,title,nodes:[{id,title,lesson_ids}]}] — بدونِ status/دیتای کاربر.
None یعنی گریدِ ناموجود؛ [] یعنی گریدِ موجودِ بدونِ واحد."""
# tree2: هر node حالا `playable_ids` هم دارد؛ کلیدِ ساختارِ قبلی باطل شد.
# tree3: هر فصل حالا `subject` (کلیدِ کتاب) دارد؛ tree2 (و پیش از آن، playable_ids) باطل شد.
# پایه‌ی ناموجود کش نمی‌شود: gradeId از query string می‌آید و هر عددِ تازه یک کلیدِ تازه
# می‌ساخت. LocMemCache (تا CACHE_URL نیست) بعد از ۳۰۰ کلید یک‌سومِ کهنه‌ها را دور می‌ریزد و
# شمارنده‌های throttle (سقفِ ورودِ ادمین، OTP بر اساسِ IP) هم در همین کش‌اند؛ با چند صد
# gradeIdِ ساختگی سقفِ ورودِ ادمین صفر می‌شد (بازتولید شد، tests_security_review).
if grade_id not in {g['id'] for g in _grades_payload()['grades']}:
return None
key = f'content:tree2:grade:{grade_id}:v{curriculum_version()}'
key = f'content:tree3:grade:{grade_id}:v{curriculum_version()}'
cached = cache.get(key)
if cached is not None:
return cached
units = (
Grade.objects.get(pk=grade_id)
.units.prefetch_related(Prefetch(
.units.select_related('subject').prefetch_related(Prefetch(
'nodes__lessons',
queryset=Lesson.objects.annotate(question_total=Count('questions')),
)).all()
)).order_by('subject__order', 'subject_id', 'order', 'id')
)
tree = []
position = 0 # شمارنده‌ی سراسریِ مبحث در این پایه، برای چرخشِ رنگ
@@ -140,6 +163,7 @@ def _grade_tree(grade_id: int):
position += 1
tree.append({
'id': str(unit.id),
'subject': unit.subject.key,
'title': unit.title,
'blurb': unit.blurb,
'nodes': nodes,
@@ -275,12 +299,19 @@ def node_is_unlocked(user_id, node) -> bool:
def unit_is_free(unit_id: int, grade_id: int) -> bool:
"""
فصلِ رایگان: فقط اولین فصلِ هر پایه (به ترتیبِ `Unit.order`، همان ترتیبِ نقشه). بقیه‌ی فصل‌ها
اشتراکِ زمانی می‌خواهند (تصمیمِ سینیور و مدیرعامل، ۲۰۲۶-۱۰-۱۰: «هر کاربر یک درس را ببرد جلو،
فصلِ رایگان: فقط اولین فصلِ هر کتابِ هر پایه (به ترتیبِ `Unit.order`، همان ترتیبِ نقشه).
پیش از افزوده‌شدنِ کتاب‌های هندسه/آمار/گسسته (۲۰۲۶-۱۰-۱۱) هر پایه یک کتاب داشت و «اولین فصلِ پایه»
همین بود؛ حالا هر کتاب فصلِ اولش را رایگان دارد تا دانش‌آموزِ دوازدهم هر سه کتاب را بچشد. این
تعبیر را ما از «یک فصل رایگان» کردیم و سینیور تأیید نکرده؛ اگر نخواست، شرطِ بالا را به
`tree[0]` برگردانید. بقیه‌ی فصل‌ها اشتراکِ زمانی می‌خواهند (تصمیمِ سینیور و مدیرعامل، ۲۰۲۶-۱۰-۱۰: «هر کاربر یک درس را ببرد جلو،
برای بقیه اشتراک بخرد»؛ مالکِ پروژه «درس» را «فصل» گرفت).
"""
tree = _grade_tree(grade_id) or []
return bool(tree) and tree[0]['id'] == str(unit_id)
unit = next((u for u in tree if u['id'] == str(unit_id)), None)
if unit is None:
return False
first_of_subject = next(u for u in tree if u['subject'] == unit['subject'])
return first_of_subject['id'] == str(unit_id)
def has_unit_access(user_id, unit_id: int, grade_id: int) -> bool:
@@ -340,6 +371,9 @@ class HomeView(APIView):
OpenApiParameter('gradeId', OpenApiTypes.INT, required=False,
description='پایه‌ای که نقشه‌اش خواسته می‌شود؛ '
'بدونِ آن، پایه‌ی ذخیره‌شده‌ی خودِ کاربر.'),
OpenApiParameter('subject', OpenApiTypes.STR, required=False,
description='کلیدِ کتاب (`subjects[].key` در همین پاسخ). بدونِ آن اولین '
'کتابِ پایه؛ کتابِ ناموجود در این پایه ۴۰۰.'),
],
responses=HomeResponseSerializer,
tags=['Curriculum (content)'],
@@ -366,6 +400,9 @@ class HomeView(APIView):
grade_id = request.user.grade_id
tree = []
subjects = []
active_subject = None
subject_param = request.query_params.get('subject')
if grade_id:
tree = _grade_tree(grade_id)
if tree is None:
@@ -375,6 +412,18 @@ class HomeView(APIView):
if grade_param:
raise Http404
tree = []
else:
# یک پایه چند کتاب دارد (دوازدهم: حسابان، هندسه، گسسته). بی‌`subject` اولین کتابِ
# پایه (به ترتیبِ `Subject.order`) باز می‌شود؛ فرانت آخرین انتخابِ کاربر را خودش می‌فرستد.
# کلیدِ نامعتبر یا کتابی که این پایه ندارد ۴۰۰ است، نه نقشه‌ی پایه‌ی خودِ کاربر.
subjects = grade_subjects(tree)
if subject_param:
if subject_param not in {s['key'] for s in subjects}:
raise ValidationError({'subject': 'این پایه چنین کتابی ندارد.'})
active_subject = subject_param
else:
active_subject = subjects[0]['key'] if subjects else None
tree = [u for u in tree if u['subject'] == active_subject]
# همه‌ی lesson_idهای کامل‌شده‌ی این کاربر در همین گرید — یک کوئری،
# مستقل از تعدادِ node (رفعِ N+1).
@@ -466,7 +515,12 @@ class HomeView(APIView):
'coins': stats.coins if stats else 0,
}
data = {'units': units_payload, 'stats': stats_payload}
data = {
'subject': next((s for s in subjects if s['key'] == active_subject), None) if subjects else None,
'subjects': subjects,
'units': units_payload,
'stats': stats_payload,
}
# خودِ dict، نه HomeResponseSerializer(data).data: سریال‌سازِ تو در توی DRF برای ~۳۰ جزیره حدودِ ۱٫۶
# میلی‌ثانیه از ~۸٫۶ِ این پرترافیک‌ترین صفحه بود (پروفایلِ ۲۰۲۶-۱۰-۱۱) و چیزی را تبدیل نمی‌کرد.
# سریال‌ساز همچنان قراردادِ Swagger است؛ HomeMatchesItsSerializerTests برابریِ این دو را نگه می‌دارد.