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:
20
CLAUDE.md
20
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.
|
**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`.
|
**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
|
the frontend domain to this backend, which makes everything same-origin and removes CORS
|
||||||
entirely. That needs the server admin, not us.
|
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
|
**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
|
frontend is tested while `api.math.tadrisino2.ir` is down, every screen fails and it
|
||||||
looks like the client is broken.
|
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 نیست** — علتش یک باگِ
|
- تستِ همزمانیِ campaigns که مدتها flaky بود **دیگر flaky نیست** — علتش یک باگِ
|
||||||
واقعی بود، نه طبیعتِ تست؛ پایینِ همین فایل توضیح داده شده.
|
واقعی بود، نه طبیعتِ تست؛ پایینِ همین فایل توضیح داده شده.
|
||||||
@@ -389,6 +401,10 @@ anyone who can read this repo can forge a token for any user.**
|
|||||||
گفتگوی RAG، تیکت با پاسخ و زیرمجموعه دارند؛ لیدربرد با `rebuild_leaderboard` از فعالیتِ
|
گفتگوی RAG، تیکت با پاسخ و زیرمجموعه دارند؛ لیدربرد با `rebuild_leaderboard` از فعالیتِ
|
||||||
واقعی ساخته شد. `purge_demo_data` هیچکدام را نمیگیرد و باید پیش از آمدنِ کاربرِ واقعی
|
واقعی ساخته شد. `purge_demo_data` هیچکدام را نمیگیرد و باید پیش از آمدنِ کاربرِ واقعی
|
||||||
پاک شوند. بکاپِ پیش از آن: `.deploy-backups/db-before-demo-20260923-213830.sqlite3`.
|
پاک شوند. بکاپِ پیش از آن: `.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/` از قالبِ خودمان رد میشوند**
|
- **صفحههای `/api/docs/` و `/api/redoc/` از قالبِ خودمان رد میشوند**
|
||||||
(`templates/tadrisino/`) تا متنِ فارسی راستبهچپ رندر شود. قالبِ drf-spectacular
|
(`templates/tadrisino/`) تا متنِ فارسی راستبهچپ رندر شود. قالبِ drf-spectacular
|
||||||
دستنخورده است و با `{{ block.super }}` گسترش داده شده.
|
دستنخورده است و با `{{ block.super }}` گسترش داده شده.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
| **آدرسِ زنده** | `https://api.math.tadrisino2.ir` · [سلامت](https://api.math.tadrisino2.ir/api/health) |
|
| **آدرسِ زنده** | `https://api.math.tadrisino2.ir` · [سلامت](https://api.math.tadrisino2.ir/api/health) |
|
||||||
| **استک** | Python 3.11+ · Django 5.2 · DRF · SQLite · Qdrant (RAG) |
|
| **استک** | Python 3.11+ · Django 5.2 · DRF · SQLite · Qdrant (RAG) |
|
||||||
| **API** | ۵۶ اندپوینت · مستنداتِ زنده در `/api/docs/` |
|
| **API** | ۵۶ اندپوینت · مستنداتِ زنده در `/api/docs/` |
|
||||||
| **تست** | ۴۸۳ تست، همه سبز (`python manage.py test`) |
|
| **تست** | ۴۸۹ تست، همه سبز (`python manage.py test`) |
|
||||||
| **محتوا** | ۸ پایه · ۵۸ فصل · ۲۶۳ نود · ۲٬۶۱۷ درس · ۲٬۴۶۳ سوال |
|
| **محتوا** | ۸ پایه · ۵۸ فصل · ۲۶۳ نود · ۲٬۶۱۷ درس · ۲٬۴۶۳ سوال |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class GradeAdmin(admin.ModelAdmin):
|
|||||||
سرور بود که به Qdrant و به دسترسیِ SSH وابسته بودند.
|
سرور بود که به Qdrant و به دسترسیِ SSH وابسته بودند.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
list_display = ('id', 'title', 'icon', 'color')
|
list_display = ('number', 'title', 'id', 'icon', 'color')
|
||||||
search_fields = ('title',)
|
search_fields = ('title',)
|
||||||
actions = ['export_curriculum_action']
|
actions = ['export_curriculum_action']
|
||||||
change_list_template = 'admin/content/grade/change_list.html'
|
change_list_template = 'admin/content/grade/change_list.html'
|
||||||
@@ -84,6 +84,9 @@ class GradeAdmin(admin.ModelAdmin):
|
|||||||
class UnitAdmin(admin.ModelAdmin):
|
class UnitAdmin(admin.ModelAdmin):
|
||||||
list_display = ('id', 'title', 'grade', 'order')
|
list_display = ('id', 'title', 'grade', 'order')
|
||||||
list_filter = ('grade',)
|
list_filter = ('grade',)
|
||||||
|
# فیلترِ «فصل» در صفحهی سوالها هم از همین ترتیب میآید: پایهبهپایه، نه
|
||||||
|
# همهی «فصل ۱»ها پشتِ هم.
|
||||||
|
ordering = ('grade__number', 'grade_id', 'order')
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Node)
|
@admin.register(Node)
|
||||||
|
|||||||
22
content/migrations/0007_grade_number.py
Normal file
22
content/migrations/0007_grade_number.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
105
content/migrations/0008_grade_id_equals_number.py
Normal file
105
content/migrations/0008_grade_id_equals_number.py
Normal 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),
|
||||||
|
]
|
||||||
@@ -1,10 +1,48 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
from django.db import models
|
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):
|
class Grade(models.Model):
|
||||||
title = models.CharField(max_length=50)
|
title = models.CharField(max_length=50)
|
||||||
icon = models.CharField(max_length=100)
|
icon = models.CharField(max_length=100)
|
||||||
color = models.CharField(max_length=10)
|
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):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class GradeSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Grade
|
model = Grade
|
||||||
fields = ['id', 'title', 'icon', 'color', 'lessonCount']
|
fields = ['id', 'number', 'title', 'icon', 'color', 'lessonCount']
|
||||||
|
|
||||||
|
|
||||||
class GradesListResponseSerializer(serializers.Serializer):
|
class GradesListResponseSerializer(serializers.Serializer):
|
||||||
|
|||||||
82
content/tests_grade_number.py
Normal file
82
content/tests_grade_number.py
Normal 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})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
from django.db.models import Count
|
from django.db.models import Count, F
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
import random
|
import random
|
||||||
|
|
||||||
@@ -87,10 +87,10 @@ def _grades_payload() -> dict:
|
|||||||
# هنوز خالی است و کاربر صفحهی خالی میبیند.
|
# هنوز خالی است و کاربر صفحهی خالی میبیند.
|
||||||
grades = Grade.objects.annotate(
|
grades = Grade.objects.annotate(
|
||||||
_lessons=Count('units__nodes__lessons', distinct=True),
|
_lessons=Count('units__nodes__lessons', distinct=True),
|
||||||
).order_by('id')
|
).order_by(F('number').asc(nulls_last=True), 'id')
|
||||||
return {'grades': [
|
return {'grades': [
|
||||||
{'id': g.id, 'title': g.title, 'icon': g.icon, 'color': g.color,
|
{'id': g.id, 'number': g.number, 'title': g.title, 'icon': g.icon,
|
||||||
'lessonCount': g._lessons}
|
'color': g.color, 'lessonCount': g._lessons}
|
||||||
for g in grades
|
for g in grades
|
||||||
]}
|
]}
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,13 @@ PaymentRequest را میزند، `Order.gateway_authority` را ذخیره م
|
|||||||
هنوز SQLite است (`OPTIONS.timeout=20` تا قفلهای همزمانیِ نوشتن را تحمل کند).
|
هنوز SQLite است (`OPTIONS.timeout=20` تا قفلهای همزمانیِ نوشتن را تحمل کند).
|
||||||
مهاجرت به Postgres انجام نشده.
|
مهاجرت به Postgres انجام نشده.
|
||||||
|
|
||||||
|
**idِ هر پایه همان شمارهی پایه است** (سوم=۳، هشتم=۸، دوازدهم=۱۲) و در فیلدِ
|
||||||
|
`Grade.number` هم هست. پایهی تازه id را از شمارهاش میگیرد، نه از شمارندهی
|
||||||
|
دیتابیس؛ شماره اگر خالی بماند از عنوان («پایه چهارم») خوانده میشود. پایهی
|
||||||
|
بیشماره ([DEMO]/[SEED]) idِ بالای ۱۰۰ میگیرد. همهجا — API، ادمین، فیلترِ
|
||||||
|
سوالها — پایهها بهترتیبِ شمارهاند. تا migrationِ `content.0008` id ترتیبِ
|
||||||
|
واردشدنِ کتابها بود (نهم=۱، هشتم=۵).
|
||||||
|
|
||||||
⚠️ **`.iterator()` را دورِ حلقهای که مینویسد یا I/O بیرونی میزند نگذارید.**
|
⚠️ **`.iterator()` را دورِ حلقهای که مینویسد یا I/O بیرونی میزند نگذارید.**
|
||||||
کرسرِ خواندن در تمامِ طولِ حلقه باز میماند و روی SQLite جدول را برای هر
|
کرسرِ خواندن در تمامِ طولِ حلقه باز میماند و روی SQLite جدول را برای هر
|
||||||
نویسندهی همزمان قفل میکند — قفلی که تا پایانِ حلقه رها نمیشود و هیچ
|
نویسندهی همزمان قفل میکند — قفلی که تا پایانِ حلقه رها نمیشود و هیچ
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
| گرفتنِ خروجیِ سوالها | چند سوال را انتخاب کنید → اکشنِ «دریافتِ فایل» |
|
| گرفتنِ خروجیِ سوالها | چند سوال را انتخاب کنید → اکشنِ «دریافتِ فایل» |
|
||||||
| وارد کردنِ **برنامهی درسی** | `/admin/content/grade/` → «وارد کردنِ برنامهی درسی از فایل» |
|
| وارد کردنِ **برنامهی درسی** | `/admin/content/grade/` → «وارد کردنِ برنامهی درسی از فایل» |
|
||||||
| گرفتنِ خروجیِ برنامهی درسی | پایهها را انتخاب کنید → اکشنِ «دریافتِ فایلِ برنامهی درسی» |
|
| گرفتنِ خروجیِ برنامهی درسی | پایهها را انتخاب کنید → اکشنِ «دریافتِ فایلِ برنامهی درسی» |
|
||||||
|
| افزودنِ یک **پایهی تازه** | `/admin/content/grade/add/` — عنوان به شکلِ «پایه چهارم»؛ شماره و id خودکار ۴ میشوند |
|
||||||
|
|
||||||
خروجیِ هر کدام **دقیقاً** همان قالبیست که ورودیاش میپذیرد، پس چرخهی
|
خروجیِ هر کدام **دقیقاً** همان قالبیست که ورودیاش میپذیرد، پس چرخهی
|
||||||
«بگیر → ویرایش کن → برگردان» بسته است و برای دیدنِ یک نمونه لازم نیست کسی
|
«بگیر → ویرایش کن → برگردان» بسته است و برای دیدنِ یک نمونه لازم نیست کسی
|
||||||
|
|||||||
@@ -76,9 +76,11 @@ curl -X POST https://api.math.tadrisino2.ir/api/auth/session \
|
|||||||
میشود، پس رویش چیزی که مالِ خودتان باشد نگذارید. برای کارِ روزمره یک
|
میشود، پس رویش چیزی که مالِ خودتان باشد نگذارید. برای کارِ روزمره یک
|
||||||
`deviceId` مخصوصِ خودتان بسازید — هر رشتهای قبول است.
|
`deviceId` مخصوصِ خودتان بسازید — هر رشتهای قبول است.
|
||||||
|
|
||||||
**۲. فهرستِ پایهها تا ششم است.** `GRADES` در `App.tsx` هاردکدِ ۱ تا ۶ است، ولی
|
**۲. فهرستِ پایهها از `api.grades()` میآید** (انجام شده). ترتیب همان ترتیبِ
|
||||||
جهتِ محصول از ششم به بالا رفته (ترانزیشنی: ۶، ۷، ۹، ۱۰). از `api.grades()`
|
کلاسهاست و `id` هر پایه برابرِ شمارهاش است: `gradeId: 8` یعنی پایهی هشتم.
|
||||||
بخوانید تا هر پایهای که اضافه شد خودبهخود بیاید.
|
فیلدِ `number` هم همین عدد را صریح میدهد. پیش از ۲۰۲۶-۰۹-۲۳ idها ترتیبِ
|
||||||
|
واردشدنِ کتابها بودند (هشتم=۵)؛ اپ چون همیشه `gradeId` را از سرور میگیرد،
|
||||||
|
تغییری لازم نداشت.
|
||||||
|
|
||||||
**۳. صفحهی درس، ویدیو دارد.** `LessonScreen` فهرستی از
|
**۳. صفحهی درس، ویدیو دارد.** `LessonScreen` فهرستی از
|
||||||
`{type: "video" | "quiz"}` نشان میدهد. بکاند هنوز مفهومِ ویدیو ندارد —
|
`{type: "video" | "quiz"}` نشان میدهد. بکاند هنوز مفهومِ ویدیو ندارد —
|
||||||
|
|||||||
@@ -41,10 +41,14 @@ export interface HomeResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Grade {
|
export interface Grade {
|
||||||
|
/** برابرِ شمارهی پایه: ۸ یعنی هشتم. */
|
||||||
id: number;
|
id: number;
|
||||||
|
/** شمارهی پایه (۱ تا ۱۲)؛ برای پایههای نمونه null. */
|
||||||
|
number: number | null;
|
||||||
title: string;
|
title: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
lessonCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserSummary {
|
export interface UserSummary {
|
||||||
|
|||||||
Reference in New Issue
Block a user