make every grade's id its class number and sort grades by it everywhere

Grade ids were the order the books were imported in (ninth=1, eighth=5), so
gradeId: 5 meant eighth grade and the admin, the question filter and the API
all listed grades out of order. Grade.number (1-12) is now read from the title,
new grades take it as their id, and migration 0008 moves existing ids plus every
reference to them: Unit, the loose grade_id in accounts/ai_tutor/rag, and the
grade target inside campaign JSON. Unnumbered demo grades move above 100 and the
id sequence is raised past 12 so they never collide with a real grade.

The frontend needs no change: it always reads gradeId from the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 22:47:02 +03:30
parent d70737dddf
commit 0c6b08c9ff
13 changed files with 292 additions and 12 deletions

View File

@@ -32,7 +32,7 @@ class GradeAdmin(admin.ModelAdmin):
سرور بود که به Qdrant و به دسترسیِ SSH وابسته بودند.
"""
list_display = ('id', 'title', 'icon', 'color')
list_display = ('number', 'title', 'id', 'icon', 'color')
search_fields = ('title',)
actions = ['export_curriculum_action']
change_list_template = 'admin/content/grade/change_list.html'
@@ -84,6 +84,9 @@ class GradeAdmin(admin.ModelAdmin):
class UnitAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'grade', 'order')
list_filter = ('grade',)
# فیلترِ «فصل» در صفحه‌ی سوال‌ها هم از همین ترتیب می‌آید: پایه‌به‌پایه، نه
# همه‌ی «فصل ۱»ها پشتِ هم.
ordering = ('grade__number', 'grade_id', 'order')
@admin.register(Node)

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.2.17 on 2026-09-23 19:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('content', '0006_lesson_duration_minutes_lesson_example_lesson_kind_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='grade',
options={'ordering': [models.OrderBy(models.F('number'), nulls_last=True), 'id']},
),
migrations.AddField(
model_name='grade',
name='number',
field=models.PositiveSmallIntegerField(blank=True, help_text='شماره\u200cی پایه، ۱ تا ۱۲. خالی بماند از عنوان حدس زده می\u200cشود.', null=True, unique=True),
),
]

View File

@@ -0,0 +1,105 @@
"""
id هر پایه را برابرِ شماره‌اش می‌کند (پایه هشتم → 8) و همه‌ی ارجاع‌ها را همراهش.
تا پیش از این id ترتیبِ واردشدنِ کتاب‌ها بود (نهم=۱، ششم=۲، …، هشتم=۵)، پس
`gradeId: 5` یعنی هشتم و همه‌جا — ادمین، فیلترِ سوال‌ها، API — پایه‌ها نامرتب
بودند. شماره‌ی پایه از عنوان خوانده می‌شود، نه از id، پس روی هر دیتابیسی (با
هر ترتیبِ idِ قبلی) درست کار می‌کند.
ارجاع‌ها: FKِ `Unit.grade`، سه ارجاعِ سبکِ `grade_id` در accounts، ai_tutor و
rag، و `grade_id` داخلِ JSONِ `target_filter` کمپین‌های پیامکی. پایه‌ای که عنوانش شماره ندارد ([DEMO]/[SEED]) اگر idش در بازه‌ی ۱ تا ۱۲
باشد به بالای ۱۰۰ می‌رود تا جا برای پایه‌های واقعی باز شود.
برگشت‌ناپذیر به‌معنای «idهای قدیمی برنمی‌گردند»؛ برگشتِ migration فقط فیلد را
برمی‌دارد و idهای تازه می‌مانند، که برای همه‌ی کدها بی‌تفاوت است.
"""
from django.db import migrations
from content.models import grade_number_from_title
LOOSE_REFS = [('accounts', 'User'), ('ai_tutor', 'Conversation'), ('rag', 'LessonChunk')]
TEMP_OFFSET = 1_000_000
FIRST_UNNUMBERED_ID = 101
def forwards(apps, schema_editor):
Grade = apps.get_model('content', 'Grade')
Unit = apps.get_model('content', 'Unit')
refs = [apps.get_model(app, model) for app, model in LOOSE_REFS]
grades = list(Grade.objects.order_by('id'))
taken = set()
for grade in grades:
number = grade_number_from_title(grade.title)
if number and number not in taken:
taken.add(number)
Grade.objects.filter(pk=grade.pk).update(number=number)
grade.number = number
next_free = max([g.id for g in grades] + [FIRST_UNNUMBERED_ID - 1]) + 1
moves = {}
for grade in grades:
if grade.number:
target = grade.number
elif grade.id <= 12:
target, next_free = next_free, next_free + 1
else:
continue
if target != grade.id:
moves[grade.id] = target
def move(old, new):
Grade.objects.filter(id=old).update(id=new)
Unit.objects.filter(grade_id=old).update(grade_id=new)
for model in refs:
model.objects.filter(grade_id=old).update(grade_id=new)
# دو مرحله، چون مقصدِ یکی ممکن است idِ فعلیِ دیگری باشد (هشتم ۵→۸ درحالی‌که
# دوازدهم هنوز ۸ است).
for old in moves:
move(old, old + TEMP_OFFSET)
for old, new in moves.items():
move(old + TEMP_OFFSET, new)
# کمپینِ «همه‌ی کاربرانِ پایه‌ی X» idِ پایه را در JSON نگه می‌دارد
Campaign = apps.get_model('campaigns', 'MessageCampaign')
for campaign in Campaign.objects.all():
target = campaign.target_filter or {}
if target.get('type') == 'grade' and target.get('grade_id') in moves:
target['grade_id'] = moves[target['grade_id']]
Campaign.objects.filter(pk=campaign.pk).update(target_filter=target)
# پایه‌ی بی‌شماره‌ای که بعداً ساخته شود idِ خودکار می‌گیرد؛ شمارنده باید از
# بازه‌ی ۱ تا ۱۲ بالاتر باشد وگرنه با «پایه نهم» (id=9) برخورد می‌کند.
table = Grade._meta.db_table
floor = max([next_free - 1] + list(moves.values()) + [FIRST_UNNUMBERED_ID - 1])
with schema_editor.connection.cursor() as cursor:
vendor = schema_editor.connection.vendor
if vendor == 'sqlite':
cursor.execute('UPDATE sqlite_sequence SET seq = MAX(seq, %s) WHERE name = %s', [floor, table])
cursor.execute(
'INSERT INTO sqlite_sequence (name, seq) SELECT %s, %s '
'WHERE NOT EXISTS (SELECT 1 FROM sqlite_sequence WHERE name = %s)',
[table, floor, table],
)
elif vendor == 'postgresql':
cursor.execute(
f"SELECT setval(pg_get_serial_sequence('{table}', 'id'), "
f"GREATEST(%s, (SELECT COALESCE(MAX(id), 1) FROM {table})))",
[floor],
)
class Migration(migrations.Migration):
dependencies = [
('content', '0007_grade_number'),
('accounts', '0003_user_last_activity_at'),
('ai_tutor', '0005_message_chunk_count_message_completion_tokens_and_more'),
('rag', '0006_remove_lessonchunk_solution_agrees_and_more'),
('campaigns', '0003_campaigndelivery_one_active_per_user_channel'),
]
operations = [
migrations.RunPython(forwards, migrations.RunPython.noop),
]

View File

@@ -1,10 +1,48 @@
import re
from django.db import models
# «پایه هشتم» → ۸. فقط دوازده پایه وجود دارد، پس شماره‌ی پایه یک کلیدِ طبیعی و
# پایدار است؛ id هر پایه همین شماره است (پایین، `Grade.save`).
GRADE_NUMBERS = {
'اول': 1, 'دوم': 2, 'سوم': 3, 'چهارم': 4, 'پنجم': 5, 'ششم': 6,
'هفتم': 7, 'هشتم': 8, 'نهم': 9, 'دهم': 10, 'یازدهم': 11, 'دوازدهم': 12,
}
_GRADE_TITLE = re.compile(r'^\s*پایه[\s‌ٔ]*(\S+)\s*$')
def grade_number_from_title(title: str):
"""«پایه نهم» → 9؛ عنوانی که دقیقاً این شکل را ندارد ([DEMO]، [SEED]، …) → None."""
match = _GRADE_TITLE.match(title or '')
return GRADE_NUMBERS.get(match.group(1)) if match else None
class Grade(models.Model):
title = models.CharField(max_length=50)
icon = models.CharField(max_length=100)
color = models.CharField(max_length=10)
# شماره‌ی پایه (۱ تا ۱۲) و هم‌زمان id آن. بدونِ این، id ترتیبِ واردشدنِ
# کتاب‌ها بود (نهم=۱، هشتم=۵) و همه‌جا — ادمین، فیلترِ سوال‌ها، API — پایه‌ها
# نامرتب نمایش داده می‌شدند.
number = models.PositiveSmallIntegerField(
null=True, blank=True, unique=True,
help_text='شماره‌ی پایه، ۱ تا ۱۲. خالی بماند از عنوان حدس زده می‌شود.',
)
class Meta:
ordering = [models.F('number').asc(nulls_last=True), 'id']
def save(self, *args, **kwargs):
if self.number is None:
guess = grade_number_from_title(self.title)
if guess and not Grade.objects.filter(number=guess).exclude(pk=self.pk).exists():
self.number = guess
# پایه‌ی تازه id را از شماره‌اش می‌گیرد، نه از شمارنده‌ی دیتابیس؛ وگرنه
# «پایه چهارم» که بعداً اضافه شود id=13 می‌گرفت.
if (self._state.adding and self.pk is None and self.number
and not Grade.objects.filter(pk=self.number).exists()):
self.pk = self.number
super().save(*args, **kwargs)
def __str__(self):
return self.title

View File

@@ -13,7 +13,7 @@ class GradeSerializer(serializers.ModelSerializer):
class Meta:
model = Grade
fields = ['id', 'title', 'icon', 'color', 'lessonCount']
fields = ['id', 'number', 'title', 'icon', 'color', 'lessonCount']
class GradesListResponseSerializer(serializers.Serializer):

View File

@@ -0,0 +1,82 @@
import importlib
from types import SimpleNamespace
from django.apps import apps
from django.db import connection
from django.test import TestCase
from accounts.models import User
from ai_tutor.models import Conversation
from campaigns.models import MessageCampaign
from content.models import Grade, Unit, grade_number_from_title
migration = importlib.import_module('content.migrations.0008_grade_id_equals_number')
class GradeNumberTests(TestCase):
def test_number_is_read_from_title(self):
self.assertEqual(grade_number_from_title('پایه هشتم'), 8)
self.assertEqual(grade_number_from_title('پایه دوازدهم'), 12)
self.assertEqual(grade_number_from_title('پایهٔ یازدهم'), 11)
self.assertIsNone(grade_number_from_title('[SEED] پایه سوم'))
self.assertIsNone(grade_number_from_title('ریاضی'))
def test_new_grade_takes_its_number_as_id(self):
# بدونِ این، «پایه چهارم»ی که بعداً اضافه شود id=13 می‌گرفت.
grade = Grade.objects.create(title='پایه چهارم', icon='g', color='#000')
self.assertEqual((grade.number, grade.pk), (4, 4))
def test_unnumbered_grade_stays_out_of_one_to_twelve(self):
grade = Grade.objects.create(title='[DEMO] نمونه', icon='g', color='#000')
self.assertIsNone(grade.number)
self.assertGreater(grade.pk, 12)
# و بعدش هنوز جا برای پایه‌ی واقعی هست
self.assertEqual(Grade.objects.create(title='پایه نهم', icon='g', color='#000').pk, 9)
def test_duplicate_title_does_not_break_save(self):
Grade.objects.create(title='پایه دهم', icon='g', color='#000')
second = Grade.objects.create(title='پایه دهم', icon='g', color='#000')
self.assertIsNone(second.number)
def test_grades_api_is_ordered_by_number(self):
for title in ('پایه نهم', 'پایه سوم', 'پایه دوازدهم', 'پایه هشتم'):
Grade.objects.create(title=title, icon='g', color='#000')
rows = self.client.get('/api/grades').json()['grades']
self.assertEqual([r['number'] for r in rows], [3, 8, 9, 12])
self.assertEqual([r['id'] for r in rows], [3, 8, 9, 12])
class GradeRenumberMigrationTests(TestCase):
"""همان وضعیتِ سرور پیش از migration: idها به ترتیبِ واردشدنِ کتاب‌ها."""
def _grade(self, pk, title):
# save() را دور می‌زنیم تا idِ قدیمی و بی‌شماره بسازیم، مثلِ دیتای واقعی
Grade.objects.bulk_create([Grade(pk=pk, title=title, icon='g', color='#000')])
return pk
def test_ids_become_numbers_and_every_reference_follows(self):
old = { # id قدیمی → عنوان (ترتیبِ سرورِ واقعی)
1: 'پایه نهم', 2: 'پایه ششم', 5: 'پایه هشتم', 8: 'پایه دوازدهم', 9: '[DEMO] نمونه',
}
for pk, title in old.items():
self._grade(pk, title)
Unit.objects.create(grade_id=pk, title=f'فصلِ {title}')
student = User.objects.create(grade_id=5) # هشتم
conversation = Conversation.objects.create(user_id=student.id, grade_id=8) # دوازدهم
campaign = MessageCampaign.objects.create(
title='t', body='b', channel='sms', target_filter={'type': 'grade', 'grade_id': 1}) # نهم
migration.forwards(apps, SimpleNamespace(connection=connection))
by_title = {g.title: g for g in Grade.objects.all()}
self.assertEqual({t: (g.pk, g.number) for t, g in by_title.items() if g.number}, {
'پایه نهم': (9, 9), 'پایه ششم': (6, 6), 'پایه هشتم': (8, 8), 'پایه دوازدهم': (12, 12),
})
self.assertGreater(by_title['[DEMO] نمونه'].pk, 12)
for grade in by_title.values():
self.assertEqual(list(grade.units.values_list('title', flat=True)), [f'فصلِ {grade.title}'])
student.refresh_from_db()
conversation.refresh_from_db()
campaign.refresh_from_db()
self.assertEqual((student.grade_id, conversation.grade_id), (8, 12))
self.assertEqual(campaign.target_filter, {'type': 'grade', 'grade_id': 9})

View File

@@ -1,6 +1,6 @@
from django.core.cache import cache
from django.db import IntegrityError, transaction
from django.db.models import Count
from django.db.models import Count, F
from django.http import Http404
import random
@@ -87,10 +87,10 @@ def _grades_payload() -> dict:
# هنوز خالی است و کاربر صفحه‌ی خالی می‌بیند.
grades = Grade.objects.annotate(
_lessons=Count('units__nodes__lessons', distinct=True),
).order_by('id')
).order_by(F('number').asc(nulls_last=True), 'id')
return {'grades': [
{'id': g.id, 'title': g.title, 'icon': g.icon, 'color': g.color,
'lessonCount': g._lessons}
{'id': g.id, 'number': g.number, 'title': g.title, 'icon': g.icon,
'color': g.color, 'lessonCount': g._lessons}
for g in grades
]}