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:
@@ -110,6 +110,10 @@ BAZAAR_REFRESH_TOKEN=
|
||||
# سقفِ خواندن روی اندپوینتهای پرترافیک، و سقفِ /api/health.
|
||||
# READ_THROTTLE_RATE=120/min
|
||||
# HEALTH_THROTTLE_RATE=60/min
|
||||
# سقفِ تمدیدِ توکن برای هر IP (همهی کاربرانِ پشتِ یک وایفای یا CGNAT با هم).
|
||||
# TOKEN_REFRESH_IP_RATE=600/hour
|
||||
# سقفِ روزانهی کلِ سامانه برای سوالهایی که به مدلِ هوش مصنوعی میرسند (ضدِ خرجِ بیسقف).
|
||||
# AI_GLOBAL_DAILY_MAX=3000
|
||||
# کشِ پاسخِ دستیار (ثانیه)؛ سوالِ تکراری دوباره به مدل نمیرود.
|
||||
# RAG_ANSWER_CACHE_TTL=1800
|
||||
# 1 = دستیار حتی با کلیدها هم پاسخِ آزمایشی بدهد.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
databases, and binding by id silently attaches questions to the wrong lesson.
|
||||
|
||||
|
||||
@@ -424,7 +424,9 @@ def _upgradable_guest(request, phone: str):
|
||||
|
||||
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):
|
||||
|
||||
@@ -529,3 +529,20 @@ class StubNeverReachesProductionTests(TestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 503)
|
||||
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])
|
||||
|
||||
@@ -197,6 +197,28 @@ def _question_focus(v: dict, user) -> str:
|
||||
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):
|
||||
"""
|
||||
⚠️ افزوده شده: وقتی 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',
|
||||
).count()
|
||||
quota = daily_quota(user.id)
|
||||
if not screened:
|
||||
_check_global_daily_cap()
|
||||
if screened:
|
||||
charge = 'none'
|
||||
elif used_today < quota:
|
||||
|
||||
@@ -170,6 +170,13 @@ def _warnings() -> list[str]:
|
||||
'شمارههای تستِ ورود با کدِ ثابت فعالاند (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':
|
||||
out.append(
|
||||
'اعتبارِ سرویسِ هوش مصنوعی (GapGPT) تمام شده: دستیار برای همهی کاربرها '
|
||||
|
||||
@@ -463,7 +463,12 @@ REST_FRAMEWORK = {
|
||||
# به UX عادی دست نمیزنه.
|
||||
'read': os.environ.get('READ_THROTTLE_RATE', '120/min'),
|
||||
'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_report': '20/hour',
|
||||
# هر درس اینجا یک نوع مسئله است با یکی-دو سوال، نه یک جلسهی درسی؛
|
||||
@@ -820,6 +825,11 @@ OPENAI_CHAT_MODEL = os.environ.get('OPENAI_CHAT_MODEL', 'gpt-4.1-mini')
|
||||
# سقفِ سوالِ روزانه از دستیار برای کاربرِ بدونِ اشتراک (مشترکان: Plan.ai_daily_quota).
|
||||
# ۳ پیشنهادِ docs/ai-cost-and-guardrails.md است؛ تا ۲۰۲۶-۱۰-۰۱ برای همه ۱۵ بود.
|
||||
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_UPPER = os.environ.get('OPENAI_CHAT_MODEL_UPPER', '')
|
||||
|
||||
@@ -6,7 +6,7 @@ from urllib.parse import urlsplit
|
||||
from unittest.mock import patch
|
||||
|
||||
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 accounts.authentication import issue_access_token
|
||||
@@ -476,3 +476,14 @@ class ApiDocsRtlShellTests(TestCase):
|
||||
def test_redoc_carries_the_rtl_shell(self):
|
||||
html = self.client.get('/api/redoc/').content.decode()
|
||||
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()))
|
||||
|
||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
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
|
||||
|
||||
# ردیفهای نمونه هرگز نباید در فایلِ ریپو بنشینند: این فایل مستقیم روی
|
||||
@@ -119,10 +119,10 @@ class Command(BaseCommand):
|
||||
|
||||
# dry-run با rollback انجام میشود، نه با شمارشِ تقریبی: تنها چیزی که
|
||||
# واقعاً میگوید چه اتفاقی میافتد، خودِ اتفاق است.
|
||||
# seed_sync کارِ ادمین (حذف، تغییرِ نام، اصلاحِ متن) را برنمیگرداند؛ شرح در خودِ ماژول.
|
||||
try:
|
||||
with transaction.atomic():
|
||||
counts = curriculum_io.import_curriculum(grades)
|
||||
question_counts = self._load_questions()
|
||||
counts = seed_sync.sync(grades, self._question_rows())
|
||||
if dry_run:
|
||||
transaction.set_rollback(True)
|
||||
except (curriculum_io.CurriculumError, question_io.ImportError_) as exc:
|
||||
@@ -134,23 +134,20 @@ class Command(BaseCommand):
|
||||
f'{counts["grades"]} پایه، {counts["units"]} فصل، '
|
||||
f'{counts["nodes"]} مبحث، {counts["lessons"]} درسِ تازه '
|
||||
f'({counts["updated"]} درس بهروز شد)؛ '
|
||||
f'{question_counts["created"]} سوالِ تازه '
|
||||
f'({question_counts["skipped"]} سوال از قبل بود)'
|
||||
f'{counts["created"]} سوالِ تازه '
|
||||
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():
|
||||
return {'created': 0, 'skipped': 0}
|
||||
|
||||
return []
|
||||
raw = json.loads(QUESTIONS_FILE.read_text(encoding='utf-8'))
|
||||
rows = raw.get('questions', raw) if isinstance(raw, dict) else raw
|
||||
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)
|
||||
return (raw.get('questions', raw) if isinstance(raw, dict) else raw) or []
|
||||
|
||||
|
||||
def _has_questions(path: Path) -> bool:
|
||||
|
||||
23
content/migrations/0010_seedrecord.py
Normal file
23
content/migrations/0010_seedrecord.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -318,3 +318,25 @@ class QuestionReport(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
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
211
content/seed_sync.py
Normal 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')
|
||||
@@ -796,3 +796,16 @@ class HomeGradeFallbackTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
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)
|
||||
|
||||
@@ -136,6 +136,64 @@ class SeedContentTests(TestCase):
|
||||
self.assertEqual(Question.objects.count(), 1)
|
||||
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):
|
||||
"""
|
||||
[DEMO]/[SEED] در دیتابیسِ لوکال زندگی میکنند و نباید در فایلی بنشینند
|
||||
|
||||
@@ -741,7 +741,9 @@ class NodeProgressListView(APIView):
|
||||
qs = NodeProgress.objects.filter(user_id=request.user.id).select_related('node')
|
||||
unit_id = request.query_params.get('unit')
|
||||
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'))}
|
||||
return Response(NodeProgressListResponseSerializer(data).data)
|
||||
|
||||
10
deploy.sh
10
deploy.sh
@@ -68,6 +68,16 @@ if [ "${SKIP_PULL:-0}" != "1" ]; then
|
||||
اگر لازم بود: git apply $PATCH"
|
||||
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` زده نمیشود و هرگز هم نباید زده شود:
|
||||
# db.sqlite3 و .env و .django_secret_key هیچکدام در گیت نیستند و داخلِ همین
|
||||
# پوشهاند. یک `git clean -fd` یعنی نابودیِ دیتابیس، تنظیمات، و کلیدی که
|
||||
|
||||
@@ -29,6 +29,15 @@
|
||||
- **سوالها با مسیرِ عنوان به درس وصل میشوند** (پایه › فصل › نود › درس)، نه با
|
||||
کلیدِ اصلی. شناسهها بینِ هر دو دیتابیس فرق دارند و اتصال با id، سوالها را
|
||||
بیصدا به درسِ اشتباه میچسباند.
|
||||
- **کارِ ادمین را برنمیگرداند** (از ۲۰۲۶-۱۰-۰۳، `content/seed_sync.py`). هر چه
|
||||
سید یک بار تحویل داده در دفترِ `SeedRecord` ثبت میشود. اگر ادمین بعداً سوالی را
|
||||
پاک کند، صورتش را اصلاح کند یا نامِ فصل و درسی را عوض کند، دیپلوی آن را دوباره
|
||||
نمیسازد. متنِ توضیحِ درس فقط وقتی از فایل بهروز میشود که در ادمین دست نخورده
|
||||
باشد. محتوای *تازه*ی فایل مثلِ قبل میرسد. پیش از این، هر دیپلوی سوالِ پاکشده را
|
||||
برمیگرداند و تغییرِ نامِ یک فصل همهی درسها و سوالهایش را تکراری میکرد.
|
||||
دیتابیسِ بیمحتوا (سرورِ تازه) دفتر را نادیده میگیرد و همهچیز را از فایل میسازد.
|
||||
نتیجهاش این است که برای برگرداندنِ چیزی که در ادمین پاک شده، همانجا دوباره بسازیدش
|
||||
(یا ردیفِ `SeedRecord`ش را پاک کنید)؛ دیپلوی دیگر آن را برنمیگرداند.
|
||||
|
||||
`seed_content --export` فایلها را از دیتابیسِ محلی بازنویسی میکند. اگر
|
||||
دیتابیس سوالی نداشته باشد از بازنویسیِ یک `questions.json` غیرخالی خودداری
|
||||
|
||||
@@ -133,6 +133,12 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st
|
||||
# از قبل paid شده را به عقب برگرداند؛ چنین callback ای idempotent/no-op است.
|
||||
if already_paid and new_status != 'paid':
|
||||
is_duplicate = True
|
||||
# نتیجهی ناموفق فقط سفارشِ «در انتظار» را عوض میکند. کالبکِ زرینپال بیاحراز است و
|
||||
# authority از هدرِ Location ِ /pay خوانده میشود؛ تا ۲۰۲۶-۱۰-۰۳ یک Status=NOKِ جعلی سفارشِ
|
||||
# «بازپرداختشده» یا «لغوشده» را «ناموفق» میکرد و ردِ بازپرداخت از وضعیتِ سفارش میرفت
|
||||
# (بازتولید شد). «paid» عمداً از این قاعده بیرون است: کسی که پول داده باید چیزش را بگیرد.
|
||||
if new_status != 'paid' and old_status != 'pending':
|
||||
is_duplicate = True
|
||||
|
||||
# reuse رفرنسِ پرداخت روی سفارش دیگر هم duplicate/no-op در نظر گرفته میشود.
|
||||
if gateway_ref_id and Transaction.objects.filter(
|
||||
|
||||
@@ -66,3 +66,31 @@ class BazaarPackTests(TestCase):
|
||||
'userId': str(self.user.id), 'amount': 1, 'provider': 'bazaar', 'planId': '999999',
|
||||
}, format='json', HTTP_X_INTERNAL_KEY='internal-test-key')
|
||||
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')
|
||||
|
||||
@@ -175,3 +175,24 @@ class OrderAdminTests(_Base):
|
||||
page = self.admin.get(f'/admin/payment/order/{order.pk}/change/')
|
||||
self.assertEqual(page.status_code, 200)
|
||||
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')
|
||||
|
||||
@@ -100,6 +100,9 @@ class CreateOrderView(APIView):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
v = serializer.validated_data
|
||||
|
||||
if v.get('provider') == 'bazaar' and not v.get('bazaarProductSku'):
|
||||
raise ValidationError({'bazaarProductSku': 'سفارشِ بازار بدونِ SKU ساخته نمیشود.'})
|
||||
|
||||
plan = None
|
||||
if v.get('planId'):
|
||||
from subscription.models import Plan
|
||||
@@ -325,8 +328,12 @@ class BazaarVerifyPurchaseView(APIView):
|
||||
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(
|
||||
product_sku=order_lookup.bazaar_product_sku or v['productSku'],
|
||||
product_sku=order_lookup.bazaar_product_sku,
|
||||
purchase_token=v['purchaseToken'],
|
||||
)
|
||||
|
||||
|
||||
@@ -266,3 +266,10 @@ class FourCompanionsCatalogTests(TestCase):
|
||||
.order_by('order').values_list('slug', 'unlock_xp')
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -140,6 +140,10 @@ class LeaderboardView(APIView):
|
||||
)
|
||||
def get(self, request):
|
||||
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). قبلاً از
|
||||
# جدولِ LeaderboardEntry خوانده میشد که فقط با اجرای دستیِ
|
||||
|
||||
@@ -459,8 +459,15 @@ def question_focus(question_id, user_id) -> str:
|
||||
'دانشآموز دربارهی این سوالِ تمرینِ اپ میپرسد:',
|
||||
f'صورتِ سوال: {question.prompt}',
|
||||
'گزینهها: ' + ' | '.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:
|
||||
verdict = 'درست' if last.correct else 'غلط'
|
||||
lines.append(f'آخرین جوابِ دانشآموز: {last.choice.value} ({verdict})')
|
||||
|
||||
@@ -298,6 +298,17 @@ class QuestionFocusTests(TestCase):
|
||||
self.assertIn('-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):
|
||||
with patch('rag.services.generation.generate_answer') as generate:
|
||||
response = self.client.post('/api/ai/ask', {'userQuestion': 'x' * 2001}, format='json')
|
||||
|
||||
@@ -4,8 +4,10 @@ from django.db.models.signals import post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .models import Plan
|
||||
|
||||
PLANS_CACHE_KEY = 'subscription:plans:v1' # همتراز با subscription/views.py
|
||||
# کلید فقط یکجا تعریف میشود (views). تا ۲۰۲۶-۱۰-۰۳ اینجا یک نسخهی جدا ('v1') بود که
|
||||
# با v3 شدنِ کلیدِ views جا ماند: ذخیرهی پلن در ادمین کش را پاک نمیکرد و اپ تا یک ساعت
|
||||
# قیمتِ قدیم را نشان میداد در حالی که سفارش با قیمتِ تازه ساخته میشد (بازتولید شد).
|
||||
from .views import PLANS_CACHE_KEY
|
||||
|
||||
|
||||
@receiver(post_save, sender=Plan, dispatch_uid='subscription_invalidate_plans_cache_on_save')
|
||||
|
||||
@@ -592,3 +592,26 @@ class YearlyPlanSeedTests(TestCase):
|
||||
seed.create_yearly_plan(global_apps, None)
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from django.contrib import admin
|
||||
from django.db import transaction
|
||||
|
||||
from .models import BugReport, Ticket, TicketMessage
|
||||
|
||||
@@ -30,26 +29,5 @@ class TicketMessageAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'ticket', 'sender', 'created_at')
|
||||
list_filter = ('sender',)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""
|
||||
طبق سند معماری، ارتباط 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,
|
||||
)
|
||||
# اعلانِ پاسخِ پشتیبانی در support/receivers.py است (سیگنال روی مدل)، نه اینجا؛ از صفحهی
|
||||
# تیکت (inline) هم که ذخیره شود اعلان میرود.
|
||||
|
||||
@@ -4,3 +4,6 @@ from django.apps import AppConfig
|
||||
class SupportConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'support'
|
||||
|
||||
def ready(self):
|
||||
from . import receivers # noqa: F401 (اعلانِ پاسخِ پشتیبانی)
|
||||
|
||||
27
support/receivers.py
Normal file
27
support/receivers.py
Normal 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,
|
||||
)
|
||||
@@ -392,3 +392,33 @@ class TicketListTests(TestCase):
|
||||
'/api/support/tickets', {'subject': 'س', 'category': 'other'}, format='json', **self.auth,
|
||||
)
|
||||
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())
|
||||
|
||||
Reference in New Issue
Block a user