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

@@ -52,7 +52,7 @@ API docs: Swagger `/api/docs/`, Redoc `/api/redoc/`, admin `/admin/`.
**Throttling**: DRF `ScopedRateThrottle` with per-endpoint `throttle_scope` strings, rates centralized in `REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']` in `config/settings.py`. Guest users are throttled by IP (`NUM_PROXIES` setting controls how many XFF hops are trusted — must match real proxy count in prod or IP spoofing/throttle-bypass is possible). Adding a new sensitive endpoint means adding both a `throttle_scope` on the view and a matching rate entry in settings.
**`POST /api/ai/ask`** currently returns a stub; real model integration point is `generate_tutor_answer` in `ai_tutor/views.py`.
**`POST /api/ai/ask`** is real RAG (Qdrant retrieval filtered by chapter → GapGPT generation) whenever `OPENAI_API_KEY` and `QDRANT_API_KEY` are set, which they are on the server since 2026-09-23; without them it falls back to a deterministic stub. Entry point is `generate_tutor_answer` in `ai_tutor/views.py`; `usedChunks` in the response names the book chunks the answer was grounded on.
**Structured logging**: `config.middleware.APIRequestLoggingMiddleware` (last in `MIDDLEWARE`, closest to the view) logs every `/api/` request as JSON (method/path/status/duration/user, no body/headers/secrets) via `config/log_formatters.py`.
@@ -110,6 +110,18 @@ for builds. **The better long-term fix is infrastructure, not code** — proxy `
the frontend domain to this backend, which makes everything same-origin and removes CORS
entirely. That needs the server admin, not us.
**State on 2026-09-23 (checked against upstream `main`, last commit 2026-09-21):** the
screens are wired to the API (PR #4/#5 upstream), but `App.tsx` builds the client with
`env.VITE_API_URL ?? "/api"` — while their own `.env.example` and `client.ts` name the
variable `VITE_API_BASE_URL`. The deployed bundle has neither, so it calls the relative
`/api` and gets HTML back: **math.tadrisino2.ir cannot reach the backend** until either
that line falls back to `https://api.math.tadrisino2.ir/api` or nginx proxies `/api`.
Our PR #3 fixed the same trap and is still open, now likely conflicting with their
rewrite. Every path their client calls exists and was driven live against the server.
The app also generates a random `deviceId` per browser, so the seeded
`frontend-test-device` account only loads after
`localStorage.setItem("deviceId", "frontend-test-device")`.
**Order matters when bringing the stack up:** backend first, then frontend. If the
frontend is tested while `api.math.tadrisino2.ir` is down, every screen fails and it
looks like the client is broken.
@@ -363,7 +375,7 @@ anyone who can read this repo can forge a token for any user.**
- **هر نودِ پایه‌های هشتم تا دوازدهم آزمونِ کاملِ چهارسوالی دارد.** پایه‌های
سوم/ششم/هفتم کتابِ تیمِ دیگرند: ۳۹ نود از ۱۴۵ کمتر از چهار سوال دارند، ولی هیچ‌کدام
خالی نیست، پس روی نقشه جزیره‌ی مرده وجود ندارد.
- ۴۸۳ تست سبز، `check` و `makemigrations --check` تمیز، و `spectacular` بدونِ خطا و
- ۴۸۹ تست سبز، `check` و `makemigrations --check` تمیز، و `spectacular` بدونِ خطا و
هشدار. ۵۶ اندپوینت.
- تستِ هم‌زمانیِ campaigns که مدت‌ها flaky بود **دیگر flaky نیست** — علتش یک باگِ
واقعی بود، نه طبیعتِ تست؛ پایینِ همین فایل توضیح داده شده.
@@ -389,6 +401,10 @@ anyone who can read this repo can forge a token for any user.**
گفتگوی RAG، تیکت با پاسخ و زیرمجموعه دارند؛ لیدربرد با `rebuild_leaderboard` از فعالیتِ
واقعی ساخته شد. `purge_demo_data` هیچ‌کدام را نمی‌گیرد و باید پیش از آمدنِ کاربرِ واقعی
پاک شوند. بکاپِ پیش از آن: `.deploy-backups/db-before-demo-20260923-213830.sqlite3`.
- **idِ هر پایه = شماره‌ی پایه** (migration‌ِ `content.0008`): هشتم=۸، دوازدهم=۱۲،
و فیلدِ `Grade.number`. پیش از آن هشتم=۵ و نهم=۱ بود. FKِ Unit، `grade_id`ِ
accounts/ai_tutor/rag و `target_filter`ِ کمپین‌ها همراهش منتقل شدند؛ ادمین و API
به ترتیبِ کلاس‌اند. شرح در `docs/architecture.md` بخشِ دیتابیس.
- **صفحه‌های `/api/docs/` و `/api/redoc/` از قالبِ خودمان رد می‌شوند**
(`templates/tadrisino/`) تا متنِ فارسی راست‌به‌چپ رندر شود. قالبِ drf-spectacular
دست‌نخورده است و با `{{ block.super }}` گسترش داده شده.

View File

@@ -13,7 +13,7 @@
| **آدرسِ زنده** | `https://api.math.tadrisino2.ir` · [سلامت](https://api.math.tadrisino2.ir/api/health) |
| **استک** | Python 3.11+ · Django 5.2 · DRF · SQLite · Qdrant (RAG) |
| **API** | ۵۶ اندپوینت · مستنداتِ زنده در `/api/docs/` |
| **تست** | ۴۸۳ تست، همه سبز (`python manage.py test`) |
| **تست** | ۴۸۹ تست، همه سبز (`python manage.py test`) |
| **محتوا** | ۸ پایه · ۵۸ فصل · ۲۶۳ نود · ۲٬۶۱۷ درس · ۲٬۴۶۳ سوال |
---

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
]}

View File

@@ -84,6 +84,13 @@ PaymentRequest را می‌زند، `Order.gateway_authority` را ذخیره م
هنوز SQLite است (`OPTIONS.timeout=20` تا قفل‌های هم‌زمانیِ نوشتن را تحمل کند).
مهاجرت به Postgres انجام نشده.
**idِ هر پایه همان شماره‌ی پایه است** (سوم=۳، هشتم=۸، دوازدهم=۱۲) و در فیلدِ
`Grade.number` هم هست. پایه‌ی تازه id را از شماره‌اش می‌گیرد، نه از شمارنده‌ی
دیتابیس؛ شماره اگر خالی بماند از عنوان («پایه چهارم») خوانده می‌شود. پایه‌ی
بی‌شماره ([DEMO]/[SEED]) idِ بالای ۱۰۰ می‌گیرد. همه‌جا — API، ادمین، فیلترِ
سوال‌ها — پایه‌ها به‌ترتیبِ شماره‌اند. تا migration‌ِ `content.0008` id ترتیبِ
واردشدنِ کتاب‌ها بود (نهم=۱، هشتم=۵).
⚠️ **`.iterator()` را دورِ حلقه‌ای که می‌نویسد یا I/O بیرونی می‌زند نگذارید.**
کرسرِ خواندن در تمامِ طولِ حلقه باز می‌ماند و روی SQLite جدول را برای هر
نویسنده‌ی هم‌زمان قفل می‌کند — قفلی که تا پایانِ حلقه رها نمی‌شود و هیچ

View File

@@ -44,6 +44,7 @@
| گرفتنِ خروجیِ سوال‌ها | چند سوال را انتخاب کنید → اکشنِ «دریافتِ فایل» |
| وارد کردنِ **برنامه‌ی درسی** | `/admin/content/grade/` → «وارد کردنِ برنامه‌ی درسی از فایل» |
| گرفتنِ خروجیِ برنامه‌ی درسی | پایه‌ها را انتخاب کنید → اکشنِ «دریافتِ فایلِ برنامه‌ی درسی» |
| افزودنِ یک **پایه‌ی تازه** | `/admin/content/grade/add/` — عنوان به شکلِ «پایه چهارم»؛ شماره و id خودکار ۴ می‌شوند |
خروجیِ هر کدام **دقیقاً** همان قالبی‌ست که ورودی‌اش می‌پذیرد، پس چرخه‌ی
«بگیر → ویرایش کن → برگردان» بسته است و برای دیدنِ یک نمونه لازم نیست کسی

View File

@@ -76,9 +76,11 @@ curl -X POST https://api.math.tadrisino2.ir/api/auth/session \
می‌شود، پس رویش چیزی که مالِ خودتان باشد نگذارید. برای کارِ روزمره یک
`deviceId` مخصوصِ خودتان بسازید — هر رشته‌ای قبول است.
**۲. فهرستِ پایه‌ها تا ششم است.** `GRADES` در `App.tsx` هاردکدِ ۱ تا ۶ است، ولی
جهتِ محصول از ششم به بالا رفته (ترانزیشنی: ۶، ۷، ۹، ۱۰). از `api.grades()`
بخوانید تا هر پایه‌ای که اضافه شد خودبه‌خود بیاید.
**۲. فهرستِ پایه‌ها از `api.grades()` می‌آید** (انجام شده). ترتیب همان ترتیبِ
کلاس‌هاست و `id` هر پایه برابرِ شماره‌اش است: `gradeId: 8` یعنی پایه‌ی هشتم.
فیلدِ `number` هم همین عدد را صریح می‌دهد. پیش از ۲۰۲۶-۰۹-۲۳ idها ترتیبِ
واردشدنِ کتاب‌ها بودند (هشتم=۵)؛ اپ چون همیشه `gradeId` را از سرور می‌گیرد،
تغییری لازم نداشت.
**۳. صفحه‌ی درس، ویدیو دارد.** `LessonScreen` فهرستی از
`{type: "video" | "quiz"}` نشان می‌دهد. بک‌اند هنوز مفهومِ ویدیو ندارد —

View File

@@ -41,10 +41,14 @@ export interface HomeResponse {
}
export interface Grade {
/** برابرِ شماره‌ی پایه: ۸ یعنی هشتم. */
id: number;
/** شماره‌ی پایه (۱ تا ۱۲)؛ برای پایه‌های نمونه null. */
number: number | null;
title: string;
icon: string;
color: string;
lessonCount: number;
}
export interface UserSummary {