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

@@ -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),
]