diff --git a/CLAUDE.md b/CLAUDE.md index 8c4e160..b63fd79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 }}` گسترش داده شده. diff --git a/README.md b/README.md index a0ed6b6..3dd9afc 100644 --- a/README.md +++ b/README.md @@ -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`) | | **محتوا** | ۸ پایه · ۵۸ فصل · ۲۶۳ نود · ۲٬۶۱۷ درس · ۲٬۴۶۳ سوال | --- diff --git a/content/admin.py b/content/admin.py index b4457d7..554996b 100644 --- a/content/admin.py +++ b/content/admin.py @@ -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) diff --git a/content/migrations/0007_grade_number.py b/content/migrations/0007_grade_number.py new file mode 100644 index 0000000..a74e458 --- /dev/null +++ b/content/migrations/0007_grade_number.py @@ -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), + ), + ] diff --git a/content/migrations/0008_grade_id_equals_number.py b/content/migrations/0008_grade_id_equals_number.py new file mode 100644 index 0000000..f9db94e --- /dev/null +++ b/content/migrations/0008_grade_id_equals_number.py @@ -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), + ] diff --git a/content/models.py b/content/models.py index 9409600..27c7780 100644 --- a/content/models.py +++ b/content/models.py @@ -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 diff --git a/content/serializers.py b/content/serializers.py index e43e6f7..8dd302e 100644 --- a/content/serializers.py +++ b/content/serializers.py @@ -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): diff --git a/content/tests_grade_number.py b/content/tests_grade_number.py new file mode 100644 index 0000000..69252fc --- /dev/null +++ b/content/tests_grade_number.py @@ -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}) diff --git a/content/views.py b/content/views.py index edef678..bb89409 100644 --- a/content/views.py +++ b/content/views.py @@ -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 ]} diff --git a/docs/architecture.md b/docs/architecture.md index 3a05403..a044a7a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 جدول را برای هر نویسنده‌ی هم‌زمان قفل می‌کند — قفلی که تا پایانِ حلقه رها نمی‌شود و هیچ diff --git a/docs/content-authoring.md b/docs/content-authoring.md index 7ff8c96..673be95 100644 --- a/docs/content-authoring.md +++ b/docs/content-authoring.md @@ -44,6 +44,7 @@ | گرفتنِ خروجیِ سوال‌ها | چند سوال را انتخاب کنید → اکشنِ «دریافتِ فایل» | | وارد کردنِ **برنامه‌ی درسی** | `/admin/content/grade/` → «وارد کردنِ برنامه‌ی درسی از فایل» | | گرفتنِ خروجیِ برنامه‌ی درسی | پایه‌ها را انتخاب کنید → اکشنِ «دریافتِ فایلِ برنامه‌ی درسی» | +| افزودنِ یک **پایه‌ی تازه** | `/admin/content/grade/add/` — عنوان به شکلِ «پایه چهارم»؛ شماره و id خودکار ۴ می‌شوند | خروجیِ هر کدام **دقیقاً** همان قالبی‌ست که ورودی‌اش می‌پذیرد، پس چرخه‌ی «بگیر → ویرایش کن → برگردان» بسته است و برای دیدنِ یک نمونه لازم نیست کسی diff --git a/docs/frontend/README.md b/docs/frontend/README.md index 25f3998..b6f0025 100644 --- a/docs/frontend/README.md +++ b/docs/frontend/README.md @@ -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"}` نشان می‌دهد. بک‌اند هنوز مفهومِ ویدیو ندارد — diff --git a/docs/frontend/api.ts b/docs/frontend/api.ts index 15bbfca..f51b4a1 100644 --- a/docs/frontend/api.ts +++ b/docs/frontend/api.ts @@ -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 {