From ab5e53730a8a464ce1b6e4a14e32d6c2ba92768c Mon Sep 17 00:00:00 2001 From: Zareian Date: Sat, 3 Oct 2026 16:05:27 +0330 Subject: [PATCH] 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 --- .env.example | 4 + CLAUDE.md | 2 +- accounts/views.py | 4 +- ai_tutor/tests.py | 17 ++ ai_tutor/views.py | 24 +++ config/health.py | 7 + config/settings.py | 12 +- config/tests.py | 13 +- content/management/commands/seed_content.py | 29 ++- content/migrations/0010_seedrecord.py | 23 +++ content/models.py | 22 ++ content/seed_sync.py | 211 ++++++++++++++++++++ content/tests.py | 13 ++ content/tests_seed_content.py | 58 ++++++ content/views.py | 4 +- deploy.sh | 10 + docs/content-authoring.md | 9 + payment/services.py | 6 + payment/tests_bazaar_pack.py | 28 +++ payment/tests_refund.py | 21 ++ payment/views.py | 9 +- progress/tests.py | 7 + progress/views.py | 4 + rag/services/generation.py | 9 +- rag/tests_tutor_quality.py | 11 + subscription/receivers.py | 6 +- subscription/tests.py | 23 +++ support/admin.py | 26 +-- support/apps.py | 3 + support/receivers.py | 27 +++ support/tests.py | 30 +++ 31 files changed, 623 insertions(+), 49 deletions(-) create mode 100644 content/migrations/0010_seedrecord.py create mode 100644 content/seed_sync.py create mode 100644 support/receivers.py diff --git a/.env.example b/.env.example index 591fd77..ddce9a5 100644 --- a/.env.example +++ b/.env.example @@ -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 = دستیار حتی با کلیدها هم پاسخِ آزمایشی بدهد. diff --git a/CLAUDE.md b/CLAUDE.md index 8cb0a70..56a26b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/accounts/views.py b/accounts/views.py index f4cfbe2..ee57bc7 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -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): diff --git a/ai_tutor/tests.py b/ai_tutor/tests.py index fc8f5cf..23b01f9 100644 --- a/ai_tutor/tests.py +++ b/ai_tutor/tests.py @@ -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]) diff --git a/ai_tutor/views.py b/ai_tutor/views.py index 66c6a1d..b416227 100644 --- a/ai_tutor/views.py +++ b/ai_tutor/views.py @@ -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: diff --git a/config/health.py b/config/health.py index 411049a..8751773 100644 --- a/config/health.py +++ b/config/health.py @@ -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) تمام شده: دستیار برای همه‌ی کاربرها ' diff --git a/config/settings.py b/config/settings.py index 4d30752..750b601 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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', '') diff --git a/config/tests.py b/config/tests.py index 1198fa3..aeb3d02 100644 --- a/config/tests.py +++ b/config/tests.py @@ -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())) diff --git a/content/management/commands/seed_content.py b/content/management/commands/seed_content.py index bce8d47..004e717 100644 --- a/content/management/commands/seed_content.py +++ b/content/management/commands/seed_content.py @@ -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: diff --git a/content/migrations/0010_seedrecord.py b/content/migrations/0010_seedrecord.py new file mode 100644 index 0000000..7ad989d --- /dev/null +++ b/content/migrations/0010_seedrecord.py @@ -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)), + ], + ), + ] diff --git a/content/models.py b/content/models.py index 5a54ea8..abebe20 100644 --- a/content/models.py +++ b/content/models.py @@ -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]}' diff --git a/content/seed_sync.py b/content/seed_sync.py new file mode 100644 index 0000000..414343e --- /dev/null +++ b/content/seed_sync.py @@ -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') diff --git a/content/tests.py b/content/tests.py index 02c2bd7..e3b8b48 100644 --- a/content/tests.py +++ b/content/tests.py @@ -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) diff --git a/content/tests_seed_content.py b/content/tests_seed_content.py index 12cb398..806ebfe 100644 --- a/content/tests_seed_content.py +++ b/content/tests_seed_content.py @@ -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] در دیتابیسِ لوکال زندگی می‌کنند و نباید در فایلی بنشینند diff --git a/content/views.py b/content/views.py index 1f2348b..8c71f8a 100644 --- a/content/views.py +++ b/content/views.py @@ -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) diff --git a/deploy.sh b/deploy.sh index 849b819..fc1d541 100755 --- a/deploy.sh +++ b/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` یعنی نابودیِ دیتابیس، تنظیمات، و کلیدی که diff --git a/docs/content-authoring.md b/docs/content-authoring.md index 38499b7..158ac86 100644 --- a/docs/content-authoring.md +++ b/docs/content-authoring.md @@ -29,6 +29,15 @@ - **سوال‌ها با مسیرِ عنوان به درس وصل می‌شوند** (پایه › فصل › نود › درس)، نه با کلیدِ اصلی. شناسه‌ها بینِ هر دو دیتابیس فرق دارند و اتصال با id، سوال‌ها را بی‌صدا به درسِ اشتباه می‌چسباند. +- **کارِ ادمین را برنمی‌گرداند** (از ۲۰۲۶-۱۰-۰۳، `content/seed_sync.py`). هر چه + سید یک بار تحویل داده در دفترِ `SeedRecord` ثبت می‌شود. اگر ادمین بعداً سوالی را + پاک کند، صورتش را اصلاح کند یا نامِ فصل و درسی را عوض کند، دیپلوی آن را دوباره + نمی‌سازد. متنِ توضیحِ درس فقط وقتی از فایل به‌روز می‌شود که در ادمین دست نخورده + باشد. محتوای *تازه*ی فایل مثلِ قبل می‌رسد. پیش از این، هر دیپلوی سوالِ پاک‌شده را + برمی‌گرداند و تغییرِ نامِ یک فصل همه‌ی درس‌ها و سوال‌هایش را تکراری می‌کرد. + دیتابیسِ بی‌محتوا (سرورِ تازه) دفتر را نادیده می‌گیرد و همه‌چیز را از فایل می‌سازد. + نتیجه‌اش این است که برای برگرداندنِ چیزی که در ادمین پاک شده، همان‌جا دوباره بسازیدش + (یا ردیفِ `SeedRecord`ش را پاک کنید)؛ دیپلوی دیگر آن را برنمی‌گرداند. `seed_content --export` فایل‌ها را از دیتابیسِ محلی بازنویسی می‌کند. اگر دیتابیس سوالی نداشته باشد از بازنویسیِ یک `questions.json` غیرخالی خودداری diff --git a/payment/services.py b/payment/services.py index fdf2441..885846b 100644 --- a/payment/services.py +++ b/payment/services.py @@ -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( diff --git a/payment/tests_bazaar_pack.py b/payment/tests_bazaar_pack.py index f0dab44..665fa3c 100644 --- a/payment/tests_bazaar_pack.py +++ b/payment/tests_bazaar_pack.py @@ -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') diff --git a/payment/tests_refund.py b/payment/tests_refund.py index dc1126c..e5772f0 100644 --- a/payment/tests_refund.py +++ b/payment/tests_refund.py @@ -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') diff --git a/payment/views.py b/payment/views.py index 05cf2f0..f8bff64 100644 --- a/payment/views.py +++ b/payment/views.py @@ -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'], ) diff --git a/progress/tests.py b/progress/tests.py index 56b6bec..c8c4c85 100644 --- a/progress/tests.py +++ b/progress/tests.py @@ -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) diff --git a/progress/views.py b/progress/views.py index b2c914f..8e0aaa1 100644 --- a/progress/views.py +++ b/progress/views.py @@ -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 خوانده می‌شد که فقط با اجرای دستیِ diff --git a/rag/services/generation.py b/rag/services/generation.py index 1a10c32..a6cca78 100644 --- a/rag/services/generation.py +++ b/rag/services/generation.py @@ -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})') diff --git a/rag/tests_tutor_quality.py b/rag/tests_tutor_quality.py index d317f19..0d04bcc 100644 --- a/rag/tests_tutor_quality.py +++ b/rag/tests_tutor_quality.py @@ -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') diff --git a/subscription/receivers.py b/subscription/receivers.py index 5e40d04..5353348 100644 --- a/subscription/receivers.py +++ b/subscription/receivers.py @@ -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') diff --git a/subscription/tests.py b/subscription/tests.py index a307344..eb73d72 100644 --- a/subscription/tests.py +++ b/subscription/tests.py @@ -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) diff --git a/support/admin.py b/support/admin.py index ab45141..c91d26e 100644 --- a/support/admin.py +++ b/support/admin.py @@ -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) هم که ذخیره شود اعلان می‌رود. diff --git a/support/apps.py b/support/apps.py index e18c7be..d50789f 100644 --- a/support/apps.py +++ b/support/apps.py @@ -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 (اعلانِ پاسخِ پشتیبانی) diff --git a/support/receivers.py b/support/receivers.py new file mode 100644 index 0000000..5c6d646 --- /dev/null +++ b/support/receivers.py @@ -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, + ) diff --git a/support/tests.py b/support/tests.py index c4362cf..6008ebf 100644 --- a/support/tests.py +++ b/support/tests.py @@ -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())