Files
MathApp/content/curriculum_io.py
Zareian 7307bfcadb Remove silent fallbacks found in the 2026-10-03 audit
- /api/ai/ask: the stub answer is reachable only under DEBUG or
  RAG_FORCE_MOCK. Without RAG keys on the server it used to return 200 with
  "this is a stub, set OPENAI_API_KEY" and spend the student's quota; now 503.
- /api/home: the `grade` alias is gone and sending it is a 400 instead of a
  silent fallback to the user's own grade. No client sent it.
- activate_subscription logs ERROR when the paid order's plan no longer exists
  (it silently became 30 days).
- resolve_grade_title logs instead of `except: pass`; curriculum_io no longer
  reads an `intro_text` key no file contains.
- CLAUDE.md: "No silent fallbacks" rules for future agents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:38:42 +03:30

214 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
وارد/خارج کردنِ کلِ برنامه‌ی درسی (Grade → Unit → Node → Lesson) به‌صورتِ فایل.
چرا جدا از question_io: سوال‌ها برگ‌های درخت‌اند، ولی خودِ درخت هم باید به
سرور برسد و تا امروز تنها راهش اجرای چند دستور روی همان سرور بود
(rag_import_collection، سپس seed_curriculum_from_textbook) — که هر دو به
Qdrant و به آدمی با دسترسیِ SSH وابسته‌اند.
با این فایل، برنامه‌ی درسی یک‌بار و لوکال ساخته می‌شود، خروجی گرفته می‌شود، و
از پنلِ ادمینِ سرور بالا می‌رود. سرور نه به Qdrant نیاز دارد نه به ترمینال.
قالب، همان ساختارِ تو‌در‌توی خودِ درس‌ها است تا خواندنش برای آدم آسان باشد:
{
"grades": [
{
"title": "پایه نهم", "icon": "grade-9", "color": "#FF9800",
"units": [
{
"title": "فصل ۴: توان و ریشه", "order": 4,
"nodes": [
{
"title": "توان صحیح", "order": 1,
"lessons": [
{"title": "ضرب توان‌ها", "introText": "..."}
]
}
]
}
]
}
]
}
هم‌سان‌سازی با عنوان انجام می‌شود، نه با id: idها بینِ دو دیتابیس یکی نیستند و
تکیه بر آن‌ها یعنی وارد کردن روی سرور محتوای بی‌ربط را بازنویسی می‌کند.
"""
import json
from django.db import transaction
from .models import Grade, Lesson, Node, Unit
class CurriculumError(Exception):
"""خطای قابلِ نمایش به کاربر (نه باگ)."""
def _text(value, field: str, where: str) -> str:
if not isinstance(value, str) or not value.strip():
raise CurriculumError(f'{where}: «{field}» خالی یا نامعتبر است.')
return value.strip()
def parse(raw: bytes) -> list[dict]:
try:
data = json.loads(raw.decode('utf-8'))
except UnicodeDecodeError:
raise CurriculumError('فایل باید UTF-8 باشد.')
except json.JSONDecodeError as exc:
raise CurriculumError(f'JSON نامعتبر است: {exc.msg} (خط {exc.lineno})')
grades = data.get('grades') if isinstance(data, dict) else data
if not isinstance(grades, list) or not grades:
raise CurriculumError('فایل باید فهرستی از پایه‌ها زیرِ کلیدِ «grades» داشته باشد.')
cleaned = []
for gi, grade in enumerate(grades):
gwhere = f'پایه {gi + 1}'
if not isinstance(grade, dict):
raise CurriculumError(f'{gwhere}: باید شیء باشد.')
units = grade.get('units') or []
if not isinstance(units, list):
raise CurriculumError(f'{gwhere}: «units» باید فهرست باشد.')
clean_units = []
for ui, unit in enumerate(units):
uwhere = f'{gwhere} › فصل {ui + 1}'
nodes = unit.get('nodes') or []
if not isinstance(nodes, list):
raise CurriculumError(f'{uwhere}: «nodes» باید فهرست باشد.')
clean_nodes = []
for ni, node in enumerate(nodes):
nwhere = f'{uwhere} › درس {ni + 1}'
lessons = node.get('lessons') or []
if not isinstance(lessons, list):
raise CurriculumError(f'{nwhere}: «lessons» باید فهرست باشد.')
clean_nodes.append({
'title': _text(node.get('title'), 'title', nwhere),
'order': int(node.get('order') or ni + 1),
'lessons': [
{
'title': _text(lesson.get('title'), 'title', f'{nwhere} › نوع {li + 1}'),
'intro_text': (lesson.get('introText') or ''),
'example': str(lesson.get('example') or ''),
'steps': [str(x) for x in (lesson.get('steps') or []) if str(x).strip()][:12],
}
for li, lesson in enumerate(lessons)
],
})
clean_units.append({
'title': _text(unit.get('title'), 'title', uwhere),
'blurb': str(unit.get('blurb') or '')[:120],
'order': int(unit.get('order') or ui + 1),
'nodes': clean_nodes,
})
cleaned.append({
'title': _text(grade.get('title'), 'title', gwhere),
'icon': (grade.get('icon') or ''),
'color': (grade.get('color') or ''),
'units': clean_units,
})
return cleaned
@transaction.atomic
def import_curriculum(grades: list[dict]) -> dict:
"""
idempotent: اجرای دوباره‌ی همان فایل چیزی را دو بار نمی‌سازد.
چیزی را هم پاک نمی‌کند — درسی که در فایل نیست دست‌نخورده می‌ماند. پاک‌کردن
عمداً اینجا نیست: یک فایلِ ناقص نباید محتوای سرور را از بین ببرد.
"""
counts = {'grades': 0, 'units': 0, 'nodes': 0, 'lessons': 0, 'updated': 0}
for grade_row in grades:
grade, created = Grade.objects.get_or_create(
title=grade_row['title'],
defaults={'icon': grade_row['icon'], 'color': grade_row['color']},
)
counts['grades'] += 1 if created else 0
for unit_row in grade_row['units']:
unit, created = Unit.objects.get_or_create(
grade=grade, title=unit_row['title'],
defaults={'order': unit_row['order']},
)
counts['units'] += 1 if created else 0
# متنِ ادمین برنده است: فقط خانه‌ی خالی از فایل پر می‌شود
if unit_row.get('blurb') and not unit.blurb:
unit.blurb = unit_row['blurb']
unit.save(update_fields=['blurb'])
for node_row in unit_row['nodes']:
node, created = Node.objects.get_or_create(
unit=unit, title=node_row['title'],
defaults={'order': node_row['order']},
)
counts['nodes'] += 1 if created else 0
for lesson_row in node_row['lessons']:
lesson, created = Lesson.objects.get_or_create(
node=node, title=lesson_row['title'],
defaults={'intro_text': lesson_row['intro_text']},
)
changed = []
if created:
counts['lessons'] += 1
elif lesson_row['intro_text'] and lesson.intro_text != lesson_row['intro_text']:
lesson.intro_text = lesson_row['intro_text']
changed.append('intro_text')
# مثال و قدم‌ها فقط وقتی از فایل می‌آیند که در دیتابیس خالی‌اند؛ آنچه
# کسی از پنلِ ادمین نوشته با دیپلوی بازنویسی نمی‌شود.
if lesson_row.get('example') and not lesson.example:
lesson.example = lesson_row['example']
changed.append('example')
if lesson_row.get('steps') and not lesson.steps:
lesson.steps = lesson_row['steps']
changed.append('steps')
if changed:
lesson.save(update_fields=changed)
if not created:
counts['updated'] += 1
return counts
def export_curriculum(grades_queryset) -> dict:
"""خروجی دقیقاً همان قالبی‌ست که import می‌خواند."""
out = []
for grade in grades_queryset.prefetch_related('units__nodes__lessons'):
out.append({
'title': grade.title,
'icon': grade.icon,
'color': grade.color,
'units': [
{
'title': unit.title,
'order': unit.order,
**({'blurb': unit.blurb} if unit.blurb else {}),
'nodes': [
{
'title': node.title,
'order': node.order,
'lessons': [
{
'title': lesson.title, 'introText': lesson.intro_text,
**({'example': lesson.example} if lesson.example else {}),
**({'steps': lesson.steps} if lesson.steps else {}),
}
for lesson in node.lessons.all()
],
}
for node in unit.nodes.all()
],
}
for unit in grade.units.all()
],
})
return {'grades': out}