Audit fixes 2026-10-03: seed keeps admin edits, plan cache, refresh limit, payment and tutor guards

- seed_content no longer undoes admin work: a SeedRecord ledger (content/seed_sync.py)
  stops deleted/renamed/re-worded content from being recreated and keeps admin-edited
  lesson text; new rows in the seed files still arrive (migration content.0010)
- plan list cache is invalidated on Plan save (receiver used a stale 'v1' key)
- token refresh per-IP cap 30/hour -> 600/hour (TOKEN_REFRESH_IP_RATE)
- a failed gateway result only changes a pending order (forged NOK no longer
  overwrites refunded/canceled)
- tutor gets the correct answer only after the student has answered
- support reply notification moved to a TicketMessage post_save signal
- global daily cap on paid AI questions (AI_GLOBAL_DAILY_MAX, default 3000)
- Bazaar verify uses only the order's SKU; bazaar orders require a SKU
- /api/health warns on the committed signing key; deploy.sh saves local commits;
  400 for bad ?unit= / ?period=; account delete keeps the referrer's Conversion
- earlier today: no stub answers outside DEBUG, ?grade= removed, logged fallbacks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 16:05:27 +03:30
parent 7e027c167c
commit ab5e53730a
31 changed files with 623 additions and 49 deletions

View File

@@ -110,6 +110,10 @@ BAZAAR_REFRESH_TOKEN=
# سقفِ خواندن روی اندپوینت‌های پرترافیک، و سقفِ /api/health. # سقفِ خواندن روی اندپوینت‌های پرترافیک، و سقفِ /api/health.
# READ_THROTTLE_RATE=120/min # READ_THROTTLE_RATE=120/min
# HEALTH_THROTTLE_RATE=60/min # HEALTH_THROTTLE_RATE=60/min
# سقفِ تمدیدِ توکن برای هر IP (همه‌ی کاربرانِ پشتِ یک وای‌فای یا CGNAT با هم).
# TOKEN_REFRESH_IP_RATE=600/hour
# سقفِ روزانه‌ی کلِ سامانه برای سوال‌هایی که به مدلِ هوش مصنوعی می‌رسند (ضدِ خرجِ بی‌سقف).
# AI_GLOBAL_DAILY_MAX=3000
# کشِ پاسخِ دستیار (ثانیه)؛ سوالِ تکراری دوباره به مدل نمی‌رود. # کشِ پاسخِ دستیار (ثانیه)؛ سوالِ تکراری دوباره به مدل نمی‌رود.
# RAG_ANSWER_CACHE_TTL=1800 # RAG_ANSWER_CACHE_TTL=1800
# 1 = دستیار حتی با کلیدها هم پاسخِ آزمایشی بدهد. # 1 = دستیار حتی با کلیدها هم پاسخِ آزمایشی بدهد.

View File

@@ -244,7 +244,7 @@ actually connected without asking a human.
(2,463 multiple-choice questions). `deploy.sh` runs `seed_content` on every release, so a (2,463 multiple-choice questions). `deploy.sh` runs `seed_content` on every release, so a
fresh server fills itself with one command: **no Qdrant, no AI credit, no SSH session.** fresh server fills itself with one command: **no Qdrant, no AI credit, no SSH session.**
`seed_content` is idempotent and never deletes. Questions bind to lessons **by title path** `seed_content` is idempotent and never deletes, and since 2026-10-03 it does not undo admin work: a `SeedRecord` ledger (`content/seed_sync.py`) remembers what was delivered, so anything the admin later deleted, renamed or re-worded is not recreated, and lesson `intro_text` is refreshed from the file only if nobody edited it. New rows in the files still arrive; an empty DB ignores the ledger. Questions bind to lessons **by title path**
(grade › unit › node › lesson), never by primary key — ids differ between any two (grade › unit › node › lesson), never by primary key — ids differ between any two
databases, and binding by id silently attaches questions to the wrong lesson. databases, and binding by id silently attaches questions to the wrong lesson.

View File

@@ -424,7 +424,9 @@ def _upgradable_guest(request, phone: str):
DELETE_CONFIRM_WORD = 'حذف' DELETE_CONFIRM_WORD = 'حذف'
# سوابقِ پرداخت سندِ مالی‌اند و با حذفِ حساب نمی‌روند (تراکنش‌ها با سفارش می‌مانند). # سوابقِ پرداخت سندِ مالی‌اند و با حذفِ حساب نمی‌روند (تراکنش‌ها با سفارش می‌مانند).
KEPT_ON_ACCOUNT_DELETE = ('payment.Order', 'statuslog.StatusChangeLog') # Conversion هم می‌ماند: ردیفِ «کاربرِ معرفی‌شده» سابقه‌ی پورسانتِ *معرف* است، نه داده‌ی این
# کاربر؛ تا ۲۰۲۶-۱۰-۰۳ با حذفِ حسابِ معرفی‌شده پورسانتِ معرف هم پاک می‌شد.
KEPT_ON_ACCOUNT_DELETE = ('payment.Order', 'statuslog.StatusChangeLog', 'affiliate.Conversion')
class DeleteAccountView(APIView): class DeleteAccountView(APIView):

View File

@@ -529,3 +529,20 @@ class StubNeverReachesProductionTests(TestCase):
) )
self.assertEqual(response.status_code, 503) self.assertEqual(response.status_code, 503)
self.assertFalse(Message.objects.filter(conversation__user_id=user.id).exists()) self.assertFalse(Message.objects.filter(conversation__user_id=user.id).exists())
class GlobalDailyCapTests(TestCase):
"""سقفِ روزانه‌ی کلِ سامانه برای سوال‌هایی که به مدل می‌رسند (AI_GLOBAL_DAILY_MAX)."""
@override_settings(AI_GLOBAL_DAILY_MAX=2)
def test_the_whole_system_stops_at_the_daily_cap(self):
cache.clear()
codes = []
for i in range(3):
user = User.objects.create(device_id=f'cap-{i}', is_guest=True, name='')
codes.append(APIClient().post(
'/api/ai/ask', {'userQuestion': f'۲+{i} چند است؟'}, format='json',
HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}',
REMOTE_ADDR=f'10.0.0.{i}',
).status_code)
self.assertEqual(codes, [200, 200, 503])

View File

@@ -197,6 +197,28 @@ def _question_focus(v: dict, user) -> str:
return question_focus(question_id, user.id) return question_focus(question_id, user.id)
class AIDailyCapReached(APIException):
status_code = 503
default_detail = 'دستیار امروز به سقفِ استفاده رسیده؛ لطفاً فردا دوباره بپرس.'
default_code = 'ai_daily_cap'
def _check_global_daily_cap() -> None:
"""
سقفِ روزانه‌ی کلِ سامانه (AI_GLOBAL_DAILY_MAX): سوال‌های امروز که به مدل رفته‌اند یا با کش
برگشته‌اند ولی هنوز «free»/«credit» مانده‌اند. در دیتابیس شمرده می‌شود، پس بینِ workerها
مشترک است. داخلِ همان تراکنشِ IMMEDIATEِ فازِ ۱ صدا زده می‌شود؛ روی Postgres دو درخواستِ
هم‌زمان ممکن است هر دو از لبه رد شوند (چند سوال بیشتر از سقف، نه بیشتر).
"""
from django.conf import settings
start = timezone.localtime().replace(hour=0, minute=0, second=0, microsecond=0)
used = Message.objects.filter(role='user', created_at__gte=start, charge__in=('free', 'credit')).count()
if used >= settings.AI_GLOBAL_DAILY_MAX:
logger.error('AI global daily cap reached: cap=%s used=%s', settings.AI_GLOBAL_DAILY_MAX, used)
raise AIDailyCapReached()
class AIProviderUnavailable(APIException): class AIProviderUnavailable(APIException):
""" """
⚠️ افزوده شده: وقتی generate_tutor_answer به یک AI provider واقعی ⚠️ افزوده شده: وقتی generate_tutor_answer به یک AI provider واقعی
@@ -326,6 +348,8 @@ class AskAIView(APIView):
conversation__user_id=user.id, role='user', created_at__date=today, charge='free', conversation__user_id=user.id, role='user', created_at__date=today, charge='free',
).count() ).count()
quota = daily_quota(user.id) quota = daily_quota(user.id)
if not screened:
_check_global_daily_cap()
if screened: if screened:
charge = 'none' charge = 'none'
elif used_today < quota: elif used_today < quota:

View File

@@ -170,6 +170,13 @@ def _warnings() -> list[str]:
'شماره‌های تستِ ورود با کدِ ثابت فعال‌اند (TEST_LOGIN_PHONES). ' 'شماره‌های تستِ ورود با کدِ ثابت فعال‌اند (TEST_LOGIN_PHONES). '
'برای توسعه لازم است؛ پیش از تحویلِ نهایی به کاربرانِ واقعی حذفشان کنید.' 'برای توسعه لازم است؛ پیش از تحویلِ نهایی به کاربرانِ واقعی حذفشان کنید.'
) )
if not settings.DEBUG and settings.SECRET_KEY.startswith('django-insecure'):
# بزرگ‌ترین ریسکِ باز (docs/security-before-production.md): این کلید در گیت است و هر
# کس ریپو را ببیند برای هر کاربری توکن می‌سازد. فقط بله/خیر، نه خودِ کلید.
out.append(
'کلیدِ امضای توکن همان کلیدِ کامیت‌شده در ریپو است (django-insecure): هر کس ریپو را '
'ببیند می‌تواند به‌جای هر کاربری وارد شود. راهِ رفع در docs/security-before-production.md.'
)
if _ai_provider_state().get('state') == 'quota_exhausted': if _ai_provider_state().get('state') == 'quota_exhausted':
out.append( out.append(
'اعتبارِ سرویسِ هوش مصنوعی (GapGPT) تمام شده: دستیار برای همه‌ی کاربرها ' 'اعتبارِ سرویسِ هوش مصنوعی (GapGPT) تمام شده: دستیار برای همه‌ی کاربرها '

View File

@@ -463,7 +463,12 @@ REST_FRAMEWORK = {
# به UX عادی دست نمی‌زنه. # به UX عادی دست نمی‌زنه.
'read': os.environ.get('READ_THROTTLE_RATE', '120/min'), 'read': os.environ.get('READ_THROTTLE_RATE', '120/min'),
'purchase': '10/hour', 'purchase': '10/hour',
'token_refresh': '30/hour', # بی‌توکن صدا زده می‌شود، پس سهمِ همه‌ی کاربرانِ یک IP مشترک است و هر کاربرِ فعال
# ساعتی یک بار تمدید می‌کند (توکنِ دسترسی یک‌ساعته). تا ۲۰۲۶-۱۰-۰۳ سی بود: سی‌ویکمین
# دانش‌آموزِ یک وای‌فایِ مدرسه یا یک CGNATِ موبایل ۴۲۹ می‌گرفت (بازتولید شد) و فرانت
# بیرونش می‌انداخت. refresh token یک‌بارمصرف و ۶۴ نویسه‌ی تصادفی است، پس حدس زدنش با
# سقفِ بالاتر هم ممکن نیست؛ این سقف فقط جلوی یک ماشینِ تنها را می‌گیرد.
'token_refresh': os.environ.get('TOKEN_REFRESH_IP_RATE', '600/hour'),
'question_answer': '60/min', 'question_answer': '60/min',
'question_report': '20/hour', 'question_report': '20/hour',
# هر درس اینجا یک نوع مسئله است با یکی-دو سوال، نه یک جلسه‌ی درسی؛ # هر درس اینجا یک نوع مسئله است با یکی-دو سوال، نه یک جلسه‌ی درسی؛
@@ -820,6 +825,11 @@ OPENAI_CHAT_MODEL = os.environ.get('OPENAI_CHAT_MODEL', 'gpt-4.1-mini')
# سقفِ سوالِ روزانه از دستیار برای کاربرِ بدونِ اشتراک (مشترکان: Plan.ai_daily_quota). # سقفِ سوالِ روزانه از دستیار برای کاربرِ بدونِ اشتراک (مشترکان: Plan.ai_daily_quota).
# ۳ پیشنهادِ docs/ai-cost-and-guardrails.md است؛ تا ۲۰۲۶-۱۰-۰۱ برای همه ۱۵ بود. # ۳ پیشنهادِ docs/ai-cost-and-guardrails.md است؛ تا ۲۰۲۶-۱۰-۰۱ برای همه ۱۵ بود.
AI_DAILY_QUOTA_FREE = int(os.environ.get('AI_DAILY_QUOTA_FREE', '3')) AI_DAILY_QUOTA_FREE = int(os.environ.get('AI_DAILY_QUOTA_FREE', '3'))
# سقفِ روزانه‌ی *کلِ سامانه* برای سوال‌هایی که به مدل می‌رسند (رایگان یا با اعتبار)، همان الگوی
# OTP_SMS_GLOBAL_DAILY_MAX. سقف‌های بالا برای هر کاربر و هر IP‌اند؛ کسی با چند IP و مهمان‌های
# تازه می‌توانست خرجِ GapGPT را بی‌سقف بالا ببرد. ۳۰۰۰ با ~۰٫۲ سنت برای هر سوال یعنی حدودِ ۶
# دلار در روز؛ رسیدن به آن ۵۰۳ و لاگِ ERROR می‌دهد. با رشدِ کاربرِ واقعی از .env بالا ببرید.
AI_GLOBAL_DAILY_MAX = int(os.environ.get('AI_GLOBAL_DAILY_MAX', '3000'))
# اختیاری: مدلِ جدا فقط برای پایه‌های دهم تا دوازدهم (حسابان/مثلثات). خالی یعنی # اختیاری: مدلِ جدا فقط برای پایه‌های دهم تا دوازدهم (حسابان/مثلثات). خالی یعنی
# همه‌ی پایه‌ها OPENAI_CHAT_MODEL. انتخابش با `rag_model_benchmark` سنجیده می‌شود. # همه‌ی پایه‌ها OPENAI_CHAT_MODEL. انتخابش با `rag_model_benchmark` سنجیده می‌شود.
OPENAI_CHAT_MODEL_UPPER = os.environ.get('OPENAI_CHAT_MODEL_UPPER', '') OPENAI_CHAT_MODEL_UPPER = os.environ.get('OPENAI_CHAT_MODEL_UPPER', '')

View File

@@ -6,7 +6,7 @@ from urllib.parse import urlsplit
from unittest.mock import patch from unittest.mock import patch
from django.conf import settings from django.conf import settings
from django.test import RequestFactory, SimpleTestCase, TestCase from django.test import RequestFactory, SimpleTestCase, TestCase, override_settings
from rest_framework.test import APIClient from rest_framework.test import APIClient
from accounts.authentication import issue_access_token from accounts.authentication import issue_access_token
@@ -476,3 +476,14 @@ class ApiDocsRtlShellTests(TestCase):
def test_redoc_carries_the_rtl_shell(self): def test_redoc_carries_the_rtl_shell(self):
html = self.client.get('/api/redoc/').content.decode() html = self.client.get('/api/redoc/').content.decode()
self.assertIn('direction: rtl', html) self.assertIn('direction: rtl', html)
class InsecureSecretKeyWarningTests(TestCase):
"""کلیدِ کامیت‌شده‌ی django-insecure در پروداکشن باید در /api/health دیده شود (۲۰۲۶-۱۰-۰۳)."""
def test_committed_key_is_reported(self):
from config import health
with override_settings(DEBUG=False, SECRET_KEY='django-insecure-xyz'):
self.assertIn('django-insecure', ' '.join(health._warnings()))
with override_settings(DEBUG=False, SECRET_KEY='a-real-random-key'):
self.assertNotIn('django-insecure', ' '.join(health._warnings()))

View File

@@ -24,7 +24,7 @@ from pathlib import Path
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from django.db import transaction from django.db import transaction
from content import curriculum_io, question_io from content import curriculum_io, question_io, seed_sync
from content.models import Grade, Lesson, Question from content.models import Grade, Lesson, Question
# ردیف‌های نمونه هرگز نباید در فایلِ ریپو بنشینند: این فایل مستقیم روی # ردیف‌های نمونه هرگز نباید در فایلِ ریپو بنشینند: این فایل مستقیم روی
@@ -119,10 +119,10 @@ class Command(BaseCommand):
# dry-run با rollback انجام می‌شود، نه با شمارشِ تقریبی: تنها چیزی که # dry-run با rollback انجام می‌شود، نه با شمارشِ تقریبی: تنها چیزی که
# واقعاً می‌گوید چه اتفاقی می‌افتد، خودِ اتفاق است. # واقعاً می‌گوید چه اتفاقی می‌افتد، خودِ اتفاق است.
# seed_sync کارِ ادمین (حذف، تغییرِ نام، اصلاحِ متن) را برنمی‌گرداند؛ شرح در خودِ ماژول.
try: try:
with transaction.atomic(): with transaction.atomic():
counts = curriculum_io.import_curriculum(grades) counts = seed_sync.sync(grades, self._question_rows())
question_counts = self._load_questions()
if dry_run: if dry_run:
transaction.set_rollback(True) transaction.set_rollback(True)
except (curriculum_io.CurriculumError, question_io.ImportError_) as exc: except (curriculum_io.CurriculumError, question_io.ImportError_) as exc:
@@ -134,23 +134,20 @@ class Command(BaseCommand):
f'{counts["grades"]} پایه، {counts["units"]} فصل، ' f'{counts["grades"]} پایه، {counts["units"]} فصل، '
f'{counts["nodes"]} مبحث، {counts["lessons"]} درسِ تازه ' f'{counts["nodes"]} مبحث، {counts["lessons"]} درسِ تازه '
f'({counts["updated"]} درس به‌روز شد)؛ ' f'({counts["updated"]} درس به‌روز شد)؛ '
f'{question_counts["created"]} سوالِ تازه ' f'{counts["created"]} سوالِ تازه '
f'({question_counts["skipped"]} سوال از قبل بود)' f'({counts["skipped"]} سوال از قبل بود)'
)) ))
if counts['retired'] or counts['kept_admin_text']:
self.stdout.write(
f' {counts["retired"]} موردِ فایل ساخته نشد چون ادمین پاکش کرده یا نامش را عوض کرده؛ '
f'{counts["kept_admin_text"]} متنِ درس که ادمین ویرایش کرده دست نخورد.'
)
def _load_questions(self) -> dict: def _question_rows(self) -> list:
if not QUESTIONS_FILE.exists(): if not QUESTIONS_FILE.exists():
return {'created': 0, 'skipped': 0} return []
raw = json.loads(QUESTIONS_FILE.read_text(encoding='utf-8')) raw = json.loads(QUESTIONS_FILE.read_text(encoding='utf-8'))
rows = raw.get('questions', raw) if isinstance(raw, dict) else raw return (raw.get('questions', raw) if isinstance(raw, dict) else raw) or []
if not rows:
return {'created': 0, 'skipped': 0}
# مسیرِ عنوان → lessonId، سپس همان اعتبارسنجیِ همیشگیِ question_io.
resolved = question_io.resolve_paths(rows)
parsed = question_io.parse(json.dumps(resolved).encode('utf-8'))
return question_io.import_questions(parsed, skip_existing=True)
def _has_questions(path: Path) -> bool: def _has_questions(path: Path) -> bool:

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.17 on 2026-10-03 08:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('content', '0009_usernodecompletion'),
]
operations = [
migrations.CreateModel(
name='SeedRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=40, unique=True)),
('kind', models.CharField(max_length=10)),
('value', models.CharField(blank=True, max_length=40)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
]

View File

@@ -318,3 +318,25 @@ class QuestionReport(models.Model):
def __str__(self): def __str__(self):
return f'report-{self.pk} on q{self.question_id} ({self.get_reason_display()})' return f'report-{self.pk} on q{self.question_id} ({self.get_reason_display()})'
class SeedRecord(models.Model):
"""
چیزی که `seed_content` یک بار از فایل‌های ریپو تحویل داده است (content/seed_sync.py).
چرا: پیش از ۲۰۲۶-۱۰-۰۳ هر دیپلوی هر چه در فایل بود و در دیتابیس پیدا نمی‌شد را دوباره
می‌ساخت. سوالی که ادمین پاک کرده بود برمی‌گشت، سوالی که صورتش را اصلاح کرده بود با صورتِ
قدیم کنارش تکرار می‌شد، فصلی که نامش عوض شده بود با همه‌ی درس‌ها و سوال‌هایش دوباره ساخته
می‌شد، و متنِ توضیحِ درس که در ادمین اصلاح شده بود با متنِ فایل بازنویسی می‌شد. این دفتر
می‌گوید «این را قبلاً داده‌ایم»؛ اگر حالا نیست، کارِ ادمین بوده و دوباره ساخته نمی‌شود.
`value` فقط برای متنِ توضیحِ درس است: هشِ آخرین متنی که سید نوشت. اگر متنِ دیتابیس هنوز
همان است، ادمین دستش نزده و متنِ تازه‌ی فایل جایش می‌نشیند؛ وگرنه متنِ ادمین می‌ماند.
"""
key = models.CharField(max_length=40, unique=True) # sha1 از نوع و مسیرِ عنوان
kind = models.CharField(max_length=10)
value = models.CharField(max_length=40, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f'seed-{self.kind}-{self.key[:8]}'

211
content/seed_sync.py Normal file
View File

@@ -0,0 +1,211 @@
"""
وارد کردنِ محتوای ریپو در دیپلوی، بدونِ برگرداندنِ کارِ ادمین (`seed_content`).
دو منبع داریم: فایل‌های `content/seed/` که با هر دیپلوی می‌آیند، و پنلِ ادمین. تا ۲۰۲۶-۱۰-۰۳
سید هر چه در فایل بود و در دیتابیس پیدا نمی‌کرد دوباره می‌ساخت؛ با تستِ دورریختنی دیده شد که
سوالِ پاک‌شده برمی‌گشت، سوالِ اصلاح‌شده نسخه‌ی غلطش را کنارش می‌گرفت، تغییرِ نامِ یک فصل ۳۳
درس و ۴۴ سوالِ تکراری می‌ساخت، و متنِ توضیحِ هر درس با متنِ فایل بازنویسی می‌شد.
قاعده‌ی تازه، با دفترِ `SeedRecord`:
- چیزی که در دیتابیس هست: ثبت می‌شود و دست نمی‌خورد (جز متنِ توضیح، پایین).
- چیزی که نیست و قبلاً هم تحویل نشده: ساخته و ثبت می‌شود (محتوای تازه‌ی ریپو).
- چیزی که نیست ولی قبلاً تحویل شده: ادمین پاکش کرده یا نامش را عوض کرده؛ ساخته نمی‌شود و
فقط شمرده می‌شود (`retired`). زیرمجموعه‌اش هم ساخته نمی‌شود.
- متنِ توضیحِ درس: اگر متنِ دیتابیس همان است که سید آخرین بار نوشت، متنِ تازه‌ی فایل جایش
می‌نشیند؛ اگر ادمین عوضش کرده، متنِ ادمین می‌ماند.
دیتابیسِ بی‌محتوا (سرورِ تازه، یا بازگرداندن پس از پاک شدنِ همه‌چیز) دفتر را نادیده می‌گیرد و
همه‌چیز را از فایل می‌سازد.
آن‌چه آزموده نشده: اولین دیپلو روی سرورِ فعلی دفتری خالی دارد؛ هر چه ادمین تا آن لحظه پاک کرده
همان یک بارِ آخر دوباره ساخته می‌شود (رفتارِ قبلی)، و از آن به بعد نه. متنِ توضیحی که در آن لحظه
با فایل فرق دارد، کارِ ادمین فرض می‌شود و هرگز از فایل بازنویسی نمی‌شود.
"""
import hashlib
import json
from django.db import transaction
from . import question_io
from .models import Grade, Lesson, Node, Question, SeedRecord, Unit
def _key(kind: str, *parts) -> str:
return hashlib.sha1('\x1f'.join((kind, *map(str, parts))).encode('utf-8')).hexdigest()
def _text_hash(text: str) -> str:
return hashlib.sha1((text or '').encode('utf-8')).hexdigest()
class _Ledger:
def __init__(self, active: bool):
self.known = dict(SeedRecord.objects.values_list('key', 'value')) if active else {}
self.write: dict[str, tuple[str, str]] = {}
def delivered(self, key: str) -> bool:
return key in self.known or key in self.write
def value(self, key: str):
if key in self.write:
return self.write[key][1]
return self.known.get(key)
def record(self, key: str, kind: str, value: str = '') -> None:
if self.known.get(key) != value or key not in self.known:
self.write[key] = (kind, value)
def save(self) -> None:
existing = set(SeedRecord.objects.filter(key__in=list(self.write)).values_list('key', flat=True))
SeedRecord.objects.bulk_create([
SeedRecord(key=k, kind=kind, value=value)
for k, (kind, value) in self.write.items() if k not in existing
])
for k, (_kind, value) in self.write.items():
if k in existing:
SeedRecord.objects.filter(key=k).update(value=value)
@transaction.atomic
def sync(grades: list[dict], question_rows: list[dict]) -> dict:
"""grades: خروجیِ curriculum_io.parse؛ question_rows: ردیف‌های مسیردارِ questions.json."""
ledger = _Ledger(active=Grade.objects.exists())
counts = {'grades': 0, 'units': 0, 'nodes': 0, 'lessons': 0, 'updated': 0,
'kept_admin_text': 0, 'retired': 0, 'created': 0, 'skipped': 0}
for g in grades:
gpath = (g['title'],)
grade = Grade.objects.filter(title=g['title']).first()
if grade is None:
if ledger.delivered(_key('grade', *gpath)):
counts['retired'] += 1
continue
grade = Grade.objects.create(title=g['title'], icon=g['icon'], color=g['color'])
counts['grades'] += 1
ledger.record(_key('grade', *gpath), 'grade')
for u in g['units']:
upath = gpath + (u['title'],)
unit = Unit.objects.filter(grade=grade, title=u['title']).first()
if unit is None:
if ledger.delivered(_key('unit', *upath)):
counts['retired'] += 1
continue
unit = Unit.objects.create(grade=grade, title=u['title'], order=u['order'])
counts['units'] += 1
ledger.record(_key('unit', *upath), 'unit')
if u.get('blurb') and not unit.blurb:
unit.blurb = u['blurb']
unit.save(update_fields=['blurb'])
for n in u['nodes']:
npath = upath + (n['title'],)
node = Node.objects.filter(unit=unit, title=n['title']).first()
if node is None:
if ledger.delivered(_key('node', *npath)):
counts['retired'] += 1
continue
node = Node.objects.create(unit=unit, title=n['title'], order=n['order'])
counts['nodes'] += 1
ledger.record(_key('node', *npath), 'node')
for row in n['lessons']:
_sync_lesson(node, npath + (row['title'],), row, ledger, counts)
_sync_questions(question_rows, ledger, counts)
ledger.save()
return counts
def _sync_lesson(node, path, row, ledger, counts):
intro_key = _key('intro', *path)
lesson = Lesson.objects.filter(node=node, title=row['title']).first()
if lesson is None:
if ledger.delivered(_key('lesson', *path)):
counts['retired'] += 1
return
lesson = Lesson.objects.create(
node=node, title=row['title'], intro_text=row['intro_text'],
example=row['example'], steps=row['steps'],
)
counts['lessons'] += 1
ledger.record(_key('lesson', *path), 'lesson')
ledger.record(intro_key, 'intro', _text_hash(lesson.intro_text))
return
ledger.record(_key('lesson', *path), 'lesson')
changed = []
file_text, db_text = row['intro_text'], lesson.intro_text
last_seeded = ledger.value(intro_key)
if file_text and db_text != file_text:
untouched = (last_seeded is not None and _text_hash(db_text) == last_seeded) or not db_text
if untouched:
lesson.intro_text = file_text
changed.append('intro_text')
ledger.record(intro_key, 'intro', _text_hash(file_text))
else:
counts['kept_admin_text'] += 1
elif db_text == file_text:
ledger.record(intro_key, 'intro', _text_hash(db_text))
# مثال و قدم‌ها فقط در خانه‌ی خالی (همان قاعده‌ی curriculum_io).
if row.get('example') and not lesson.example:
lesson.example = row['example']
changed.append('example')
if row.get('steps') and not lesson.steps:
lesson.steps = row['steps']
changed.append('steps')
if changed:
lesson.save(update_fields=changed)
counts['updated'] += 1
def _sync_questions(rows, ledger, counts):
if not rows:
return
lessons = {
(l.node.unit.grade.title, l.node.unit.title, l.node.title, l.title): l.pk
for l in Lesson.objects.select_related('node__unit__grade')
}
existing = set(
Question.objects.filter(lesson_id__in=lessons.values()).values_list('lesson_id', 'prompt')
)
to_create, missing = [], []
for index, row in enumerate(rows):
path = row.get('path')
if not isinstance(path, list) or len(path) != 4:
raise question_io.ImportError_(
f'ردیف {index + 1}: «path» باید چهار عنوان باشد (پایه، فصل، مبحث، درس).')
path = tuple(str(p).strip() for p in path)
prompt = (row.get('prompt') or '').strip()
qkey = _key('question', *path, prompt)
lesson_id = lessons.get(path)
if lesson_id is None:
# درسی که سید قبلاً داده و حالا نیست (ادمین پاک یا نامش را عوض کرده): سوالش هم نمی‌آید.
if ledger.delivered(_key('lesson', *path)) or ledger.delivered(qkey):
counts['retired'] += 1
continue
missing.append(' › '.join(path))
continue
if (lesson_id, prompt) in existing:
ledger.record(qkey, 'question')
counts['skipped'] += 1
continue
if ledger.delivered(qkey):
counts['retired'] += 1
continue
to_create.append(({**{k: v for k, v in row.items() if k != 'path'}, 'lessonId': lesson_id}, qkey))
existing.add((lesson_id, prompt))
if missing:
raise question_io.ImportError_(
'این درس‌ها در برنامه‌ی درسی پیدا نشدند (اول کوریکولوم را وارد کنید): '
+ '؛ '.join(missing[:5]) + ('…' if len(missing) > 5 else '')
)
if not to_create:
return
parsed = question_io.parse(json.dumps([r for r, _ in to_create]).encode('utf-8'))
result = question_io.import_questions(parsed)
counts['created'] += result['created']
for _row, qkey in to_create:
ledger.record(qkey, 'question')

View File

@@ -796,3 +796,16 @@ class HomeGradeFallbackTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['units'], []) self.assertEqual(response.data['units'], [])
class NodeProgressUnitParamTests(TestCase):
def test_non_numeric_unit_is_a_400_not_a_500(self):
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
user = User.objects.create(device_id='unit-param', is_guest=True)
response = APIClient().get('/api/progress/nodes?unit=abc',
HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
self.assertEqual(response.status_code, 400)

View File

@@ -136,6 +136,64 @@ class SeedContentTests(TestCase):
self.assertEqual(Question.objects.count(), 1) self.assertEqual(Question.objects.count(), 1)
self.assertEqual(question.choices.get(is_correct=True).value, 'چهار') self.assertEqual(question.choices.get(is_correct=True).value, 'چهار')
# ── کارِ ادمین با دیپلویِ بعدی برنمی‌گردد (۲۰۲۶-۱۰-۰۳، content/seed_sync.py) ──
def test_a_question_the_admin_deleted_stays_deleted(self):
call_command('seed_content')
Question.objects.get().delete()
call_command('seed_content')
self.assertEqual(Question.objects.count(), 0)
def test_fixing_a_question_prompt_does_not_bring_the_old_one_back(self):
call_command('seed_content')
Question.objects.update(prompt='حاصل ۲^۵ ÷ ۲^۳ چند می‌شود؟')
call_command('seed_content')
self.assertEqual(list(Question.objects.values_list('prompt', flat=True)),
['حاصل ۲^۵ ÷ ۲^۳ چند می‌شود؟'])
def test_renaming_a_unit_does_not_duplicate_it(self):
call_command('seed_content')
Unit.objects.update(title='فصل ۴: توان')
call_command('seed_content')
self.assertEqual(Unit.objects.count(), 1)
self.assertEqual(Lesson.objects.count(), 2)
self.assertEqual(Question.objects.count(), 1)
def test_lesson_text_edited_in_admin_is_kept(self):
call_command('seed_content')
Lesson.objects.filter(title='ضرب توان‌ها').update(intro_text='متنِ ادمین')
call_command('seed_content')
self.assertEqual(Lesson.objects.get(title='ضرب توان‌ها').intro_text, 'متنِ ادمین')
def test_lesson_text_changed_in_the_file_still_arrives_when_untouched(self):
call_command('seed_content')
curriculum = json.loads(json.dumps(CURRICULUM))
curriculum['grades'][0]['units'][0]['nodes'][0]['lessons'][0]['introText'] = 'متنِ تازه‌ی فایل'
self._write(curriculum, QUESTIONS)
call_command('seed_content')
self.assertEqual(Lesson.objects.get(title='ضرب توان‌ها').intro_text, 'متنِ تازه‌ی فایل')
def test_new_content_in_the_file_still_arrives(self):
call_command('seed_content')
questions = {'questions': QUESTIONS['questions'] + [{
**QUESTIONS['questions'][0], 'prompt': 'سوالِ تازه', 'choices': ['۱', '۲', '۳'],
}]}
self._write(CURRICULUM, questions)
call_command('seed_content')
self.assertEqual(Question.objects.count(), 2)
def test_export_leaves_out_demo_rows(self): def test_export_leaves_out_demo_rows(self):
""" """
[DEMO]/[SEED] در دیتابیسِ لوکال زندگی می‌کنند و نباید در فایلی بنشینند [DEMO]/[SEED] در دیتابیسِ لوکال زندگی می‌کنند و نباید در فایلی بنشینند

View File

@@ -741,7 +741,9 @@ class NodeProgressListView(APIView):
qs = NodeProgress.objects.filter(user_id=request.user.id).select_related('node') qs = NodeProgress.objects.filter(user_id=request.user.id).select_related('node')
unit_id = request.query_params.get('unit') unit_id = request.query_params.get('unit')
if unit_id: if unit_id:
qs = qs.filter(node__unit_id=unit_id) if not unit_id.isdigit():
raise ValidationError({'unit': 'شناسه‌ی فصل باید عدد باشد.'})
qs = qs.filter(node__unit_id=int(unit_id))
data = {'progress': list(qs.order_by('node__unit_id', 'node__order'))} data = {'progress': list(qs.order_by('node__unit_id', 'node__order'))}
return Response(NodeProgressListResponseSerializer(data).data) return Response(NodeProgressListResponseSerializer(data).data)

View File

@@ -68,6 +68,16 @@ if [ "${SKIP_PULL:-0}" != "1" ]; then
اگر لازم بود: git apply $PATCH" اگر لازم بود: git apply $PATCH"
fi fi
# کامیتِ محلی روی سرور (جلوتر از origin) را هم reset --hard بی‌صدا دور می‌ریخت؛ تا
# ۲۰۲۶-۱۰-۰۳ فقط تغییرِ کامیت‌نشده ذخیره می‌شد، برخلافِ آن‌چه CLAUDE.md می‌گفت.
if [ -n "$(git log --oneline "origin/$BRANCH..HEAD" 2>/dev/null)" ]; then
mkdir -p .deploy-backups
COMMITS=".deploy-backups/local-commits-$(date +%Y%m%d-%H%M%S).patch"
git format-patch --stdout "origin/$BRANCH..HEAD" > "$COMMITS" 2>/dev/null || true
warn "روی سرور کامیتِ محلی بود. در $COMMITS ذخیره شد و کنار گذاشته می‌شود.
اگر لازم بود: git am $COMMITS"
fi
# ⚠️ اینجا عمداً `git clean` زده نمی‌شود و هرگز هم نباید زده شود: # ⚠️ اینجا عمداً `git clean` زده نمی‌شود و هرگز هم نباید زده شود:
# db.sqlite3 و .env و .django_secret_key هیچ‌کدام در گیت نیستند و داخلِ همین # db.sqlite3 و .env و .django_secret_key هیچ‌کدام در گیت نیستند و داخلِ همین
# پوشه‌اند. یک `git clean -fd` یعنی نابودیِ دیتابیس، تنظیمات، و کلیدی که # پوشه‌اند. یک `git clean -fd` یعنی نابودیِ دیتابیس، تنظیمات، و کلیدی که

View File

@@ -29,6 +29,15 @@
- **سوال‌ها با مسیرِ عنوان به درس وصل می‌شوند** (پایه › فصل › نود › درس)، نه با - **سوال‌ها با مسیرِ عنوان به درس وصل می‌شوند** (پایه › فصل › نود › درس)، نه با
کلیدِ اصلی. شناسه‌ها بینِ هر دو دیتابیس فرق دارند و اتصال با id، سوال‌ها را کلیدِ اصلی. شناسه‌ها بینِ هر دو دیتابیس فرق دارند و اتصال با id، سوال‌ها را
بی‌صدا به درسِ اشتباه می‌چسباند. بی‌صدا به درسِ اشتباه می‌چسباند.
- **کارِ ادمین را برنمی‌گرداند** (از ۲۰۲۶-۱۰-۰۳، `content/seed_sync.py`). هر چه
سید یک بار تحویل داده در دفترِ `SeedRecord` ثبت می‌شود. اگر ادمین بعداً سوالی را
پاک کند، صورتش را اصلاح کند یا نامِ فصل و درسی را عوض کند، دیپلوی آن را دوباره
نمی‌سازد. متنِ توضیحِ درس فقط وقتی از فایل به‌روز می‌شود که در ادمین دست نخورده
باشد. محتوای *تازه*ی فایل مثلِ قبل می‌رسد. پیش از این، هر دیپلوی سوالِ پاک‌شده را
برمی‌گرداند و تغییرِ نامِ یک فصل همه‌ی درس‌ها و سوال‌هایش را تکراری می‌کرد.
دیتابیسِ بی‌محتوا (سرورِ تازه) دفتر را نادیده می‌گیرد و همه‌چیز را از فایل می‌سازد.
نتیجه‌اش این است که برای برگرداندنِ چیزی که در ادمین پاک شده، همان‌جا دوباره بسازیدش
(یا ردیفِ `SeedRecord`ش را پاک کنید)؛ دیپلوی دیگر آن را برنمی‌گرداند.
`seed_content --export` فایل‌ها را از دیتابیسِ محلی بازنویسی می‌کند. اگر `seed_content --export` فایل‌ها را از دیتابیسِ محلی بازنویسی می‌کند. اگر
دیتابیس سوالی نداشته باشد از بازنویسیِ یک `questions.json` غیرخالی خودداری دیتابیس سوالی نداشته باشد از بازنویسیِ یک `questions.json` غیرخالی خودداری

View File

@@ -133,6 +133,12 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st
# از قبل paid شده را به عقب برگرداند؛ چنین callback ای idempotent/no-op است. # از قبل paid شده را به عقب برگرداند؛ چنین callback ای idempotent/no-op است.
if already_paid and new_status != 'paid': if already_paid and new_status != 'paid':
is_duplicate = True is_duplicate = True
# نتیجه‌ی ناموفق فقط سفارشِ «در انتظار» را عوض می‌کند. کال‌بکِ زرین‌پال بی‌احراز است و
# authority از هدرِ Location ِ /pay خوانده می‌شود؛ تا ۲۰۲۶-۱۰-۰۳ یک Status=NOKِ جعلی سفارشِ
# «بازپرداخت‌شده» یا «لغوشده» را «ناموفق» می‌کرد و ردِ بازپرداخت از وضعیتِ سفارش می‌رفت
# (بازتولید شد). «paid» عمداً از این قاعده بیرون است: کسی که پول داده باید چیزش را بگیرد.
if new_status != 'paid' and old_status != 'pending':
is_duplicate = True
# reuse رفرنسِ پرداخت روی سفارش دیگر هم duplicate/no-op در نظر گرفته می‌شود. # reuse رفرنسِ پرداخت روی سفارش دیگر هم duplicate/no-op در نظر گرفته می‌شود.
if gateway_ref_id and Transaction.objects.filter( if gateway_ref_id and Transaction.objects.filter(

View File

@@ -66,3 +66,31 @@ class BazaarPackTests(TestCase):
'userId': str(self.user.id), 'amount': 1, 'provider': 'bazaar', 'planId': '999999', 'userId': str(self.user.id), 'amount': 1, 'provider': 'bazaar', 'planId': '999999',
}, format='json', HTTP_X_INTERNAL_KEY='internal-test-key') }, format='json', HTTP_X_INTERNAL_KEY='internal-test-key')
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
@override_settings(INTERNAL_SERVICE_KEY='internal-test-key')
class BazaarSkuComesFromTheOrderTests(TestCase):
"""تأییدِ بازار SKU را فقط از سفارش می‌گیرد، نه از کلاینت (۲۰۲۶-۱۰-۰۳)."""
def setUp(self):
cache.clear()
self.user = User.objects.create(phone='09120007102', is_guest=False)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
def test_a_bazaar_order_needs_a_sku(self):
response = APIClient().post('/api/payment/orders', {
'userId': str(self.user.id), 'amount': 1000, 'provider': 'bazaar',
}, format='json', HTTP_X_INTERNAL_KEY='internal-test-key')
self.assertEqual(response.status_code, 400)
def test_verify_never_uses_the_clients_sku(self):
order = Order.objects.create(user_id=self.user.id, amount=259000, provider='bazaar',
idempotency_key='no-sku-1')
with patch('payment.bazaar.verify_purchase') as verify:
response = self.client.post('/api/payment/bazaar/verify', {
'orderId': str(order.id), 'productSku': 'cheapest', 'purchaseToken': 't'}, format='json')
self.assertEqual(response.status_code, 400)
verify.assert_not_called()
order.refresh_from_db()
self.assertEqual(order.status, 'pending')

View File

@@ -175,3 +175,24 @@ class OrderAdminTests(_Base):
page = self.admin.get(f'/admin/payment/order/{order.pk}/change/') page = self.admin.get(f'/admin/payment/order/{order.pk}/change/')
self.assertEqual(page.status_code, 200) self.assertEqual(page.status_code, 200)
self.assertNotContains(page, 'name="status"') self.assertNotContains(page, 'name="status"')
class ForgedFailureCallbackTests(TestCase):
"""
کال‌بکِ زرین‌پال بی‌احراز است. Status=NOKِ جعلی نباید سفارشِ بازپرداخت‌شده یا لغوشده را
«ناموفق» کند (تا ۲۰۲۶-۱۰-۰۳ می‌کرد و ردِ بازپرداخت از وضعیتِ سفارش می‌رفت).
"""
def test_nok_does_not_overwrite_refunded_or_canceled(self):
for status in ('refunded', 'canceled'):
order = Order.objects.create(user_id=1, amount=1000, status=status,
gateway_authority=f'A-{status}', idempotency_key=f'k-{status}')
APIClient().get('/api/payment/zarinpal/callback', {'Authority': f'A-{status}', 'Status': 'NOK'})
order.refresh_from_db()
self.assertEqual(order.status, status)
def test_nok_still_fails_a_pending_order(self):
order = Order.objects.create(user_id=1, amount=1000, gateway_authority='A-p', idempotency_key='k-p')
APIClient().get('/api/payment/zarinpal/callback', {'Authority': 'A-p', 'Status': 'NOK'})
order.refresh_from_db()
self.assertEqual(order.status, 'failed')

View File

@@ -100,6 +100,9 @@ class CreateOrderView(APIView):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
v = serializer.validated_data v = serializer.validated_data
if v.get('provider') == 'bazaar' and not v.get('bazaarProductSku'):
raise ValidationError({'bazaarProductSku': 'سفارشِ بازار بدونِ SKU ساخته نمی‌شود.'})
plan = None plan = None
if v.get('planId'): if v.get('planId'):
from subscription.models import Plan from subscription.models import Plan
@@ -325,8 +328,12 @@ class BazaarVerifyPurchaseView(APIView):
Order, pk=v['orderId'], user_id=request.user.id, provider='bazaar', Order, pk=v['orderId'], user_id=request.user.id, provider='bazaar',
) )
# SKU فقط از خودِ سفارش. تا ۲۰۲۶-۱۰-۰۳ اگر سفارش SKU نداشت، SKUِ فرستاده‌ی کلاینت سنجیده
# می‌شد؛ یعنی توکنِ خریدِ ارزان‌ترین محصول سفارشِ گران‌ترین پلن را «paid» می‌کرد.
if not order_lookup.bazaar_product_sku:
raise ValidationError({'orderId': 'این سفارش محصولِ بازار ندارد.'})
result = bazaar.verify_purchase( result = bazaar.verify_purchase(
product_sku=order_lookup.bazaar_product_sku or v['productSku'], product_sku=order_lookup.bazaar_product_sku,
purchase_token=v['purchaseToken'], purchase_token=v['purchaseToken'],
) )

View File

@@ -266,3 +266,10 @@ class FourCompanionsCatalogTests(TestCase):
.order_by('order').values_list('slug', 'unlock_xp') .order_by('order').values_list('slug', 'unlock_xp')
) )
self.assertEqual(rows, [('sigma', 0), ('delta', 100), ('pi', 300), ('tedi', 500)]) self.assertEqual(rows, [('sigma', 0), ('delta', 100), ('pi', 300), ('tedi', 500)])
class LeaderboardPeriodValidationTests(TestCase):
def test_unknown_period_is_a_400(self):
from rest_framework.test import APIClient
self.assertEqual(APIClient().get('/api/leaderboard?period=xyz').status_code, 400)
self.assertEqual(APIClient().get('/api/leaderboard?period=all_time').status_code, 200)

View File

@@ -140,6 +140,10 @@ class LeaderboardView(APIView):
) )
def get(self, request): def get(self, request):
period = request.query_params.get('period', 'weekly') period = request.query_params.get('period', 'weekly')
from .leaderboard import PERIODS
if period not in PERIODS:
# پیش‌تر هر رشته‌ای ۲۰۰ و یک کلیدِ کشِ تازه می‌ساخت.
raise ValidationError({'period': f'یکی از {", ".join(PERIODS)}.'})
# زنده از فعالیتِ واقعی، با کشِ کوتاه (progress/leaderboard.py). قبلاً از # زنده از فعالیتِ واقعی، با کشِ کوتاه (progress/leaderboard.py). قبلاً از
# جدولِ LeaderboardEntry خوانده می‌شد که فقط با اجرای دستیِ # جدولِ LeaderboardEntry خوانده می‌شد که فقط با اجرای دستیِ

View File

@@ -459,8 +459,15 @@ def question_focus(question_id, user_id) -> str:
'دانش‌آموز درباره‌ی این سوالِ تمرینِ اپ می‌پرسد:', 'دانش‌آموز درباره‌ی این سوالِ تمرینِ اپ می‌پرسد:',
f'صورتِ سوال: {question.prompt}', f'صورتِ سوال: {question.prompt}',
'گزینه‌ها: ' + ' | '.join(c.value for c in choices), 'گزینه‌ها: ' + ' | '.join(c.value for c in choices),
f'جوابِ درست: {correct}',
] ]
# جوابِ درست فقط بعد از این‌که خودش جواب داده. تا ۲۰۲۶-۱۰-۰۳ همیشه می‌رفت: کسی که API را
# مستقیم صدا می‌زد پیش از جواب دادن، جواب را از دستیار می‌گرفت و امتیاز می‌برد (فرانت دکمه را
# فقط بعد از جوابِ غلط نشان می‌دهد، ولی سرور شرطی نداشت). این‌که مدل در آن حالت جواب را
# تکرار می‌کرد با مدلِ زنده آزموده نشد؛ فقط رسیدنش به پرامپت.
if last is None:
lines.append('دانش‌آموز هنوز جواب نداده؛ جوابِ نهایی یا گزینه‌ی درست را نگو، فقط راهِ فکر کردن را نشان بده.')
else:
lines.append(f'جوابِ درست: {correct}')
if last is not None: if last is not None:
verdict = 'درست' if last.correct else 'غلط' verdict = 'درست' if last.correct else 'غلط'
lines.append(f'آخرین جوابِ دانش‌آموز: {last.choice.value} ({verdict})') lines.append(f'آخرین جوابِ دانش‌آموز: {last.choice.value} ({verdict})')

View File

@@ -298,6 +298,17 @@ class QuestionFocusTests(TestCase):
self.assertIn('-8 (غلط)', focus) self.assertIn('-8 (غلط)', focus)
self.assertIn('جوابِ درست: 1/8', focus) self.assertIn('جوابِ درست: 1/8', focus)
def test_the_answer_is_not_given_before_the_student_answers(self):
with patch('rag.services.retrieval.retrieve_context', return_value=([], {})), \
patch('rag.services.generation.generate_answer',
return_value=('جواب', [], {})) as generate:
self.client.post('/api/ai/ask', {
'userQuestion': 'جوابش چیه؟', 'questionId': str(self.question.id),
}, format='json')
focus = generate.call_args.kwargs['focus']
self.assertIn('۲ به توان منفی ۳؟', focus)
self.assertNotIn('جوابِ درست:', focus)
def test_an_overlong_question_is_refused_before_any_paid_call(self): def test_an_overlong_question_is_refused_before_any_paid_call(self):
with patch('rag.services.generation.generate_answer') as generate: with patch('rag.services.generation.generate_answer') as generate:
response = self.client.post('/api/ai/ask', {'userQuestion': 'x' * 2001}, format='json') response = self.client.post('/api/ai/ask', {'userQuestion': 'x' * 2001}, format='json')

View File

@@ -4,8 +4,10 @@ from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver from django.dispatch import receiver
from .models import Plan from .models import Plan
# کلید فقط یک‌جا تعریف می‌شود (views). تا ۲۰۲۶-۱۰-۰۳ این‌جا یک نسخه‌ی جدا ('v1') بود که
PLANS_CACHE_KEY = 'subscription:plans:v1' # هم‌تراز با subscription/views.py # با v3 شدنِ کلیدِ views جا ماند: ذخیره‌ی پلن در ادمین کش را پاک نمی‌کرد و اپ تا یک ساعت
# قیمتِ قدیم را نشان می‌داد در حالی که سفارش با قیمتِ تازه ساخته می‌شد (بازتولید شد).
from .views import PLANS_CACHE_KEY
@receiver(post_save, sender=Plan, dispatch_uid='subscription_invalidate_plans_cache_on_save') @receiver(post_save, sender=Plan, dispatch_uid='subscription_invalidate_plans_cache_on_save')

View File

@@ -592,3 +592,26 @@ class YearlyPlanSeedTests(TestCase):
seed.create_yearly_plan(global_apps, None) seed.create_yearly_plan(global_apps, None)
self.assertEqual(list(Plan.objects.values_list('title', flat=True)), ['پلنِ ادمین']) self.assertEqual(list(Plan.objects.values_list('title', flat=True)), ['پلنِ ادمین'])
class PlanCacheInvalidationTests(TestCase):
"""
ذخیره‌ی پلن در ادمین باید فهرستِ پلن‌ها را تازه کند. تا ۲۰۲۶-۱۰-۰۳ گیرنده‌ی سیگنال کلیدِ
قدیمیِ 'v1' را پاک می‌کرد و فهرست تا یک ساعت قیمتِ قبلی را نشان می‌داد، در حالی که سفارش با
قیمتِ تازه ساخته می‌شد.
"""
def test_admin_price_change_shows_up_immediately(self):
from django.core.cache import cache
cache.clear()
plan = Plan.objects.create(title='بسته‌ی آزمایشی', price=45000, kind='pack', question_credits=7)
client = APIClient()
def price():
plans = client.get('/api/subscription/plans').json()['plans']
return next(p['price'] for p in plans if p['id'] == str(plan.id))
self.assertEqual(price(), 45000)
plan.price = 99000
plan.save()
self.assertEqual(price(), 99000)

View File

@@ -1,5 +1,4 @@
from django.contrib import admin from django.contrib import admin
from django.db import transaction
from .models import BugReport, Ticket, TicketMessage from .models import BugReport, Ticket, TicketMessage
@@ -30,26 +29,5 @@ class TicketMessageAdmin(admin.ModelAdmin):
list_display = ('id', 'ticket', 'sender', 'created_at') list_display = ('id', 'ticket', 'sender', 'created_at')
list_filter = ('sender',) list_filter = ('sender',)
def save_model(self, request, obj, form, change): # اعلانِ پاسخِ پشتیبانی در support/receivers.py است (سیگنال روی مدل)، نه این‌جا؛ از صفحه‌ی
""" # تیکت (inline) هم که ذخیره شود اعلان می‌رود.
طبق سند معماری، ارتباط Support→Notifications فقط از طریق رویداد
TicketReplied‌ه، نه رفرنس مدل. چون پاسخ پشتیبانی (sender='support')
از پنل ادمین ثبت می‌شه، رویداد دقیقاً همین‌جا ساطع می‌شه — همون
الگوی notify() که در payment/views.py (PaymentWebhookView) هنگام
پرداخت موفق (status=paid) صدا زده می‌شه.
"""
is_new = obj.pk is None
# ⚛️ atomic: ذخیره‌ی پیام پشتیبانی + ساختِ Notification یک واحدند.
with transaction.atomic():
super().save_model(request, obj, form, change)
if is_new and obj.sender == 'support':
from notifications.services import notify
notify(
user_id=obj.ticket.user_id,
type='ticket_replied',
title='پاسخ جدید برای تیکت شما',
body=obj.text[:200],
related_id=obj.ticket_id,
)

View File

@@ -4,3 +4,6 @@ from django.apps import AppConfig
class SupportConfig(AppConfig): class SupportConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'support' name = 'support'
def ready(self):
from . import receivers # noqa: F401 (اعلانِ پاسخِ پشتیبانی)

27
support/receivers.py Normal file
View File

@@ -0,0 +1,27 @@
"""
اعلانِ «پاسخِ تازه برای تیکتت» با هر پیامِ پشتیبانی، از هر مسیری که ذخیره شود.
تا ۲۰۲۶-۱۰-۰۳ اعلان فقط در `TicketMessageAdmin.save_model` ساخته می‌شد. پشتیبان معمولاً از
صفحه‌ی خودِ تیکت (inline) جواب می‌دهد و آن مسیر از `TicketAdmin` رد می‌شود؛ پیام ثبت می‌شد و
کاربر هیچ اعلانی نمی‌گرفت (بازتولید شد). سیگنال روی خودِ مدل هر دو مسیر را می‌گیرد.
اعلان داخلِ همان تراکنشِ ذخیره‌ی پیام نوشته می‌شود؛ شکستش ذخیره‌ی پیام را هم برمی‌گرداند.
"""
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import TicketMessage
@receiver(post_save, sender=TicketMessage, dispatch_uid='support_notify_ticket_reply')
def _notify_ticket_reply(sender, instance, created, **kwargs):
if not created or instance.sender != 'support':
return
from notifications.services import notify
notify(
user_id=instance.ticket.user_id,
type='ticket_replied',
title='پاسخ جدید برای تیکت شما',
body=instance.text[:200],
related_id=instance.ticket_id,
)

View File

@@ -392,3 +392,33 @@ class TicketListTests(TestCase):
'/api/support/tickets', {'subject': 'س', 'category': 'other'}, format='json', **self.auth, '/api/support/tickets', {'subject': 'س', 'category': 'other'}, format='json', **self.auth,
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
class TicketReplyNotificationTests(TestCase):
"""پاسخی که پشتیبان از صفحه‌ی خودِ تیکت (inline) می‌دهد هم اعلان می‌سازد (تا ۲۰۲۶-۱۰-۰۳ نمی‌ساخت)."""
def test_inline_reply_on_the_ticket_page_notifies_the_user(self):
from django.contrib.auth.models import User as Staff
from django.test import Client
from notifications.models import Notification
staff = Staff.objects.create_superuser('support-staff', 's@example.com', 'pw')
ticket = Ticket.objects.create(user_id=77, subject='s', category='c')
client = Client()
client.force_login(staff)
client.post(f'/admin/support/ticket/{ticket.id}/change/', {
'user_id': 77, 'subject': 's', 'category': 'c', 'status': 'open', 'priority': 'normal',
'messages-TOTAL_FORMS': '1', 'messages-INITIAL_FORMS': '0',
'messages-MIN_NUM_FORMS': '0', 'messages-MAX_NUM_FORMS': '1000',
'messages-0-sender': 'support', 'messages-0-text': 'پاسخ', 'messages-0-ticket': str(ticket.id),
})
self.assertEqual(ticket.messages.count(), 1)
self.assertEqual(Notification.objects.filter(user_id=77, type='ticket_replied').count(), 1)
def test_a_users_own_message_does_not_notify_them(self):
from notifications.models import Notification
ticket = Ticket.objects.create(user_id=78, subject='s', category='c')
TicketMessage.objects.create(ticket=ticket, sender='user', text='سلام')
self.assertFalse(Notification.objects.filter(user_id=78).exists())