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:
@@ -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 برابریِ این دو را نگه میدارد.
|
||||
|
||||
Reference in New Issue
Block a user