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

@@ -15,8 +15,8 @@ from .curriculum_io import parse as parse_curriculum
from .question_io import ImportError_, export_questions, import_questions, parse
from .models import (
Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, Unit, UserAnswer,
UserLessonCompletion,
Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, Subject, Unit,
UserAnswer, UserLessonCompletion,
)
@@ -84,10 +84,18 @@ class GradeAdmin(admin.ModelAdmin):
})
@admin.register(Subject)
class SubjectAdmin(admin.ModelAdmin):
"""کتاب‌های ریاضی (ریاضی، حسابان، هندسه، …) که نوارِ جابه‌جاییِ اپ از آن‌ها ساخته می‌شود."""
list_display = ('order', 'title', 'key', 'icon', 'color')
ordering = ('order',)
@admin.register(Unit)
class UnitAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'grade', 'order')
list_filter = ('grade',)
list_display = ('id', 'title', 'grade', 'subject', 'order')
list_filter = ('grade', 'subject')
# فیلترِ «فصل» در صفحه‌ی سوال‌ها هم از همین ترتیب می‌آید: پایه‌به‌پایه، نه
# همه‌ی «فصل ۱»ها پشتِ هم.
ordering = ('grade__number', 'grade_id', 'order')

View File

@@ -102,6 +102,8 @@ def parse(raw: bytes) -> list[dict]:
})
clean_units.append({
# کتاب صریح است: فایلِ بی‌کتاب رد می‌شود تا فصلِ حسابان بی‌صدا «ریاضی» نشود.
'subject': _text(unit.get('subject'), 'subject', uwhere),
'title': _text(unit.get('title'), 'title', uwhere),
'blurb': str(unit.get('blurb') or '')[:120],
'order': int(unit.get('order') or ui + 1),
@@ -117,6 +119,16 @@ def parse(raw: bytes) -> list[dict]:
return cleaned
def _subject_or_error(title: str):
"""کتابِ فایل باید از قبل در جدولِ Subject باشد (ادمین ← کتاب‌ها)؛ نامِ ناشناخته خطا است، نه ساختنِ بی‌صدا."""
from .models import Subject
subject = Subject.objects.filter(title=title).first()
if subject is None:
known = '، '.join(Subject.objects.values_list('title', flat=True))
raise CurriculumError(f'کتابِ «{title}» در جدولِ کتاب‌ها نیست. موجود: {known}')
return subject
@transaction.atomic
def import_curriculum(grades: list[dict]) -> dict:
"""
@@ -135,9 +147,10 @@ def import_curriculum(grades: list[dict]) -> dict:
counts['grades'] += 1 if created else 0
for unit_row in grade_row['units']:
subject = _subject_or_error(unit_row['subject'])
unit, created = Unit.objects.get_or_create(
grade=grade, title=unit_row['title'],
defaults={'order': unit_row['order']},
defaults={'order': unit_row['order'], 'subject': subject},
)
counts['units'] += 1 if created else 0
# متنِ ادمین برنده است: فقط خانه‌ی خالی از فایل پر می‌شود
@@ -185,13 +198,14 @@ def import_curriculum(grades: list[dict]) -> dict:
def export_curriculum(grades_queryset) -> dict:
"""خروجی دقیقاً همان قالبی‌ست که import می‌خواند."""
out = []
for grade in grades_queryset.prefetch_related('units__nodes__lessons'):
for grade in grades_queryset.prefetch_related('units__subject', 'units__nodes__lessons'):
out.append({
'title': grade.title,
'icon': grade.icon,
'color': grade.color,
'units': [
{
'subject': unit.subject.title,
'title': unit.title,
'order': unit.order,
**({'blurb': unit.blurb} if unit.blurb else {}),

View File

@@ -124,7 +124,7 @@ class Command(BaseCommand):
continue
finder._book = book
collection = build_collection_name(book.subject, book.grade)
lessons = (Lesson.objects.filter(node__unit__grade=grade)
lessons = (Lesson.objects.filter(node__unit__grade=grade, node__unit__subject__title=book.subject)
.select_related('node__unit').order_by('node__unit__order', 'node__order', 'id'))
for lesson in lessons:
if lesson.example.strip() or lesson.steps:

View File

@@ -24,6 +24,7 @@ import threading
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import openai
from django.core.management.base import BaseCommand, CommandError
from content.models import Lesson
@@ -109,6 +110,7 @@ class Command(BaseCommand):
def add_arguments(self, p):
p.add_argument('--grade', type=int, help='بدونِ آن همه‌ی پایه‌ها')
p.add_argument('--subject', help='عنوانِ کتاب (مثلاً هندسه)؛ بدونِ آن همه‌ی کتاب‌ها')
p.add_argument('--limit', type=int, default=4)
p.add_argument('--lesson', type=int, help='فقط همین شناسه')
p.add_argument('--redo', action='store_true', help='درس‌هایی که شکل دارند هم')
@@ -128,6 +130,8 @@ class Command(BaseCommand):
'node__unit__grade__number', 'node__unit__order', 'node__order', 'id')
if o['grade']:
qs = qs.filter(node__unit__grade__number=o['grade'])
if o['subject']:
qs = qs.filter(node__unit__subject__title=o['subject'])
if o['lesson']:
qs = qs.filter(pk=o['lesson'])
if not o['redo']:
@@ -179,7 +183,8 @@ class Command(BaseCommand):
return
try:
items = _json_list(ask(PROMPT + spec, text))
except (ValueError, KeyError) as e:
# خطای شبکه/درگاه یک درس را رد می‌کند، نه کلِ اجرا را؛ در ledger ثبت نمی‌شود تا دفعه‌ی بعد دوباره برود.
except (ValueError, KeyError, openai.OpenAIError) as e:
with lock:
stats['invalid'] += 1
self.stderr.write(f'درس {lesson.id}: خروجیِ نامعتبر ({e}); ذخیره نشد.')
@@ -218,7 +223,7 @@ class Command(BaseCommand):
ok = {v.get('i'): v for v in verdicts if isinstance(v, dict)}
kept = [f for i, f in enumerate(figures) if ok.get(i, {}).get('ok') is True]
notes = [f"{i}: {v.get('why', '')}" for i, v in ok.items() if v.get('ok') is not True]
except (ValueError, KeyError, AttributeError) as e:
except (ValueError, KeyError, AttributeError, openai.OpenAIError) as e:
kept, notes = [], [f'وارسی ناموفق: {e}']
with lock:
stats['rejected'] += len(figures) - len(kept)

View File

@@ -298,7 +298,7 @@ class Command(BaseCommand):
"""[(lesson, chunk), ...] — فقط درس‌هایی که هنوز سوال کم دارند."""
lessons = (
Lesson.objects
.filter(node__unit__grade=grade)
.filter(node__unit__grade=grade, node__unit__subject__title=self._book.subject)
.select_related('node__unit')
.order_by('node__unit__order', 'node__order', 'id')
)

View File

@@ -21,7 +21,7 @@
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from content.models import Grade, Lesson, Node, Unit
from content.models import Grade, Lesson, Node, Subject, Unit
from rag.models import LessonChunk
from rag.services.config import build_collection_name
from rag.services.textbooks import TEXTBOOKS, get_textbook
@@ -179,11 +179,17 @@ class Command(BaseCommand):
title=grade_title, defaults={'icon': icon, 'color': color},
)
# کتاب باید در جدولِ Subject باشد (migrationِ 0015 پنج‌تایی را می‌سازد؛ کتابِ تازه از ادمین).
subject = Subject.objects.filter(title=book.subject).first()
if subject is None:
raise CommandError(
f'کتابِ «{book.subject}» در جدولِ Subject نیست؛ از ادمین (محتوا ← کتاب‌ها) بسازید.'
)
counts = {'units': 0, 'nodes': 0, 'lessons': 0}
for chapter, groups in plan:
unit, _ = Unit.objects.update_or_create(
grade=grade, title=chapter.title,
defaults={'order': chapter.number},
defaults={'order': chapter.number, 'subject': subject},
)
counts['units'] += 1

View File

@@ -0,0 +1,36 @@
"""
جزیره‌های بلند را به جزیره‌های حداکثر ۶ درسی می‌شکند (منطق در `content/node_split.py`).
python manage.py split_long_nodes --grade 8 --dry-run
python manage.py split_long_nodes --grade 4 --grade 5
پایه‌های ۳ تا ۱۲ را migrationِ 0018 یک بار شکسته است؛ این دستور برای پایه‌ی تازه یا آستانه‌ی دیگر است و اجرای
دوباره‌اش بی‌اثر. بعد از اجرا فایل‌های seed را دوباره بسازید (`seed_content --export`)، وگرنه
`seed_content` جزیره‌های بلندِ قدیمی را کنارِ بخش‌های تازه دوباره می‌سازد.
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from content.models import Lesson, Node, NodeProgress, Unit, UserNodeCompletion
from content.node_split import split_long_nodes
class Command(BaseCommand):
help = 'شکستنِ جزیره‌های بلند به جزیره‌های کوتاه‌تر، با حفظِ پیشرفتِ کاربران.'
def add_arguments(self, parser):
parser.add_argument('--grade', type=int, action='append', required=True,
help='شماره‌ی پایه؛ چند بار قابلِ تکرار.')
parser.add_argument('--max-lessons', type=int, default=7)
parser.add_argument('--part-cap', type=int, default=6)
parser.add_argument('--dry-run', action='store_true')
def handle(self, *args, **opts):
units = Unit.objects.filter(grade__number__in=opts['grade']).order_by('id')
with transaction.atomic():
counts = split_long_nodes(
Node=Node, Lesson=Lesson, UserNodeCompletion=UserNodeCompletion,
NodeProgress=NodeProgress, units=units,
max_lessons=opts['max_lessons'], part_cap=opts['part_cap'], dry_run=opts['dry_run'],
)
self.stdout.write(f"{'(dry-run) ' if opts['dry_run'] else ''}{counts}")

View File

@@ -0,0 +1,23 @@
from django.db import migrations, models
class Migration(migrations.Migration):
"""مرحله‌ی ۱ از ۴ (جدولِ کتاب‌ها). چهار مرحله‌ی جدا چون PostgreSQL اجازه نمی‌دهد در یک تراکنش
هم داده نوشت و هم همان جدول را ALTER کرد (pending trigger events)."""
dependencies = [('content', '0013_alter_userlessoncompletion_created_at_and_more')]
operations = [
migrations.CreateModel(
name='Subject',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.SlugField(max_length=30, unique=True)),
('title', models.CharField(max_length=50, unique=True)),
('icon', models.CharField(blank=True, max_length=100)),
('color', models.CharField(blank=True, max_length=10)),
('order', models.PositiveSmallIntegerField(default=0)),
],
options={'ordering': ['order', 'id']},
),
]

View File

@@ -0,0 +1,24 @@
from django.db import migrations
# رنگ‌ها جدا از رنگِ پایه‌ها (Grade.color) و از هم‌اند تا دکمه‌های نوارِ کتاب‌ها در نقشه
# به‌چشم یک مسیرِ جدا بیایند. آیکون‌ها نامِ lucide‌اند (همان فهرستی که نقشه می‌شناسد).
SUBJECTS = [
('math', 'ریاضی', 'Sigma', '#ED6956', 1),
('calculus', 'حسابان', 'LineChart', '#00BCD4', 2),
('geometry', 'هندسه', 'Compass', '#9B7BFF', 3),
('stats', 'آمار و احتمال', 'BarChart3', '#FF7A3D', 4),
('discrete', 'گسسته', 'Network', '#5FBF8F', 5),
]
def create_subjects(apps, schema_editor):
Subject = apps.get_model('content', 'Subject')
for key, title, icon, color, order in SUBJECTS:
Subject.objects.get_or_create(
key=key, defaults={'title': title, 'icon': icon, 'color': color, 'order': order},
)
class Migration(migrations.Migration):
dependencies = [('content', '0014_subject')]
operations = [migrations.RunPython(create_subjects, migrations.RunPython.noop)]

View File

@@ -0,0 +1,20 @@
import django.db.models.deletion
from django.db import migrations, models
import content.models
class Migration(migrations.Migration):
dependencies = [('content', '0015_seed_subjects')]
operations = [
migrations.AddField(
model_name='unit',
name='subject',
field=models.ForeignKey(
default=content.models.default_subject_pk,
on_delete=django.db.models.deletion.PROTECT,
related_name='units', to='content.subject',
),
),
]

View File

@@ -0,0 +1,19 @@
from django.db import migrations
def mark_calculus(apps, schema_editor):
"""
فصل‌های پایه‌ی یازدهم و دوازدهمِ موجود از کتابِ «حسابان»اند (`CALCULUS_11/12`)، نه ریاضیِ عمومی.
۰۰۱۶ همه را «ریاضی» گذاشت؛ اینجا درست می‌شود. پایه از `number` شناخته می‌شود، نه از id.
بررسی‌نشده: دیتابیسی که در آن فصلِ یازدهم/دوازدهم از کتابی جز حسابان آمده باشد (روی سرور و
لپ‌تاپ نیست؛ هیچ کتابِ دیگری برای آن پایه‌ها پیش از این migration ingest نشده بود).
"""
Subject = apps.get_model('content', 'Subject')
Unit = apps.get_model('content', 'Unit')
calculus = Subject.objects.get(key='calculus')
Unit.objects.filter(grade__number__in=(11, 12)).update(subject=calculus)
class Migration(migrations.Migration):
dependencies = [('content', '0016_unit_subject')]
operations = [migrations.RunPython(mark_calculus, migrations.RunPython.noop)]

View File

@@ -0,0 +1,25 @@
from django.db import migrations
from content.node_split import split_long_nodes
def split(apps, schema_editor):
"""
جزیره‌های بلندِ (بیش از ۷ درس) پایه‌های سوم تا دوازدهم را به جزیره‌های حداکثر ۶ درسی می‌شکند (`content/node_split.py`).
روی دیتابیسِ خالی (تست، سرورِ تازه) هیچ کاری نمی‌کند؛ آنجا `seed_content` از فایلِ بازساخته‌شده می‌سازد.
"""
Unit = apps.get_model('content', 'Unit')
stats = split_long_nodes(
Node=apps.get_model('content', 'Node'),
Lesson=apps.get_model('content', 'Lesson'),
UserNodeCompletion=apps.get_model('content', 'UserNodeCompletion'),
NodeProgress=apps.get_model('content', 'NodeProgress'),
units=Unit.objects.filter(grade__number__in=range(3, 13)).order_by('id'),
)
if stats['nodes_split']:
print(f'\n split_long_nodes: {stats}')
class Migration(migrations.Migration):
dependencies = [('content', '0017_calculus_units')]
operations = [migrations.RunPython(split, migrations.RunPython.noop)]

View File

@@ -48,8 +48,53 @@ class Grade(models.Model):
return self.title
class Subject(models.Model):
"""
کتابِ درسیِ یک پایه: «ریاضی»، «حسابان»، «هندسه»، «آمار و احتمال»، «گسسته».
چرا جدا از Grade: از دهم به بعد یک پایه چند کتابِ ریاضی دارد (دوازدهم: حسابان +
هندسه + گسسته) و دانش‌آموز بینشان جابه‌جا می‌شود. `title` برابرِ `subject`ِ
کتاب در `rag/services/textbooks.py` است (همان رشته‌ای که در نامِ collectionِ
Qdrant می‌آید)؛ ثبت‌نامِ کتابِ تازه یک ردیفِ اینجا هم می‌خواهد.
"""
key = models.SlugField(max_length=30, unique=True)
title = models.CharField(max_length=50, unique=True)
icon = models.CharField(max_length=100, blank=True)
color = models.CharField(max_length=10, blank=True)
# ترتیبِ دکمه‌ها در نوارِ کتاب‌ها؛ کتابِ اولِ هر پایه همان است که بی‌انتخاب باز می‌شود.
order = models.PositiveSmallIntegerField(default=0)
class Meta:
ordering = ['order', 'id']
def __str__(self):
return self.title
DEFAULT_SUBJECT_KEY = 'math'
def default_subject_pk():
"""
کتابِ پیش‌فرضِ یک فصل: «ریاضی» (کتابِ عمومیِ همه‌ی پایه‌ها).
انتخابِ محصولی است، نه «محض احتیاط»: تا پیش از وجودِ Subject همه‌ی فصل‌ها
ریاضی بودند. مسیرهای سیدِ برنامه‌ی درسی کتاب را صریح می‌دهند و فایلِ
ناقص را رد می‌کنند (`curriculum_io.parse`)؛ فقط ساختنِ دستیِ Unit بی‌کتاب
(ادمین، تست) به اینجا می‌رسد. ردیفِ ریاضی را migrationِ ۰۰۱۵ می‌سازد؛
get_or_create فقط دیتابیسی را پوشش می‌دهد که تستِ TransactionTestCase پاکش کرده.
"""
return Subject.objects.get_or_create(
key=DEFAULT_SUBJECT_KEY, defaults={'title': 'ریاضی', 'order': 1},
)[0].pk
class Unit(models.Model):
grade = models.ForeignKey(Grade, on_delete=models.CASCADE, related_name='units')
subject = models.ForeignKey(
Subject, on_delete=models.PROTECT, related_name='units', default=default_subject_pk,
)
title = models.CharField(max_length=100)
# یک جمله‌ی کوتاه زیرِ عنوانِ فصل در صفحه‌ی دسته‌بندیِ درس‌ها.
blurb = models.CharField(max_length=120, blank=True)

113
content/node_split.py Normal file
View File

@@ -0,0 +1,113 @@
"""
شکستنِ جزیره‌های بلند به جزیره‌های حداکثر ۶ درسی (۲۰۲۶-۱۰-۱۲؛ نخست ۱۰ بود، به خواستِ مالک: «جزیره‌ها زود تموم می‌شن، صدها جزیره»).
چرا: `seed_curriculum_from_textbook` هر «درسِ فهرستِ کتاب» را یک جزیره می‌کند. حسابانِ دوازدهم
فقط ۱۲ جزیره دارد با میانگینِ ۲۷ و حداکثرِ ۴۳ نوعِ مسئله، در حالی‌که پایه‌های دیگر ۲۸ تا ۵۳ جزیره‌ی
۸ تا ۱۰ درسی دارند. حجمِ سوال یکی بود، ولی دانش‌آموز «تعداد جزیره» را می‌بیند و هر جزیره‌ی بلند فقط
یک آزمونِ چهارسوالی می‌دهد (سینیور درست می‌گفت: کم است).
مهم‌ترین قیدها:
- شناسه‌ی درس‌ها (Lesson.id) و سوال‌ها دست نمی‌خورد؛ فقط `Lesson.node` عوض می‌شود. پس
UserLessonCompletion/UserAnswer همه‌جا درست می‌ماند.
- جزیره‌ی اصلی بخشِ اول می‌ماند (همان id)، بخش‌های بعدی جزیره‌ی تازه‌اند.
- کسی که جزیره‌ی اصلی را با آزمون تمام کرده بود، همان را برای بخش‌های تازه هم تمام‌شده دارد
(ردیفِ UserNodeCompletion/NodeProgress با xp و سکه‌ی صفر و زمانِ اصلی؛ XP دوبار شمرده نشود و
لیدربورد/گزارشِ هفتگی تغییر نکند). بدونِ این، پیشرفتِ کاربرانِ فعلی عقب می‌رفت.
- ایدمپوتنت: جزیره‌ای که از قبل کوتاه است (≤ max_lessons) دست نمی‌خورد، پس اجرای دوباره
چیزی را دوباره نمی‌شکند.
نسنجیده: اجرا روی دیتابیسی که کاربرِ واقعی در وسطِ آزمونِ یک جزیره‌ی بلند دارد (آزمون ۴ سوالِ
ثابت با seed `nodeId:userId` است؛ شناسه‌ی جزیره‌ی بخشِ اول همان است، پس آزمونِ نیمه‌کاره‌اش
فقط ممکن است با درس‌های کمتر ساخته شود).
"""
import math
_FA = str.maketrans('0123456789', '۰۱۲۳۴۵۶۷۸۹')
def part_count(n_lessons: int, max_lessons: int, part_cap: int) -> int:
"""جزیره‌ی تا max_lessons درس دست نمی‌خورد؛ بلندتر به کمترین تعدادی تکه می‌شود که هیچ‌کدام از part_cap بزرگ‌تر نشود."""
if n_lessons <= max_lessons:
return 1
return max(2, math.ceil(n_lessons / part_cap))
def balanced_chunks(items: list, parts: int) -> list[list]:
"""parts تکه‌ی پشتِ‌هم با اندازه‌های حداکثر یک واحد اختلاف (اولی‌ها بزرگ‌تر)."""
size, extra = divmod(len(items), parts)
out, start = [], 0
for i in range(parts):
end = start + size + (1 if i < extra else 0)
out.append(items[start:end])
start = end
return out
def part_title(base: str, index: int) -> str:
return f'{base} ({str(index).translate(_FA)})'
def split_long_nodes(*, Node, Lesson, UserNodeCompletion, NodeProgress, units, max_lessons=7, part_cap=6,
dry_run=False) -> dict:
"""
units: queryset از Unit؛ مدل‌ها پارامتر می‌شوند تا هم migration (مدل‌های تاریخی) و هم دستورِ مدیریتی
از همین کد استفاده کنند.
"""
counts = {'nodes_split': 0, 'nodes_created': 0, 'lessons_moved': 0, 'completions_cloned': 0}
for unit in units:
nodes = list(Node.objects.filter(unit=unit).order_by('order', 'id'))
plan = []
for node in nodes:
lessons = list(Lesson.objects.filter(node=node).order_by('id').values_list('id', flat=True))
parts = part_count(len(lessons), max_lessons, part_cap)
plan.append((node, balanced_chunks(lessons, parts) if parts > 1 else None))
if not any(chunks for _node, chunks in plan):
continue
if dry_run:
for node, chunks in plan:
if chunks:
counts['nodes_split'] += 1
counts['nodes_created'] += len(chunks) - 1
counts['lessons_moved'] += sum(len(c) for c in chunks[1:])
continue
order = 0
for node, chunks in plan:
if not chunks:
order += 1
if node.order != order:
Node.objects.filter(pk=node.pk).update(order=order)
continue
base = node.title
counts['nodes_split'] += 1
completions = list(UserNodeCompletion.objects.filter(node=node))
progresses = list(NodeProgress.objects.filter(node=node, status='completed'))
for index, lesson_ids in enumerate(chunks, start=1):
order += 1
title = part_title(base, index)
if index == 1:
Node.objects.filter(pk=node.pk).update(title=title, order=order)
continue
new = Node.objects.create(unit=unit, title=title, order=order, icon=node.icon, color=node.color)
counts['nodes_created'] += 1
counts['lessons_moved'] += Lesson.objects.filter(pk__in=lesson_ids).update(node=new)
clones = UserNodeCompletion.objects.bulk_create([
UserNodeCompletion(
user_id=c.user_id, node=new, correct_count=c.correct_count,
total_questions=c.total_questions, stars=c.stars, xp_earned=0, coins_earned=0,
idempotency_key=None,
) for c in completions
])
# created_at از auto_now_add است و در bulk_create نادیده گرفته نمی‌شود؛ بعداً به زمانِ اصلی برمی‌گردد.
for clone, original in zip(clones, completions):
UserNodeCompletion.objects.filter(pk=clone.pk).update(created_at=original.created_at)
counts['completions_cloned'] += len(clones)
NodeProgress.objects.bulk_create([
NodeProgress(
user_id=p.user_id, node=new, status='completed',
lessons_completed_count=len(lesson_ids), started_at=p.started_at,
completed_at=p.completed_at, last_activity_at=p.last_activity_at,
) for p in progresses
])
# بخشِ اول فقط درس‌های خودش را نگه می‌دارد: درس‌های بخش‌های بعدی بالا جابه‌جا شدند.
return counts

View File

@@ -1,7 +1,7 @@
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from .models import Grade, Lesson, Node, Question, Unit, UserAnswer, UserLessonCompletion
from .models import Grade, Lesson, Node, Question, Subject, Unit, UserAnswer, UserLessonCompletion
from .services import touch_node_progress_on_answer, update_node_progress_on_lesson_completion
@@ -25,6 +25,8 @@ def _on_lesson_completion_created(sender, instance, created, raw=False, **kwargs
# می‌رود و همه‌ی کلیدهای content:grades / content:tree را یک‌جا باطل می‌کند.
@receiver(post_save, sender=Grade, dispatch_uid='content_bump_curriculum_grade_save')
@receiver(post_delete, sender=Grade, dispatch_uid='content_bump_curriculum_grade_delete')
@receiver(post_save, sender=Subject, dispatch_uid='content_bump_curriculum_subject_save')
@receiver(post_delete, sender=Subject, dispatch_uid='content_bump_curriculum_subject_delete')
@receiver(post_save, sender=Unit, dispatch_uid='content_bump_curriculum_unit_save')
@receiver(post_delete, sender=Unit, dispatch_uid='content_bump_curriculum_unit_delete')
@receiver(post_save, sender=Node, dispatch_uid='content_bump_curriculum_node_save')

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,7 @@ import json
from django.db import transaction
from . import question_io
from .curriculum_io import _subject_or_error
from .models import Grade, Lesson, Node, Question, SeedRecord, Unit
@@ -91,7 +92,10 @@ def sync(grades: list[dict], question_rows: list[dict]) -> dict:
if ledger.delivered(_key('unit', *upath)):
counts['retired'] += 1
continue
unit = Unit.objects.create(grade=grade, title=u['title'], order=u['order'])
unit = Unit.objects.create(
grade=grade, title=u['title'], order=u['order'],
subject=_subject_or_error(u['subject']),
)
counts['units'] += 1
ledger.record(_key('unit', *upath), 'unit')
if u.get('blurb') and not unit.blurb:

View File

@@ -61,7 +61,24 @@ class StatsSerializer(serializers.Serializer):
coins = serializers.IntegerField()
class SubjectSerializer(serializers.Serializer):
key = serializers.CharField(help_text='همان که به `?subject=` داده می‌شود.')
title = serializers.CharField()
icon = serializers.CharField(allow_blank=True, help_text='نامِ آیکنِ lucide.')
color = serializers.CharField(allow_blank=True)
order = serializers.IntegerField()
class HomeResponseSerializer(serializers.Serializer):
subject = SubjectSerializer(
allow_null=True,
help_text='کتابی که `units` از آن است؛ null فقط وقتی پایه هیچ فصلی ندارد.',
)
subjects = SubjectSerializer(
many=True,
help_text='همه‌ی کتاب‌های این پایه. فرانت نوارِ جابه‌جاییِ کتاب را فقط وقتی نشان می‌دهد '
'که بیش از یکی باشد.',
)
units = UnitWithNodesSerializer(many=True)
stats = StatsSerializer()

View File

@@ -12,7 +12,7 @@ from rag.models import LessonChunk
def _curriculum(blurb='', example='', steps=None):
return json.dumps({'grades': [{'title': 'پایه آزمون', 'units': [{
'title': 'فصل ۱', 'blurb': blurb, 'nodes': [{'title': 'جزیره', 'lessons': [
'subject': 'ریاضی', 'title': 'فصل ۱', 'blurb': blurb, 'nodes': [{'title': 'جزیره', 'lessons': [
{'title': 'درس', 'introText': 'توضیح', 'example': example, 'steps': steps or []},
]}],
}]}]}).encode()

View File

@@ -109,7 +109,7 @@ class CurriculumImportTests(TestCase):
'grades': [{
'title': 'پایه نهم', 'icon': 'grade-9', 'color': '#FF9800',
'units': [{
'title': 'فصل ۴: توان و ریشه', 'order': 4,
'subject': 'ریاضی', 'title': 'فصل ۴: توان و ریشه', 'order': 4,
'nodes': [{
'title': 'توان صحیح', 'order': 1,
'lessons': [{'title': 'ضرب توان‌ها', 'introText': 'پایه‌ها برابرند.'}],

View File

@@ -21,7 +21,7 @@ CURRICULUM = {
'grades': [{
'title': 'پایه نهم', 'icon': 'grade-9', 'color': '#FF9800',
'units': [{
'title': 'فصل ۴: توان و ریشه', 'order': 4,
'subject': 'ریاضی', 'title': 'فصل ۴: توان و ریشه', 'order': 4,
'nodes': [{
'title': 'توان صحیح', 'order': 1,
'lessons': [

159
content/tests_subjects.py Normal file
View File

@@ -0,0 +1,159 @@
"""
یک پایه چند کتاب دارد (دوازدهم: حسابان، هندسه، گسسته) و دانش‌آموز بینشان جابه‌جا می‌شود
(۲۰۲۶-۱۰-۱۱). `/api/home?subject=` نقشه‌ی همان کتاب را می‌دهد؛ کتابِ ناموجود در پایه ۴۰۰ است؛
فصلِ اولِ هر کتاب رایگان است؛ و دستیار کتابِ درس را از خودِ فصل می‌گیرد.
"""
from django.core.cache import cache
from django.test import TestCase
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from .models import Grade, Lesson, Node, Subject, Unit
class SubjectHomeTests(TestCase):
def setUp(self):
cache.clear()
self.grade = Grade.objects.create(title='پایه دوازدهم', icon='', color='')
self.calc = Subject.objects.get(key='calculus')
self.geo = Subject.objects.get(key='geometry')
self.units = {}
for subject, titles in ((self.calc, ['فصل ۱: تابع', 'فصل ۲: مثلثات']),
(self.geo, ['فصل ۱: ماتریس', 'فصل ۲: مقاطع مخروطی'])):
for order, title in enumerate(titles, start=1):
unit = Unit.objects.create(grade=self.grade, subject=subject, title=title, order=order)
node = Node.objects.create(unit=unit, title=f'جزیره {title}', order=1)
Lesson.objects.create(node=node, title='درس')
self.units[(subject.key, order)] = unit
self.user = User.objects.create(phone='09120007911', grade_id=self.grade.id)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
def test_default_is_the_first_subject_of_the_grade(self):
body = self.client.get('/api/home').json()
self.assertEqual(body['subject']['key'], 'calculus')
self.assertEqual([s['key'] for s in body['subjects']], ['calculus', 'geometry'])
self.assertEqual([u['title'] for u in body['units']], ['فصل ۱: تابع', 'فصل ۲: مثلثات'])
def test_subject_param_switches_the_map(self):
body = self.client.get('/api/home?subject=geometry').json()
self.assertEqual(body['subject']['key'], 'geometry')
self.assertEqual([u['title'] for u in body['units']], ['فصل ۱: ماتریس', 'فصل ۲: مقاطع مخروطی'])
def test_a_subject_the_grade_does_not_have_is_a_400_not_a_silent_default(self):
self.assertEqual(self.client.get('/api/home?subject=discrete').status_code, 400)
self.assertEqual(self.client.get('/api/home?subject=nope').status_code, 400)
def test_the_first_unit_of_each_subject_is_free(self):
for key in ('calculus', 'geometry'):
body = self.client.get(f'/api/home?subject={key}').json()
self.assertEqual([u['requiresSubscription'] for u in body['units']], [False, True], key)
def test_second_unit_of_other_subject_is_still_locked_by_the_paywall(self):
node = Node.objects.get(unit=self.units[('geometry', 2)])
lesson = node.lessons.first()
self.assertEqual(self.client.get(f'/api/lessons/{lesson.id}/questions').status_code, 402)
def test_ordering_follows_subject_order_not_creation_order(self):
# هندسه زودتر ساخته شود؛ نقشه باز هم حسابان را اول می‌دهد (Subject.order).
cache.clear()
Subject.objects.filter(pk=self.geo.pk).update(order=1)
Subject.objects.filter(pk=self.calc.pk).update(order=9)
cache.clear()
self.assertEqual(self.client.get('/api/home').json()['subject']['key'], 'geometry')
class LessonContextUsesTheUnitSubjectTests(TestCase):
def test_a_geometry_lesson_resolves_to_the_geometry_book(self):
from rag.services.lesson_context import resolve_from_lesson
grade = Grade.objects.create(title='پایه دهم', icon='', color='')
geo = Subject.objects.get(key='geometry')
unit = Unit.objects.create(grade=grade, subject=geo, title='فصل ۲: قضیه‌ی تالس، تشابه و کاربردهای آن')
lesson = Lesson.objects.create(node=Node.objects.create(unit=unit, title='n'), title='l')
self.assertEqual(
resolve_from_lesson(lesson.id),
{'subject': 'هندسه', 'gradeTitle': 'دهم', 'chapter': 2},
)
def test_same_title_under_the_wrong_subject_does_not_match(self):
from rag.services.lesson_context import resolve_from_lesson
grade = Grade.objects.create(title='پایه دهم', icon='', color='')
math = Subject.objects.get(key='math')
# عنوانِ فصلِ هندسه، ولی زیرِ کتابِ ریاضی: نباید به collectionِ هندسه برود.
unit = Unit.objects.create(grade=grade, subject=math, title='فصل ۲: قضیه‌ی تالس، تشابه و کاربردهای آن')
lesson = Lesson.objects.create(node=Node.objects.create(unit=unit, title='n'), title='l')
self.assertEqual(resolve_from_lesson(lesson.id), {'gradeTitle': 'دهم'})
class CurriculumFileNeedsASubjectTests(TestCase):
def test_a_unit_without_subject_is_rejected(self):
import json
from .curriculum_io import CurriculumError, parse
raw = json.dumps({'grades': [{'title': 'پایه ۱', 'units': [{'title': 'فصل ۱', 'nodes': []}]}]})
with self.assertRaises(CurriculumError):
parse(raw.encode())
def test_an_unknown_subject_is_rejected_on_import(self):
from .curriculum_io import CurriculumError, import_curriculum
grades = [{'title': 'پایه ۱', 'icon': '', 'color': '', 'units': [
{'subject': 'فیزیک', 'title': 'فصل ۱', 'blurb': '', 'order': 1, 'nodes': []}]}]
with self.assertRaises(CurriculumError):
import_curriculum(grades)
class NodeSplitTests(TestCase):
"""`content.node_split`: جزیره‌ی بلند → چند جزیره‌ی حداکثر ۶ درسی، بی‌آن‌که پیشرفتِ کسی گم یا دوبار شمرده شود."""
def setUp(self):
from .models import NodeProgress, UserNodeCompletion
self.grade = Grade.objects.create(title='پایه دوازدهم', icon='', color='')
self.subject = Subject.objects.get(key='calculus')
self.unit = Unit.objects.create(grade=self.grade, subject=self.subject, title='فصل ۱: تابع', order=1)
self.long = Node.objects.create(unit=self.unit, title='تبدیل نمودار', order=1)
self.short = Node.objects.create(unit=self.unit, title='کوتاه', order=2)
self.lesson_ids = [Lesson.objects.create(node=self.long, title=f'درس {i}').id for i in range(25)]
Lesson.objects.create(node=self.short, title='تک')
UserNodeCompletion.objects.create(user_id=7, node=self.long, correct_count=3, total_questions=4,
stars=2, xp_earned=50, coins_earned=10)
NodeProgress.objects.create(user_id=7, node=self.long, status='completed')
def _run(self, **kw):
from .models import NodeProgress, UserNodeCompletion
from .node_split import split_long_nodes
return split_long_nodes(Node=Node, Lesson=Lesson, UserNodeCompletion=UserNodeCompletion,
NodeProgress=NodeProgress, units=Unit.objects.all(), **kw)
def test_splits_into_balanced_ordered_parts_and_keeps_lesson_ids(self):
counts = self._run()
self.assertEqual((counts['nodes_split'], counts['nodes_created']), (1, 4))
nodes = list(Node.objects.filter(unit=self.unit).order_by('order'))
self.assertEqual([n.title for n in nodes], ['تبدیل نمودار (۱)', 'تبدیل نمودار (۲)', 'تبدیل نمودار (۳)', 'تبدیل نمودار (۴)', 'تبدیل نمودار (۵)', 'کوتاه'])
self.assertEqual([n.order for n in nodes], [1, 2, 3, 4, 5, 6])
self.assertEqual(nodes[0].pk, self.long.pk) # بخشِ اول همان شناسه را نگه می‌دارد
sizes = [Lesson.objects.filter(node=n).count() for n in nodes]
self.assertEqual(sizes, [5, 5, 5, 5, 5, 1])
# ترتیبِ کتاب حفظ شده: درس‌های هر بخش پشتِ‌هم‌اند.
first = list(Lesson.objects.filter(node=nodes[0]).order_by('id').values_list('id', flat=True))
self.assertEqual(first, self.lesson_ids[:5])
def test_a_finished_island_stays_finished_without_double_counting_xp(self):
from .models import NodeProgress, UserNodeCompletion
self._run()
rows = UserNodeCompletion.objects.filter(user_id=7).order_by('node__order')
self.assertEqual(rows.count(), 5)
self.assertEqual(sum(r.xp_earned for r in rows), 50)
self.assertEqual(sum(r.coins_earned for r in rows), 10)
self.assertEqual(NodeProgress.objects.filter(user_id=7, status='completed').count(), 5)
def test_second_run_changes_nothing(self):
self._run()
again = self._run()
self.assertEqual(again, {'nodes_split': 0, 'nodes_created': 0, 'lessons_moved': 0, 'completions_cloned': 0})
self.assertEqual(Node.objects.filter(unit=self.unit).count(), 6)
def test_dry_run_writes_nothing(self):
counts = self._run(dry_run=True)
self.assertEqual(counts['nodes_created'], 4)
self.assertEqual(Node.objects.filter(unit=self.unit).count(), 2)

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 برابریِ این دو را نگه می‌دارد.