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:
@@ -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')
|
||||
|
||||
@@ -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 {}),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
36
content/management/commands/split_long_nodes.py
Normal file
36
content/management/commands/split_long_nodes.py
Normal 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}")
|
||||
23
content/migrations/0014_subject.py
Normal file
23
content/migrations/0014_subject.py
Normal 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']},
|
||||
),
|
||||
]
|
||||
24
content/migrations/0015_seed_subjects.py
Normal file
24
content/migrations/0015_seed_subjects.py
Normal 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)]
|
||||
20
content/migrations/0016_unit_subject.py
Normal file
20
content/migrations/0016_unit_subject.py
Normal 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',
|
||||
),
|
||||
),
|
||||
]
|
||||
19
content/migrations/0017_calculus_units.py
Normal file
19
content/migrations/0017_calculus_units.py
Normal 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)]
|
||||
25
content/migrations/0018_split_long_calculus_nodes.py
Normal file
25
content/migrations/0018_split_long_calculus_nodes.py
Normal 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)]
|
||||
@@ -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
113
content/node_split.py
Normal 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
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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': 'پایهها برابرند.'}],
|
||||
|
||||
@@ -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
159
content/tests_subjects.py
Normal 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)
|
||||
@@ -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