diff --git a/.env.example b/.env.example
index ddce9a5..9caafb9 100644
--- a/.env.example
+++ b/.env.example
@@ -100,8 +100,10 @@ BAZAAR_REFRESH_TOKEN=
# ═══════════════════════════════════════════════════════════════════════
# ۶) اختیاری — پیشفرضها معمولاً کافیاند
# ═══════════════════════════════════════════════════════════════════════
-# کلیدِ مسیرهای داخلیِ سرور-به-سرور؛ خالی روی سرور = آن مسیرها بستهاند.
+# کلیدِ مسیرهای داخلیِ سرور-به-سرور؛ خالی = آن مسیرها بستهاند (در DEBUG هم پیشفرضی ندارد).
# INTERNAL_SERVICE_KEY=
+# مهمانهای نمایشی که با «حذفِ حساب» پاک نمیشوند (با کاما). پیشفرض: frontend-test-device.
+# DEMO_GUEST_DEVICE_IDS=
# با صفر قلب جلوی جواب دادن گرفته شود؟ پیشفرض روشن (از ۲۰۲۶-۱۰-۰۲)؛ ۰ خاموشش میکند.
# HEARTS_ENFORCED=1
# سقفهای کدِ ورود: هر شماره در روز، و کلِ سامانه در روز (ضدِ SMS pumping).
diff --git a/.gitignore b/.gitignore
index 4ec2cd5..71cff48 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,7 +10,7 @@ data/textbooks/*
!data/textbooks/README.md
# کلیدِ امضای تولیدشدهی خودکار (هرگز کامیت نشود)
-.django_secret_key
+.django_secret_key*
# خروجیِ collectstatic — تولیدی است، در هر دیپلوی از نو ساخته میشود.
staticfiles/
diff --git a/CLAUDE.md b/CLAUDE.md
index fb9ce58..647abbb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -65,7 +65,9 @@ default hosts/CORS/cache/secret key (`/api/health` warns); `LocMemCache` without
OAuth path beside the Pishkhan token (server still uses OAuth); `birthYear` alone in
`PATCH /api/user/profile` (frontend type still allows it); `totalQuestions` accepted but
ignored (frontend sends it); orders with no `plan_id` from before 2026-09-25 (none
-paid on the server on 2026-10-03).
+paid on the server on 2026-10-03); orders without the `question_credits`/`hearts_days`/
+`duration_days` snapshot (`payment.0011`) grant and refund from the plan's current values;
+`DEMO_GUEST_DEVICE_IDS` defaults to `frontend-test-device` (remove with the demo data).
## Before any real user: read `docs/security-before-production.md`
diff --git a/accounts/admin.py b/accounts/admin.py
index d26d9c2..a010794 100644
--- a/accounts/admin.py
+++ b/accounts/admin.py
@@ -49,8 +49,27 @@ class SendSMSForm(forms.Form):
return cleaned_data
+class DeviceIdForSuperuserOnly:
+ """
+ شناسهی دستگاه برای مهمان تنها رمزِ ورود است (`POST /api/auth/session`)؛ پس فقط ابرکاربر
+ آن را در فهرست، جستجو و فرم میبیند، نه کارمندی با اجازهی دیدن (گروهِ Supervisor).
+ """
+
+ def get_list_display(self, request):
+ cols = super().get_list_display(request)
+ return cols if request.user.is_superuser else tuple(c for c in cols if c != 'device_id')
+
+ def get_search_fields(self, request):
+ cols = super().get_search_fields(request)
+ return cols if request.user.is_superuser else tuple(c for c in cols if c != 'device_id')
+
+ def get_exclude(self, request, obj=None):
+ exclude = tuple(super().get_exclude(request, obj) or ())
+ return exclude if request.user.is_superuser else exclude + ('device_id',)
+
+
@admin.register(User)
-class UserAdmin(admin.ModelAdmin):
+class UserAdmin(DeviceIdForSuperuserOnly, admin.ModelAdmin):
list_display = ('id', 'public_id', 'name', 'phone', 'device_id', 'is_guest', 'grade_id', 'created_at')
search_fields = ('name', 'phone', 'device_id')
@@ -165,7 +184,7 @@ class ProfileAdmin(admin.ModelAdmin):
@admin.register(Session)
-class SessionAdmin(admin.ModelAdmin):
+class SessionAdmin(DeviceIdForSuperuserOnly, admin.ModelAdmin):
list_display = ('id', 'user', 'device_id', 'expires_at', 'revoked')
list_filter = ('revoked',)
diff --git a/accounts/management/commands/delete_users.py b/accounts/management/commands/delete_users.py
index 12c3cea..a313637 100644
--- a/accounts/management/commands/delete_users.py
+++ b/accounts/management/commands/delete_users.py
@@ -68,15 +68,20 @@ def plan(user_ids):
return counts
-def delete_users(user_ids, keep=()):
- """keep: برچسبِ مدلهایی که نمیمانند پاک شوند (مثلاً 'payment.Order' برای حذفِ حسابِ خودِ کاربر)."""
+def delete_users(user_ids, keep=(), delete_otp_codes=True):
+ """
+ keep: برچسبِ مدلهایی که نباید پاک شوند (مثلاً 'payment.Order' برای حذفِ حسابِ خودِ کاربر).
+ delete_otp_codes: این دستور (کارِ ادمین) کدهای شماره را پاک میکند تا آن شماره از صفر شروع کند؛
+ حذفِ حساب به دستِ خودِ کاربر این کار را نمیکند تا سقفِ پیامکِ شماره دور زده نشود.
+ """
phones = list(User.objects.filter(pk__in=user_ids).exclude(phone=None).values_list('phone', flat=True))
with transaction.atomic():
for model, column in user_scoped_models():
if model._meta.label in keep:
continue
model.objects.filter(**{f'{column}__in': user_ids}).delete()
- OTPCode.objects.filter(phone__in=phones).delete()
+ if delete_otp_codes:
+ OTPCode.objects.filter(phone__in=phones).delete()
User.objects.filter(pk__in=user_ids).delete()
diff --git a/accounts/serializers.py b/accounts/serializers.py
index 036def7..3b34276 100644
--- a/accounts/serializers.py
+++ b/accounts/serializers.py
@@ -5,16 +5,23 @@ from rest_framework import serializers
from .phone import normalize_phone
# لینک، آیدیِ شبکهی اجتماعی، و هر رشتهی ۵ رقمی یا بیشتر (شمارهی تلفن با
-# فاصله/خطتیره هم) — ارقامِ فارسی و عربی هم.
+# فاصله/خطتیره هم) — ارقامِ فارسی و عربی هم. این نام در رتبهبندی به بچههای دیگر نشان داده میشود.
_CONTACT = re.compile(
r'(https?://\S+|www\.\S+|\S+\.(?:com|ir|org|net|me)\S*|@\w+'
- r'|(?:[0-9۰-۹٠-٩][\s\-.]?){5,})',
+ r'|(?:[0-9۰-۹٠-٩][\s\-._/\\|,،]*){5,})',
+ re.IGNORECASE,
+)
+# «ig: name»، «insta name»، «آیدی name» و مانندش: کلیدواژه و هر چه بعدش آمده حذف میشود.
+_HANDLE = re.compile(
+ r'(?:\b(?:ig|insta(?:gram)?|tele(?:gram)?|tg|id|whats ?app|rubika|eitaa|bale)\b'
+ r'|اینستا\S*|(?:تلگرام|تل|آیدی|ایدی|واتساپ|روبیکا|ایتا)(?!\w)|بله\s*:)\s*[::]?.*',
re.IGNORECASE,
)
def clean_display_name(value: str) -> str:
- return ' '.join(_CONTACT.sub(' ', value or '').split())[:30]
+ value = _HANDLE.sub(' ', value or '')
+ return ' '.join(_CONTACT.sub(' ', value).split())[:30]
class IranMobileField(serializers.CharField):
diff --git a/accounts/services.py b/accounts/services.py
index 2adfeb2..446865d 100644
--- a/accounts/services.py
+++ b/accounts/services.py
@@ -134,8 +134,10 @@ def _kavenegar_post(*, phone: str, method: str, payload: dict) -> None:
'phone_suffix=%s method=%s', _phone_suffix(phone), method,
)
raise SMSDeliveryUncertain('پاسخِ سرویسِ پیامک دیر رسید.')
- except requests.RequestException:
- logger.exception('SMS request to provider failed: phone_suffix=%s', _phone_suffix(phone))
+ except requests.RequestException as exc:
+ # فقط نوعِ خطا: متنِ خطای requests آدرسِ کامل را دارد و کلیدِ API بخشی از همان آدرس است.
+ logger.error('SMS request to provider failed: phone_suffix=%s method=%s error=%s',
+ _phone_suffix(phone), method, type(exc).__name__)
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
if response.status_code != 200:
diff --git a/accounts/tests_security_review.py b/accounts/tests_security_review.py
new file mode 100644
index 0000000..6e82abb
--- /dev/null
+++ b/accounts/tests_security_review.py
@@ -0,0 +1,179 @@
+"""
+حفاظهای بازبینیِ امنیتیِ پیش از تحویل (هویت و حساب). هر تست رفتاری را نگه میدارد که پیشتر غلط بود.
+"""
+import logging
+import socket
+from unittest import mock
+
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
+from django.core.cache import cache
+from django.core.management import call_command
+from django.test import TestCase, override_settings
+from django.utils import timezone
+from rest_framework.test import APIClient
+
+from accounts.authentication import issue_access_token
+from accounts.models import OTPCode, User
+from config.log_formatters import JSONLogFormatter
+
+
+def _bearer(token):
+ c = APIClient()
+ c.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
+ return c
+
+
+def _otp_row(phone, code='482913'):
+ OTPCode.objects.create(phone=phone, code=code, expires_at=timezone.now() + timezone.timedelta(minutes=5))
+ return code
+
+
+def _guest(device):
+ return APIClient().post('/api/auth/session', {'type': 'guest', 'deviceId': device}, format='json').json()
+
+
+class GuestUpgradeRevokesEarlierSessionsTests(TestCase):
+ """کسی که پیش از ثبتِ شماره با شناسهی دستگاه نشست گرفته بود، بعد از آن به حسابِ شمارهدار نمیرسید."""
+
+ def setUp(self):
+ cache.clear()
+
+ def test_pre_upgrade_tokens_stop_working(self):
+ attacker = _guest('shared-device')
+ victim = _guest('shared-device')
+ login = _bearer(victim['token']).post('/api/auth/login',
+ {'phone': '09121230001', 'otp': _otp_row('09121230001')},
+ format='json')
+ self.assertEqual(login.status_code, 200)
+ self.assertEqual(login.json()['userId'], victim['userId'])
+
+ refresh = APIClient().post('/api/auth/refresh', {'refreshToken': attacker['refreshToken']}, format='json')
+ self.assertEqual(refresh.status_code, 401)
+ # توکنِ دسترسیِ پیش از ثبت هم (اگر همان ثانیه صادر نشده باشد) رد میشود؛ نشستِ تازه کار میکند.
+ self.assertEqual(_bearer(login.json()['token']).get('/api/user/profile').status_code, 200)
+ fresh = APIClient().post('/api/auth/refresh', {'refreshToken': login.json()['refreshToken']}, format='json')
+ self.assertEqual(fresh.status_code, 200)
+
+
+class AccountDeletionKeepsLimitsTests(TestCase):
+ def setUp(self):
+ cache.clear()
+ from discount.models import DiscountCode
+ from subscription.models import Plan
+ self.plan = Plan.objects.create(title='pack20', price=45000, kind='pack',
+ question_credits=20, hearts_days=7, duration_days=0)
+ DiscountCode.objects.create(code='FREE1', type='percent', value=100, max_uses=1)
+
+ def _guest_redeems(self, device):
+ s = _guest(device)
+ c = _bearer(s['token'])
+ p = c.post('/api/subscription/purchase', {'planId': str(self.plan.id)}, format='json')
+ return c, c.post('/api/discount/redeem', {'code': 'FREE1', 'orderId': p.json()['orderId']}, format='json')
+
+ def test_single_use_code_stays_used_after_delete(self):
+ c1, first = self._guest_redeems('g-1')
+ self.assertEqual(first.status_code, 200)
+ self.assertEqual(c1.post('/api/user/delete', {'confirm': 'حذف'}, format='json').status_code, 200)
+ _, again = self._guest_redeems('g-2')
+ self.assertEqual(again.status_code, 400)
+
+ @override_settings(DEBUG=False, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
+ def test_per_phone_sms_cap_survives_delete(self):
+ phone = '09121230009'
+ user = User.objects.create(phone=phone, is_guest=False)
+ for i in range(10):
+ OTPCode.objects.create(phone=phone, code=f'{i:06d}', is_used=True,
+ expires_at=timezone.now() - timezone.timedelta(minutes=i + 1))
+ with mock.patch('accounts.views.send_otp_sms'):
+ OTPCode.objects.create(phone=phone, code='777777',
+ expires_at=timezone.now() + timezone.timedelta(minutes=5))
+ d = _bearer(issue_access_token(user.id)).post('/api/user/delete', {'otp': '777777'}, format='json')
+ self.assertEqual(d.status_code, 200)
+ cache.clear()
+ r = APIClient().post('/api/auth/otp/request', {'phone': phone}, format='json')
+ self.assertEqual(r.status_code, 429)
+
+ def test_public_demo_guest_cannot_be_deleted(self):
+ demo = User.objects.create(device_id='frontend-test-device', is_guest=True, name='demo')
+ s = _guest('frontend-test-device')
+ self.assertEqual(int(s['userId']), demo.id)
+ r = _bearer(s['token']).post('/api/user/delete', {'confirm': 'حذف'}, format='json')
+ self.assertEqual(r.status_code, 403)
+ self.assertTrue(User.objects.filter(pk=demo.id).exists())
+
+
+class DisplayNameContactFilterTests(TestCase):
+ def test_contacts_are_removed_and_names_kept(self):
+ from accounts.serializers import clean_display_name as clean
+
+ for sample in ('0912/123/4567', '۰۹۱۲/۱۲۳/۴۵۶۷', '0912 _123_ 4567', 'ig: ali_kid_2014', 'آیدی: kid2014'):
+ self.assertEqual(clean(sample), '', sample)
+ for name in ('علی رضایی', 'آیدین', 'Sara 2014', 'ایتالیا', 'تلما'):
+ self.assertEqual(clean(name), name)
+
+
+class _Capture(logging.Handler):
+ def __init__(self):
+ super().__init__()
+ self.lines = []
+ self.setFormatter(JSONLogFormatter())
+
+ def emit(self, record):
+ self.lines.append(self.format(record))
+
+
+FAKE_KEY = '4E6F74415265616C4B6176656E656761724B6579414243444546'
+
+
+@override_settings(KAVENEGAR_API_KEY=FAKE_KEY, KAVENEGAR_OTP_TEMPLATE='SMSVerify')
+class KavenegarKeyNotLoggedTests(TestCase):
+ """کلیدِ کاوهنگار بخشی از آدرس است و متنِ خطای requests آدرس را دارد."""
+
+ def test_connection_error_log_has_no_key(self):
+ from accounts import services
+
+ cap = _Capture()
+ log = logging.getLogger('accounts.services')
+ log.addHandler(cap)
+
+ def no_dns(*args, **kwargs):
+ raise socket.gaierror(socket.EAI_NONAME, 'network disabled in tests')
+
+ try:
+ with mock.patch('socket.getaddrinfo', no_dns), self.assertRaises(services.SMSDeliveryError):
+ services.send_verify_lookup(phone='09120000000', template='SMSVerify', token='123456')
+ finally:
+ log.removeHandler(cap)
+ out = '\n'.join(cap.lines)
+ self.assertIn('SMS request to provider failed', out)
+ self.assertNotIn(FAKE_KEY, out)
+
+
+class SupervisorDoesNotSeeDeviceIdsTests(TestCase):
+ """شناسهی دستگاه برای مهمان رمزِ ورود است؛ کارمندِ فقطخواندنی آن را نمیبیند."""
+
+ def setUp(self):
+ call_command('setup_supervisor_group', verbosity=0)
+ self.guest = User.objects.create(device_id='secret-device-123', is_guest=True)
+ from accounts.models import Session
+ Session.objects.create(user=self.guest, refresh_token='h' * 64, device_id='secret-device-123',
+ expires_at=timezone.now() + timezone.timedelta(days=1))
+ staff = get_user_model().objects.create_user('sup', 'sup@example.com', 'pw-for-tests-only', is_staff=True)
+ staff.groups.add(Group.objects.get(name='Supervisor'))
+ # Supervisor نشستها را نمیبیند؛ اینجا اجازهی دیدن داده میشود تا ستونِ آن صفحه هم سنجیده شود.
+ from django.contrib.auth.models import Permission
+ staff.user_permissions.add(Permission.objects.get(codename='view_session', content_type__app_label='accounts'))
+ self.client.force_login(staff)
+
+ def test_hidden_in_list_search_and_form(self):
+ listing = self.client.get('/admin/accounts/user/')
+ self.assertEqual(listing.status_code, 200)
+ self.assertNotContains(listing, 'secret-device-123')
+ # متنِ جستجو در خودِ صفحه تکرار میشود؛ نتیجه نباید همان مهمان باشد.
+ search = self.client.get('/admin/accounts/user/', {'q': 'secret-device-123'})
+ self.assertNotContains(search, f'/admin/accounts/user/{self.guest.pk}/change/')
+ form = self.client.get(f'/admin/accounts/user/{self.guest.pk}/change/')
+ self.assertNotContains(form, 'secret-device-123')
+ sessions = self.client.get('/admin/accounts/session/')
+ self.assertNotContains(sessions, 'secret-device-123')
diff --git a/accounts/views.py b/accounts/views.py
index d562435..9f7981d 100644
--- a/accounts/views.py
+++ b/accounts/views.py
@@ -362,7 +362,11 @@ class LoginView(APIView):
# جزیرهها و گفتگوها همه با همین user_id ذخیره شدهاند. پیشتر یک
# کاربرِ تازه و خالی ساخته میشد و پیشرفتِ مهمان جا میماند.
guest.phone = phone
- guest.save(update_fields=['phone'])
+ # هر نشستی که پیش از این با شناسهی دستگاه گرفته شده بود (گوشیِ مشترک، کسی که
+ # شناسه را دیده) نباید به حسابِ شمارهدار برسد؛ فقط نشستِ همین ورود میماند.
+ guest.tokens_valid_after = timezone.now()
+ guest.save(update_fields=['phone', 'tokens_valid_after'])
+ Session.objects.filter(user=guest, revoked=False).update(revoked=True)
user, created = guest, True
elif user is None:
user, created = User.objects.get_or_create(
@@ -426,7 +430,10 @@ DELETE_CONFIRM_WORD = 'حذف'
# سوابقِ پرداخت سندِ مالیاند و با حذفِ حساب نمیروند (تراکنشها با سفارش میمانند).
# Conversion هم میماند: ردیفِ «کاربرِ معرفیشده» سابقهی پورسانتِ *معرف* است، نه دادهی این
# کاربر؛ تا ۲۰۲۶-۱۰-۰۳ با حذفِ حسابِ معرفیشده پورسانتِ معرف هم پاک میشد.
-KEPT_ON_ACCOUNT_DELETE = ('payment.Order', 'statuslog.StatusChangeLog', 'affiliate.Conversion')
+# Redemption و UserOffer هم میمانند: سقفِ مصرفِ کدِ تخفیف ردیفهای Redemption را میشمارد، پس پاک
+# شدنشان یک کدِ یکبارمصرف را با «بساز، بخر، حذف کن» دوباره آزاد میکرد.
+KEPT_ON_ACCOUNT_DELETE = ('payment.Order', 'statuslog.StatusChangeLog', 'affiliate.Conversion',
+ 'discount.Redemption', 'discount.UserOffer')
class DeleteAccountView(APIView):
@@ -439,7 +446,7 @@ class DeleteAccountView(APIView):
بتواند حساب را برای همیشه پاک کند.
- مهمان شماره ندارد؛ باید کلمهی «حذف» را تایپ کرده باشد. حسابش فقط به همین دستگاه وصل است.
- داده در همهی اپها پاک میشود (delete_users)، جز سفارشها و تراکنشهایشان که سندِ مالیاند.
- - حسابهای دمو (TEST_LOGIN_PHONES) حذف نمیشوند؛ مشترکاند و برای ارائه پُرشدهاند.
+ - حسابهای دمو (TEST_LOGIN_PHONES و مهمانهای DEMO_GUEST_DEVICE_IDS) حذف نمیشوند؛ مشترکاند و برای ارائه پُرشدهاند.
- بعد از حذف، توکنهای همین کاربر کار نمیکنند چون کاربری با آن شناسه نمانده.
"""
@@ -460,7 +467,8 @@ class DeleteAccountView(APIView):
v = serializer.validated_data
user = request.user
- if user.phone and user.phone in settings.TEST_LOGIN_PHONES:
+ if (user.phone and user.phone in settings.TEST_LOGIN_PHONES) or (
+ user.is_guest and user.device_id in settings.DEMO_GUEST_DEVICE_IDS):
return Response({'detail': 'حسابِ نمایشی را نمیشود حذف کرد.'}, status=status.HTTP_403_FORBIDDEN)
if user.phone:
if not v.get('otp'):
@@ -474,7 +482,8 @@ class DeleteAccountView(APIView):
status=status.HTTP_400_BAD_REQUEST)
logger.info('Account deleted by its owner: user_id=%s guest=%s', user.id, user.is_guest)
- delete_users([user.id], keep=KEPT_ON_ACCOUNT_DELETE)
+ # کدهای پیامکیِ شماره میمانند: سقفِ روزانهی پیامکِ هر شماره از همین ردیفها شمرده میشود.
+ delete_users([user.id], keep=KEPT_ON_ACCOUNT_DELETE, delete_otp_codes=False)
return Response(DeleteAccountResponseSerializer({'success': True}).data)
diff --git a/ai_tutor/tests_security_review.py b/ai_tutor/tests_security_review.py
new file mode 100644
index 0000000..d9af357
--- /dev/null
+++ b/ai_tutor/tests_security_review.py
@@ -0,0 +1,138 @@
+"""
+حفاظهای بازبینیِ امنیتیِ پیش از تحویل (دستیار). مدل و Qdrant همه ساختگیاند.
+"""
+import json
+from types import SimpleNamespace
+from unittest.mock import patch
+
+from django.core.cache import cache
+from django.test import TestCase, override_settings
+from rest_framework.test import APIClient
+
+from accounts.authentication import issue_access_token
+from accounts.models import User
+from ai_tutor.models import Message
+from rag.services.clients import reset_clients_cache
+
+RAG_ON = dict(OPENAI_API_KEY='t', QDRANT_API_KEY='t', RAG_FORCE_MOCK=False, AI_DAILY_QUOTA_FREE=3)
+
+ANSWER = '۱) دو بهعلاوهی دو میشود چهار.\n✅ جواب: ۴\n'
+OVERFLOW_DIAGRAM = '{"kind":"numberline","min":1' + '0' * 400 + '*2,"max":5}'
+DEEP_DIAGRAM = '' + '[' * 3000 + ']' * 3000 + ''
+
+
+class FakeOpenAI:
+ def __init__(self, reply):
+ self.reply = reply
+ self.calls = 0
+ self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create))
+ self.embeddings = SimpleNamespace(
+ create=lambda **k: SimpleNamespace(data=[SimpleNamespace(embedding=[0.01] * 1536)]))
+
+ def _create(self, **kw):
+ self.calls += 1
+ if kw.get('stream'):
+ parts = [self.reply[i:i + 25] for i in range(0, len(self.reply), 25)]
+ events = [SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content=p))], usage=None)
+ for p in parts]
+ events.append(SimpleNamespace(choices=[], usage=SimpleNamespace(prompt_tokens=900, completion_tokens=300)))
+ return iter(events)
+ return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content=self.reply))],
+ usage=SimpleNamespace(prompt_tokens=900, completion_tokens=300))
+
+
+def sse(resp):
+ out = []
+ for block in b''.join(resp.streaming_content).decode().split('\n\n'):
+ ev = data = None
+ for line in block.strip().splitlines():
+ if line.startswith('event: '):
+ ev = line[7:]
+ elif line.startswith('data: '):
+ data = json.loads(line[6:])
+ if ev:
+ out.append((ev, data))
+ return out
+
+
+@override_settings(**RAG_ON)
+class _Base(TestCase):
+ def setUp(self):
+ cache.clear()
+ reset_clients_cache()
+ self.user = User.objects.create(phone='09120009901', is_guest=False)
+ self.c = APIClient()
+ self.c.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
+
+ def ask(self, q, **extra):
+ return self.c.post('/api/ai/ask', {'userQuestion': q, **extra}, format='json')
+
+ def remaining(self):
+ return self.c.get('/api/ai/quota').data['remainingQuota']
+
+
+class BrokenDiagramDoesNotMakeAnswersFreeTests(_Base):
+ """پارسرِ شکل OverflowError/RecursionError را بالا میداد و جوابِ استریمشده رایگان و بیثبت میماند."""
+
+ def test_stream_and_json_answers_are_charged(self):
+ for diagram in (OVERFLOW_DIAGRAM, DEEP_DIAGRAM):
+ with patch('rag.services.clients.get_openai_client', return_value=FakeOpenAI(ANSWER + diagram)):
+ events = sse(self.ask(f'دو بهعلاوهی دو؟ {len(diagram)}', stream=True))
+ self.assertEqual(events[-1][0], 'done')
+ self.assertIsNone(events[-1][1]['diagram'])
+ with patch('rag.services.clients.get_openai_client', return_value=FakeOpenAI(ANSWER + OVERFLOW_DIAGRAM)):
+ self.assertEqual(self.ask('دو بهعلاوهی دو؟').status_code, 200)
+ self.assertEqual(self.remaining(), 0)
+ self.assertEqual(Message.objects.filter(role='assistant').count(), 3)
+
+
+@override_settings(RAG_ANSWER_CACHE_TTL=300)
+class LateOffTopicMarkerTests(_Base):
+ def test_rest_is_not_streamed_stored_or_cached(self):
+ from rag.services.guardrails import OFF_TOPIC_MARKER, REFUSAL
+
+ reply = ('۱) این یک جملهی ریاضیِ بلند برای عبور از چهل نویسه است.\n'
+ f'{OFF_TOPIC_MARKER} متنِ غیرِ ریاضی: یک انشای کامل دربارهی فوتبال.\n')
+ fake = FakeOpenAI(reply)
+ q = 'سوال دربارهی چیزی'
+ with patch('rag.services.clients.get_openai_client', return_value=fake):
+ streamed = ''.join(d['delta'] for e, d in sse(self.ask(q, stream=True)) if e == 'chunk')
+ other = User.objects.create(phone='09120009902', is_guest=False)
+ c2 = APIClient()
+ c2.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(other.id)}')
+ second = c2.post('/api/ai/ask', {'userQuestion': q}, format='json')
+ self.assertNotIn('فوتبال', streamed)
+ self.assertIn(REFUSAL, streamed)
+ self.assertEqual(Message.objects.filter(role='assistant').first().text, REFUSAL)
+ self.assertNotIn('فوتبال', second.data['answer'])
+ self.assertEqual(fake.calls, 2) # جوابِ ردشده در کش نرفت
+
+
+class DisconnectMidStreamTests(_Base):
+ def test_partial_answer_is_saved(self):
+ with patch('rag.services.clients.get_openai_client', return_value=FakeOpenAI(ANSWER * 10)):
+ resp = self.ask('سوال', stream=True)
+ it = iter(resp.streaming_content)
+ next(it)
+ next(it)
+ resp._iterator.close() # همان GeneratorExit که بستنِ صفحه میسازد
+ saved = Message.objects.filter(role='assistant')
+ self.assertEqual(saved.count(), 1)
+ self.assertTrue(saved.first().text.endswith('…'))
+ self.assertEqual(self.remaining(), 2)
+
+
+class UnknownCollectionIsNotSearchedTests(_Base):
+ def test_client_chosen_collection_is_ignored(self):
+ seen = []
+
+ def fake_search(collection, vec, **kw):
+ seen.append(collection)
+ return [{'score': 0.9, 'payload': {'title': 'doc', 'text': 'PAYLOAD-OF-UNRELATED-COLLECTION'}}]
+
+ with patch('rag.services.clients.get_openai_client', return_value=FakeOpenAI('پاسخ')), \
+ patch('rag.services.retrieval.search', side_effect=fake_search):
+ r = self.ask('هر چیزی', subject='any', grade='other collection')
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(seen, [])
+ self.assertEqual(r.data['usedChunks'], [])
diff --git a/ai_tutor/views.py b/ai_tutor/views.py
index b416227..a1192af 100644
--- a/ai_tutor/views.py
+++ b/ai_tutor/views.py
@@ -152,7 +152,9 @@ def _is_db_ref_id(value) -> bool:
"""
if value in (None, ''):
return False
- return str(value).strip().lstrip('-').isdigit()
+ # isascii: '²'.isdigit() هم True است ولی int('²') خطا میدهد.
+ text = str(value).strip().lstrip('-')
+ return text.isascii() and text.isdigit()
def _db_ref_or_none(value):
@@ -606,7 +608,7 @@ class AskAIView(APIView):
event: error data: {"detail": "..."}
فاز ۱ (رزرو کوتا + conversation + user_message) قبلاً در post() انجام شده.
"""
- from rag.services import answer_cache
+ from rag.services import answer_cache, guardrails
from rag.services.generation import (
PROMPT_VERSION, chat_model_for, default_follow_ups, stream_answer,
)
@@ -654,12 +656,18 @@ class AskAIView(APIView):
stream_result: dict = {}
started = time.monotonic()
effective_grade = retrieval_meta.get('gradeTitle') or grade_title or ''
- for delta in stream_answer(
- v['userQuestion'], used_chunks or None, effective_grade,
- history=history, focus=focus, result=stream_result, age=age,
- ):
- parts.append(delta)
- yield _sse('chunk', {'delta': delta})
+ try:
+ for delta in stream_answer(
+ v['userQuestion'], used_chunks or None, effective_grade,
+ history=history, focus=focus, result=stream_result, age=age,
+ ):
+ parts.append(delta)
+ yield _sse('chunk', {'delta': delta})
+ except GeneratorExit:
+ # کاربر صفحه را وسطِ جواب بست. سوال خرج میماند (مدل هزینهاش را گرفته)، ولی
+ # همان بخشی که ساخته شد ثبت میشود تا گفتگو سوالِ بیجواب نداشته باشد.
+ self._save_partial_answer(conversation, user, v, ''.join(parts))
+ raise
provider_status.record_success()
answer_text = ''.join(parts).strip() or 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم.'
follow_ups = default_follow_ups()
@@ -678,6 +686,7 @@ class AskAIView(APIView):
}
if stream_result.get('refused'):
used_chunks = []
+ answer_text = guardrails.REFUSAL
elif cacheable:
answer_cache.set(cache_key, {
'answer': answer_text, 'followUps': follow_ups,
@@ -752,6 +761,20 @@ class AskAIView(APIView):
response['X-Accel-Buffering'] = 'no'
return response
+ @staticmethod
+ def _save_partial_answer(conversation, user, v, text):
+ logger.info('stream closed by client mid-answer: user_id=%s conversation_id=%s chars=%s',
+ user.id, conversation.id, len(text))
+ if not text.strip():
+ return
+ try:
+ Message.objects.create(
+ conversation=conversation, role='assistant', text=text.strip() + ' …',
+ question_id=_db_ref_or_none(v.get('questionId')),
+ )
+ except Exception:
+ logger.exception('saving partial answer failed: conversation_id=%s', conversation.id)
+
def _release_reserved_slot(self, conversation, user_message, conversation_is_new):
"""پاککردن رزرو فاز ۱ وقتی فاز بعدی شکست میخورد، تا نه پیام یتیم بماند و نه کوتا مصرفشده حساب شود."""
from subscription.services import refund_credit
diff --git a/config/settings.py b/config/settings.py
index 3bb2291..19d8325 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -53,21 +53,49 @@ if not SECRET_KEY:
else:
import secrets
+ import time
+
+ # سه workerِ gunicorn همزمان به اینجا میرسند. فایل فقط یک بار و اتمی ساخته میشود
+ # (O_EXCL، و نوشتن در فایلِ موقت + rename)، و بقیه همان را میخوانند؛ در غیرِ این صورت هر
+ # worker کلیدِ خودش را میساخت و توکن فقط روی یکی از آنها معتبر بود. سنجیده با سه
+ # پروسهی همزمان، نه با gunicornِ واقعی.
_key_file = BASE_DIR / '.django_secret_key'
- try:
- SECRET_KEY = _key_file.read_text().strip()
- except OSError:
- SECRET_KEY = ''
- if not SECRET_KEY:
- SECRET_KEY = secrets.token_urlsafe(64)
+ _lock_file = BASE_DIR / '.django_secret_key.lock'
+ SECRET_KEY = ''
+ for _attempt in range(50):
try:
- _key_file.write_text(SECRET_KEY)
- _key_file.chmod(0o600)
+ SECRET_KEY = _key_file.read_text().strip()
+ except FileNotFoundError:
+ SECRET_KEY = ''
+ except OSError as exc:
+ raise ImproperlyConfigured(f'خواندنِ {_key_file} ممکن نشد ({exc}).') from exc
+ if SECRET_KEY:
+ break
+ try:
+ _fd = os.open(_lock_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
+ except FileExistsError:
+ time.sleep(0.1) # پروسهی دیگری در حالِ ساختنِ کلید است
+ continue
except OSError as exc:
raise ImproperlyConfigured(
- f'DJANGO_SECRET_KEY ست نیست و نوشتنِ {_key_file} هم ممکن نشد ({exc}). '
+ f'DJANGO_SECRET_KEY ست نیست و ساختنِ {_key_file} هم ممکن نشد ({exc}). '
'یا متغیر را ست کنید یا اجازهی نوشتن در پوشهی پروژه بدهید.'
) from exc
+ try:
+ os.close(_fd)
+ if not _key_file.exists():
+ _tmp = BASE_DIR / '.django_secret_key.tmp'
+ _tmp_fd = os.open(_tmp, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
+ with os.fdopen(_tmp_fd, 'w') as fh:
+ fh.write(secrets.token_urlsafe(64))
+ os.replace(_tmp, _key_file)
+ finally:
+ _lock_file.unlink(missing_ok=True)
+ if not SECRET_KEY:
+ raise ImproperlyConfigured(
+ f'کلیدِ امضا ساخته نشد: {_lock_file} مانده است. اگر هیچ پروسهای در حالِ اجرا نیست، '
+ 'آن را پاک کنید و دوباره راهاندازی کنید.'
+ )
def _env_list(name: str) -> list[str]:
return [v.strip() for v in os.environ.get(name, '').split(',') if v.strip()]
@@ -607,6 +635,12 @@ SPECTACULAR_SETTINGS = {
'filter': True,
'persistAuthorization': True,
},
+ # نسخهی ثابت، نه @latest: این صفحهها روی همان دامنهی /admin/ اجرا میشوند، و نسخهی منتشرشده
+ # در npm عوضشدنی نیست؛ با @latest هر نسخهی آلودهی تازهی این بستهها در مرورگرِ کارمند اجرا میشد.
+ # SRI ندارد. ارتقا یعنی عوض کردنِ همین دو عدد.
+ 'SWAGGER_UI_DIST': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1',
+ 'SWAGGER_UI_FAVICON_HREF': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/favicon-32x32.png',
+ 'REDOC_DIST': 'https://cdn.jsdelivr.net/npm/redoc@2.5.4',
}
# اعتبار توکن دسترسی (ثانیه) و رفرشتوکن (روز)
@@ -702,6 +736,11 @@ def _parse_test_login_phones(raw: str) -> dict[str, str]:
TEST_LOGIN_PHONES = _parse_test_login_phones(os.environ.get('TEST_LOGIN_PHONES', ''))
+# مهمانهای نمایشی که با حذفِ حساب پاک نمیشوند (همان کاری که TEST_LOGIN_PHONES برای شمارهها میکند).
+# پیشفرض: حسابِ مهمانِ تستِ فرانت (کاربرِ ۶ روی سرور)، که شناسهاش در ریپو و مستندات آمده و هر کسی
+# میتواند با آن وارد شود. با پاک شدنِ دادهی دمو پیش از کاربرِ واقعی، این پیشفرض هم برداشته شود.
+DEMO_GUEST_DEVICE_IDS = _env_list('DEMO_GUEST_DEVICE_IDS') or ['frontend-test-device']
+
# -----------------------------------------------------------------------
# OTP SMS delivery (accounts/services.py: send_otp_sms)
# -----------------------------------------------------------------------
@@ -745,10 +784,9 @@ ADMIN_LOGIN_MAX_ATTEMPTS = 10
# POST /api/payment/orders باید فقط سرور-به-سرور (از subscription) صدا زده شود؛
# کلاینت باید هدر X-Internal-Key برابر همین مقدار بفرستد. بدون env در
# پروداکشن مقدار خالی میماند و HasInternalServiceKey صراحتاً رد میکند.
-INTERNAL_SERVICE_KEY = os.environ.get(
- 'INTERNAL_SERVICE_KEY',
- 'dev-internal-service-key-change-me' if DEBUG else '',
-)
+# هیچ پیشفرضی، حتی در DEBUG: رشتهی پیشفرضِ داخلِ ریپو هر لپتاپِ توسعهای را که روی شبکه
+# باز است به ingest در Qdrant راه میداد.
+INTERNAL_SERVICE_KEY = os.environ.get('INTERNAL_SERVICE_KEY', '')
# -----------------------------------------------------------------------
diff --git a/config/urls.py b/config/urls.py
index dc97cb8..2dea47d 100644
--- a/config/urls.py
+++ b/config/urls.py
@@ -13,6 +13,7 @@ from django.views import defaults as django_default_views
from drf_spectacular.utils import extend_schema
from config.health import HealthView
+from rest_framework.throttling import ScopedRateThrottle
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularRedocView,
@@ -46,6 +47,13 @@ def handler500(request):
return django_default_views.server_error(request)
+class ThrottledSchemaView(SpectacularAPIView):
+ """بیورود و بیکش: هر بار ~۵۶ms CPU و ۱۱۰KB؛ بیthrottle چند ده درخواست در ثانیه workerها را پر میکرد."""
+
+ throttle_classes = [ScopedRateThrottle]
+ throttle_scope = 'read'
+
+
urlpatterns = [
path('admin/', admin.site.urls),
@@ -67,7 +75,7 @@ urlpatterns = [
path('api/health/', HealthViewSlashAlias.as_view(), name='health-slash'),
# OpenAPI schema + Swagger UI + Redoc
- path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
+ path('api/schema/', ThrottledSchemaView.as_view(), name='schema'),
# قالبِ خودمان فقط یک پوستهی راستبهچپ روی قالبِ drf-spectacular است؛
# بدونش متنِ فارسی در یک بلوکِ LTR رندر میشود و نقطهی جمله و ترتیبِ
# ستونهای جدول جابهجا میشوند. توضیح در خودِ قالب.
diff --git a/content/admin.py b/content/admin.py
index 554996b..ce9de26 100644
--- a/content/admin.py
+++ b/content/admin.py
@@ -1,6 +1,7 @@
import json
from django.contrib import admin, messages
+from django.core.exceptions import PermissionDenied
from django.db.models import Count, Q
from django.http import HttpResponse
from django.shortcuts import redirect, render
@@ -54,6 +55,9 @@ class GradeAdmin(admin.ModelAdmin):
return response
def import_curriculum_view(self, request):
+ # admin_view فقط is_staff را میسنجد؛ این صفحه پایه/فصل/درس میسازد و متنِ درس را عوض میکند.
+ if not (self.has_add_permission(request) and self.has_change_permission(request)):
+ raise PermissionDenied
if request.method == 'POST':
uploaded = request.FILES.get('file')
if uploaded is None:
@@ -178,6 +182,9 @@ class QuestionAdmin(admin.ModelAdmin):
return response
def import_view(self, request):
+ # admin_view فقط is_staff را میسنجد؛ «جایگزینی» سوالها و جوابهای دانشآموزان به آنها را پاک میکند.
+ if not self.has_add_permission(request):
+ raise PermissionDenied
if request.method == 'POST':
uploaded = request.FILES.get('file')
if uploaded is None:
@@ -185,6 +192,8 @@ class QuestionAdmin(admin.ModelAdmin):
return redirect(request.path)
replace = request.POST.get('replace') == 'on'
+ if replace and not self.has_delete_permission(request):
+ raise PermissionDenied
try:
rows = parse(uploaded.read())
result = import_questions(rows, replace=replace)
@@ -238,7 +247,7 @@ class QuestionReportAdmin(admin.ModelAdmin):
autocomplete_fields = ['question']
actions = ['mark_resolved']
- @admin.action(description='علامت زدن بهعنوانِ بررسیشده')
+ @admin.action(description='علامت زدن بهعنوانِ بررسیشده', permissions=['change'])
def mark_resolved(self, request, queryset):
updated = queryset.update(status='resolved', resolved_at=timezone.now())
self.message_user(request, f'{updated} گزارش بررسیشده علامت خورد.', messages.SUCCESS)
diff --git a/content/admin_browser.py b/content/admin_browser.py
index fa329a9..4ad09be 100644
--- a/content/admin_browser.py
+++ b/content/admin_browser.py
@@ -10,9 +10,12 @@
گزارشهای بازِ کاربران، و درصدِ پاسخِ غلط — یعنی همان سیگنالی که میگوید کدام
سوال ایراد دارد یا بیش از حد سخت است.
+فقط برای کارمندی که اجازهی دیدنِ سوال دارد، چون کلیدِ جوابها را نشان میدهد.
+
وابستگیِ تازهای اضافه نمیکند: چند اندپوینتِ کوچکِ JSON و JavaScript ساده.
"""
from django.contrib.admin.views.decorators import staff_member_required
+from django.contrib.auth.decorators import permission_required
from django.db.models import Count, Q
from django.http import JsonResponse
from django.shortcuts import render
@@ -22,6 +25,7 @@ from .models import Grade, Lesson, Node, Question, Unit
@staff_member_required
+@permission_required('content.view_question', raise_exception=True)
def browser_view(request):
return render(request, 'admin/content/browser.html', {
'title': 'مرورِ محتوا',
@@ -30,6 +34,7 @@ def browser_view(request):
@staff_member_required
+@permission_required('content.view_question', raise_exception=True)
def units_json(request, grade_id: int):
rows = (
Unit.objects.filter(grade_id=grade_id)
@@ -41,6 +46,7 @@ def units_json(request, grade_id: int):
@staff_member_required
+@permission_required('content.view_question', raise_exception=True)
def nodes_json(request, unit_id: int):
rows = (
Node.objects.filter(unit_id=unit_id)
@@ -52,6 +58,7 @@ def nodes_json(request, unit_id: int):
@staff_member_required
+@permission_required('content.view_question', raise_exception=True)
def lessons_json(request, node_id: int):
rows = (
Lesson.objects.filter(node_id=node_id)
@@ -63,6 +70,7 @@ def lessons_json(request, node_id: int):
@staff_member_required
+@permission_required('content.view_question', raise_exception=True)
def questions_json(request, lesson_id: int):
"""
هر سوال با دو سیگنال: گزارشِ بازِ کاربران، و درصدِ پاسخِ غلط.
diff --git a/content/tests_security_review.py b/content/tests_security_review.py
new file mode 100644
index 0000000..44d0741
--- /dev/null
+++ b/content/tests_security_review.py
@@ -0,0 +1,80 @@
+"""
+حفاظهای بازبینیِ امنیتیِ پیش از تحویل (محتوا، پنل و ورودیها). هر تست رفتاری را نگه میدارد که
+پیشتر غلط بود.
+"""
+import io
+import json
+
+from django.contrib.auth.models import Group, User as AuthUser
+from django.core.cache import cache
+from django.core.management import call_command
+from django.test import TestCase
+from rest_framework.test import APIClient
+
+from accounts.authentication import issue_access_token
+from accounts.models import User
+from content.models import Choice, Grade, Lesson, Node, Question, QuestionReport, Unit, UserAnswer
+
+
+class SupervisorCannotEditContentTests(TestCase):
+ """admin_view فقط is_staff را میسنجد؛ صفحههای ورودِ فایل اجازهی مدل را جدا چک نمیکردند."""
+
+ def setUp(self):
+ cache.clear()
+ call_command('setup_supervisor_group', stdout=io.StringIO())
+ sup = AuthUser.objects.create_user('sup', 's@x.c', 'pw12345!', is_staff=True)
+ sup.groups.add(Group.objects.get(name='Supervisor'))
+ self.client.force_login(sup)
+
+ grade = Grade.objects.create(title='G', icon='', color='', number=55)
+ unit = Unit.objects.create(grade=grade, title='U', order=1)
+ node = Node.objects.create(unit=unit, title='N', order=1)
+ self.lesson = Lesson.objects.create(node=node, title='L', intro_text='x')
+ self.q = Question.objects.create(lesson=self.lesson, type='multiple_choice', prompt='p?')
+ ok = Choice.objects.create(question=self.q, value='1', is_correct=True)
+ Choice.objects.create(question=self.q, value='2', is_correct=False)
+ self.student = User.objects.create(phone='09120009999', is_guest=False)
+ UserAnswer.objects.create(user_id=self.student.id, question=self.q, choice=ok, correct=True)
+
+ def test_question_import_with_replace_is_refused(self):
+ f = io.BytesIO(json.dumps([{
+ 'lessonId': self.lesson.id, 'prompt': 'HACKED', 'type': 'multiple_choice',
+ 'choices': ['a', 'b'], 'correct': 0,
+ }]).encode())
+ f.name = 'q.json'
+ r = self.client.post('/admin/content/question/import/', {'file': f, 'replace': 'on'})
+ self.assertEqual(r.status_code, 403)
+ self.assertEqual(list(Question.objects.filter(lesson=self.lesson).values_list('prompt', flat=True)), ['p?'])
+ self.assertEqual(UserAnswer.objects.filter(user_id=self.student.id).count(), 1)
+
+ def test_curriculum_import_and_content_browser_are_refused(self):
+ self.assertEqual(self.client.get('/admin/content/grade/import-curriculum/').status_code, 403)
+ self.assertEqual(self.client.get('/admin/content/question/import/').status_code, 403)
+ self.assertEqual(self.client.get(f'/admin/content/question/browse/questions/{self.lesson.id}/').status_code, 403)
+
+ def test_mark_resolved_action_is_refused(self):
+ report = QuestionReport.objects.create(question=self.q, reason='wrong_answer', user_id=self.student.id)
+ self.client.post('/admin/content/questionreport/', {'action': 'mark_resolved', '_selected_action': [report.pk]})
+ report.refresh_from_db()
+ self.assertNotEqual(report.status, 'resolved')
+
+
+class NonAsciiDigitsAre400Tests(TestCase):
+ """'²'.isdigit() هم True است ولی int('²') خطا میدهد؛ این ورودیها ۵۰۰ میدادند."""
+
+ def setUp(self):
+ cache.clear()
+ user = User.objects.create(device_id='digits', is_guest=True)
+ self.app = APIClient()
+ self.app.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
+
+ def test_character_and_unit_ids(self):
+ self.assertEqual(self.app.post('/api/characters/select', {'characterId': 'abc'}, format='json').status_code, 400)
+ self.assertEqual(self.app.post('/api/characters/select', {'characterId': '²'}, format='json').status_code, 400)
+ self.assertEqual(self.app.get('/api/progress/nodes', {'unit': '²'}).status_code, 400)
+
+ def test_ticket_text_is_capped(self):
+ r = self.app.post('/api/support/bug-reports', {'title': 't', 'description': 'x' * 5001}, format='json')
+ self.assertEqual(r.status_code, 400)
+ ok = self.app.post('/api/support/bug-reports', {'title': 't', 'description': 'x' * 5000}, format='json')
+ self.assertIn(ok.status_code, (200, 201))
diff --git a/content/views.py b/content/views.py
index 8c71f8a..1164da3 100644
--- a/content/views.py
+++ b/content/views.py
@@ -741,7 +741,7 @@ 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:
- if not unit_id.isdigit():
+ if not (unit_id.isascii() and unit_id.isdigit()):
raise ValidationError({'unit': 'شناسهی فصل باید عدد باشد.'})
qs = qs.filter(node__unit_id=int(unit_id))
diff --git a/docker-compose.yml b/docker-compose.yml
index afd212a..5b7d593 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -27,8 +27,10 @@ services:
# idempotent است (مطابقِ همون workflow ای که در README برای اجرای
# محلی/غیرِ Docker هم توصیه شده).
command: sh -c "python manage.py migrate --noinput && python manage.py runserver 0.0.0.0:8000"
+ # فقط روی خودِ لپتاپ: این پشته DEBUG=1 دارد (کدِ 0000 برای هر شماره) و Grafana بیرمز است؛
+ # روی 0.0.0.0 هر همسایهی وایفای به آنها میرسید.
ports:
- - "8000:8000"
+ - "127.0.0.1:8000:8000"
environment:
# این مقادیر فقط برای اجرای محلیِ Docker/دِمو هستن؛ برای پروداکشن
# واقعی جایگزین بشن (به .env.example نگاه کنید).
@@ -53,7 +55,7 @@ services:
image: grafana/loki:2.9.6
container_name: tadrisino_loki
ports:
- - "3100:3100"
+ - "127.0.0.1:3100:3100"
command: -config.file=/etc/loki/loki-config.yml
volumes:
- ./docker/loki/loki-config.yml:/etc/loki/loki-config.yml:ro
@@ -71,7 +73,7 @@ services:
- --storage.path=/var/lib/alloy/data
- /etc/alloy/config.alloy
ports:
- - "12345:12345" # اختیاری: UI دیباگِ Alloy (وضعیتِ کامپوننتها)
+ - "127.0.0.1:12345:12345" # اختیاری: UI دیباگِ Alloy (وضعیتِ کامپوننتها)
volumes:
- ./docker/alloy/config.alloy:/etc/alloy/config.alloy:ro
- django_logs:/var/log/django:ro # همون لاگهای Django رو میخونه (read-only)
@@ -86,12 +88,12 @@ services:
image: grafana/grafana:10.4.3
container_name: tadrisino_grafana
ports:
- - "3000:3000"
+ - "127.0.0.1:3000:3000"
environment:
# فقط برای دسترسیِ محلی/دِمو ساده شده (بدونِ لاگین)؛ برای هر چیزِ
# عمومی/پروداکشن حتماً anonymous access رو خاموش و پسورد ست کنید.
- GF_AUTH_ANONYMOUS_ENABLED=true
- - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
+ - GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin}
volumes:
- ./docker/grafana/provisioning:/etc/grafana/provisioning:ro
diff --git a/docs/docker.md b/docs/docker.md
index 2622c1f..8ae1d0f 100644
--- a/docs/docker.md
+++ b/docs/docker.md
@@ -16,7 +16,7 @@ docker compose up --build
| سرویس | آدرس | توضیح |
|---|---|---|
| اپ Django | `http://localhost:8000` | |
-| Grafana | `http://localhost:3000` | دسترسیِ anonymous، فقط محیطِ محلی |
+| Grafana | `http://localhost:3000` | دسترسیِ anonymous فقط با نقشِ Viewer، فقط روی خودِ لپتاپ (`127.0.0.1`) |
| Loki | `http://localhost:3100` | ذخیرهسازیِ لاگ، در معرضِ کاربر نیست |
| Alloy | `http://localhost:12345` | رابطِ دیباگِ اختیاری |
diff --git a/docs/operations.md b/docs/operations.md
index d54c085..ff3b1f2 100644
--- a/docs/operations.md
+++ b/docs/operations.md
@@ -112,7 +112,8 @@ curl https://mathapi.tadrisino2.ir/api/health
| `OPENAI_API_KEY` · `QDRANT_API_KEY` | `POST /api/ai/ask` همان پاسخِ stub را میدهد |
| `TEST_LOGIN_PHONES` | هیچ حسابِ تستی باز نیست (بخشِ بعد) |
| `HEARTS_ENFORCED` | روشن (از ۲۰۲۶-۱۰-۰۲): با صفر قلب نه جواب پذیرفته میشود نه پایانِ درس/جزیرهی بیجواب. `0` خاموشش میکند |
-| `INTERNAL_SERVICE_KEY` | endpointهای داخلیِ ingestِ RAG و ساختِ سفارشِ سرور-به-سرور بستهاند |
+| `INTERNAL_SERVICE_KEY` | endpointهای داخلیِ ingestِ RAG و ساختِ سفارشِ سرور-به-سرور بستهاند (در DEBUG هم؛ پیشفرض ندارد) |
+| `DEMO_GUEST_DEVICE_IDS` | فقط `frontend-test-device` با «حذفِ حساب» پاک نمیشود. با پاک شدنِ دادهی دمو، پیشفرض هم از `config/settings.py` برداشته شود |
**اتصال به بازار — روشِ جدید (ترجیحی):** در پیشخانِ بازار روی برنامه ← «API پیشخان
بازار» ← «دریافت توکن جدید» (از حسابِ ناشر، نه کارپرداز). همان را در `.env` بهعنوانِ
diff --git a/docs/security-before-production.md b/docs/security-before-production.md
index 333ea28..c99c38c 100644
--- a/docs/security-before-production.md
+++ b/docs/security-before-production.md
@@ -1,6 +1,6 @@
# پیش از پروداکشنِ واقعی: چیزهایی که عمداً باز ماندهاند
-**آخرین بازبینی با کد و سرورِ زنده: ۲۰۲۶-۱۰-۰۴ (۱۲ مهر ۱۴۰۵).**
+**آخرین بازبینی با کد: ۲۰۲۶-۱۰-۰۵ (۱۳ مهر ۱۴۰۵)؛ با سرورِ زنده: ۲۰۲۶-۱۰-۰۴.**
این پروژه تا این تاریخ در حالتِ **دمو و ارائهی کارآموزی** است، نه محصولِ نهایی. چند چیز
عمداً باز گذاشته شده تا توسعهدهندهها، فرانتکار و ارائه کار کنند. هر کدام برای یک سرورِ
@@ -38,6 +38,14 @@
| ۲۰ | یونیتِ systemd `DJANGO_DEBUG=1` و کلیدِ ناامن دارد؛ فقط یک خطِ `.env` جلوی DEBUG را گرفته | بالا (نهفته) | ادمینِ سرور |
| ۲۲ | سایتِ فرانت هیچ هدرِ امنیتی ندارد (CSP، frame-ancestors، HSTS) | کم | nginx/فرانت |
| ۲۳ | سقفِ دستیار برای مهمانها بر اساسِ IP مشترک است؛ کلاسِ پشتِ یک IP بعد از ۲۰ سوال در ساعت ۴۲۹ میگیرد | متوسط (دسترسپذیری) | تصمیمِ محصولی |
+| ۲۴ | سقفِ روزانهی ۳۰۰ پیامکِ کلِ سامانه را یک ناشناس پر میکند و ورود با پیامک برای همه بسته میشود | بالا (دسترسپذیری، هزینه) | تصمیمِ طراحی |
+| ۲۵ | با دانستنِ شمارهی کسی، ورودِ او تا ۲۴ ساعت بسته میشود | متوسط | تصمیمِ طراحی |
+| ۲۶ | پایانِ درس و جزیره به گفتهی کلاینت است (جزیرهی قفل، قلبِ صفر، گزارشِ والدین) | متوسط | کد، بعد از ارائه |
+| ۲۷ | کلیدِ جوابِ هر سوال با یک حسابِ دوم درمیآید؛ امتیازِ هر جوابِ درست دو بار داده میشود | کم | تصمیمِ محصولی |
+| ۲۸ | استفادهی دوباره از توکنِ تمدیدِ دزدیدهشده تشخیص داده نمیشود؛ «خروج از همهی دستگاهها» نیست | کم | کد، بعد از ارائه |
+| ۲۹ | پیشنهادِ ثبتنام با حذفِ حساب و ورودِ دوباره با همان شماره دوباره داده میشود | کم | کد |
+| ۳۰ | چند مسیرِ خواندنی (از جمله بانکِ سوال، بیورود) throttle ندارند | کم | کد، بعد از ارائه |
+| ۳۱ | لپتاپِ توسعه با `mobile-preview` روی شبکهی محلی باز است و در DEBUG کدِ `0000` را میپذیرد | کم | لپتاپِ توسعه |
---
@@ -82,7 +90,9 @@
- **خطر:** دادهی ساختگی در آمار و جدولِ رتبهبندیِ واقعی؛ یک حسابِ عمومی که هر کس
میتواند با آن کار کند (ثبتِ تیکت، گفتگو با AI به هزینهی ما).
- **رفع:** پیش از کاربرِ واقعی پاک شوند. `purge_demo_data` اینها را **نمیگیرد** (از مسیرِ
- واقعیِ viewها ساخته شدهاند)؛ باید با شناسه پاک شوند، پیش از آن بکاپ.
+ واقعیِ viewها ساخته شدهاند)؛ باید با شناسه پاک شوند، پیش از آن بکاپ. همراهِ آن پیشفرضِ
+ `DEMO_GUEST_DEVICE_IDS` در `config/settings.py` (که حذفِ این مهمان را با `POST /api/user/delete`
+ میبندد) هم برداشته شود.
- **چطور بسنجید:** `POST /api/auth/session` با `frontend-test-device` باید `isNewUser: true` بدهد.
## ۴. `/api/health` عمومی و پرجزئیات
@@ -107,8 +117,12 @@
(`X-Internal-Key`) را فهرست میکند.
- **خطر:** شناساییِ کاملِ سطحِ حمله بدونِ زحمت.
- **چرا باز مانده:** قراردادِ مشترک با فرانت همینجاست؛ فرانتکار و ارائه به آن نیاز دارند.
-- **رفع:** در پروداکشن `SERVE_PERMISSIONS` در `SPECTACULAR_SETTINGS` را به
- `IsAdminUser` (staffِ جنگو) محدود کنید، یا مسیر را در nginx به IPهای تیم ببندید.
+- **رفع:** مسیر را در nginx به IPهای تیم ببندید، یا در `SPECTACULAR_SETTINGS` هر دوِ
+ `SERVE_PERMISSIONS = ['rest_framework.permissions.IsAdminUser']` و
+ `SERVE_AUTHENTICATION = ['rest_framework.authentication.SessionAuthentication']` را بگذارید.
+ `IsAdminUser` بهتنهایی کار نمیکند: احرازِ پیشفرضِ DRF فقط توکنِ Bearer است، پس ادمینِ واردشده
+ به پنل هم ۴۰۱ میگیرد (آزموده شد). اسکریپتِ Swagger/ReDoc نسخهی ثابت دارد (نه `@latest`)، چون
+ روی همان دامنهی `/admin/` اجرا میشود.
- **چطور بسنجید:** `curl -o /dev/null -w "%{http_code}" …/api/schema/` باید ۴۰۱/۴۰۳ بدهد.
## ۷. پنلِ ادمین روی دامنهی عمومی
@@ -148,7 +162,9 @@
- **چیست:** `POST /api/auth/session` با هر `deviceId` یا حسابِ مهمانِ همان دستگاه را
برمیگرداند یا میسازد (`accounts/views.py::GuestSessionView`). هیچ رازِ دیگری در کار نیست.
- **خطر:** هر کس `deviceId`ِ یک مهمان را بداند (از `localStorage`، لاگ، اسکرینشات) وارد
- حسابِ او میشود. بعد از ثبتنام با شماره این راه بسته میشود (مهمان دیگر مهمان نیست).
+ حسابِ او میشود. بعد از ثبتنام با شماره این راه بسته میشود (مهمان دیگر مهمان نیست) و همهی
+ نشستهایی که پیش از آن گرفته شده بودند باطل میشوند. شناسهی دستگاه در پنل فقط برای ابرکاربر
+ نمایش داده میشود.
- **رفع:** برای مهمان هم یک رازِ تصادفیِ سمتِ سرور صادر شود و ورودِ دوباره با همان راز باشد،
نه با `deviceId`ِ خام.
@@ -234,6 +250,72 @@
سقفِ سراسریِ `AI_GLOBAL_DAILY_MAX` (۳۰۰۰ در روز، حدودِ ۶ دلار) هزینه را جدا محدود
میکند، پس میشود سطل را per-user کرد و فقط ساختِ مهمان را محدود نگه داشت. تا تصمیم، دست نخورد.
+## ۲۴. پر کردنِ سقفِ سراسریِ پیامک
+
+- **چیست:** `OTP_SMS_GLOBAL_DAILY_MAX` (۳۰۰ در ۲۴ ساعت) جلوی خرجِ بیسقفِ پیامک را میگیرد. هر
+ ناشناسی با درخواستِ کد برای شمارههای تصادفی آن را پر میکند؛ از یک IP بیجعل ساعتی ۲۰ درخواست،
+ یعنی روزی ۴۸۰، پس یک نفر سقف را همیشه پر نگه میدارد.
+- **بازتولید شد:** ۳۰۰ درخواست پذیرفته و ۳۰۰ پیامک فرستاده شد؛ بعد درخواستِ کاربرِ واقعی ۵۰۳.
+- **خطر:** هیچ ثبتنامِ تازه، ورودِ دوباره یا حذفِ حسابی ممکن نیست؛ هزینهی ۳۰۰ پیامک در روز.
+- **رفع (تصمیمِ طراحی):** بعد از گذشتن از یک حد، CAPTCHA یا مانعِ مشابه پیش از ارسال؛ شمارشِ سقف
+ بر اساسِ IP/شبکه؛ هشدار در نیمهی سقف.
+
+## ۲۵. بستنِ ورودِ یک شمارهی مشخص
+
+- **چیست:** ۱۰ درخواستِ کد در ~۲ ساعت سقفِ روزانهی آن شماره را پر میکند (صاحبش تا ۲۴ ساعت کد
+ نمیگیرد)، و ۵ حدسِ غلطِ هر کسی کدِ تازهی او را باطل میکند. هر دو بازتولید شد.
+- **رفع (تصمیمِ طراحی):** کد به درخواستکننده گره بخورد (درخواستِ کد یک شناسهی موقت برگرداند که
+ ورود لازمش دارد) و حدسِ غلط برای هر درخواستکننده جدا شمرده شود.
+
+## ۲۶. پایانِ درس و جزیره به گفتهی کلاینت
+
+- **چیست:** `POST /api/nodes/{id}/quiz/complete` و `POST /api/lessons/{id}/complete` تعدادِ سوال و
+ جوابِ درست را از کلاینت میگیرند. بازتولید شد: با قلبِ صفر و `totalQuestions=1` جزیره تمام
+ میشود؛ جزیرهی قفل با `correctCount=0` تمام میشود؛ درس و جزیره بیهیچ جوابی تمام میشوند، پس
+ `GET /api/user/weekly-report` پیشرفتِ ۱۰۰٪ نشان میدهد بیآنکه سوالی جواب داده شده باشد.
+- **چرا باز مانده:** رفتارِ آزمون در فرانت به همین شکل است؛ عوض کردنش پیش از ارائه جریانِ نقشه را
+ به خطر میاندازد.
+- **رفع:** تعدادِ سوالِ آزمون را سرور تعیین کند (همان `/quiz` با seed)، همان تعداد جوابِ ثبتشده
+ لازم باشد، و جزیرهی قبلی تمام شده باشد.
+
+## ۲۷. کلیدِ جواب با حسابِ دوم، امتیازِ دوباره
+
+- **چیست:** جوابِ غلط `correctOptionId` را برمیگرداند و برای همه یکی است؛ پایانِ درس و پایانِ
+ جزیره هر کدام جدا امتیاز میدهند. یک مهمانِ دورریختنی کلید را درمیآورد و حسابِ اصلی همه را درست
+ میزند (۴ سوال، ۸۰ امتیاز). رتبهبندی و آمارِ درصدِ غلط آلوده میشوند.
+- **رفع (تصمیمِ محصولی):** امتیازِ هر سوال یک بار؛ گزینهی درست فقط بعد از پایانِ آزمون.
+
+## ۲۸. توکنِ تمدیدِ دزدیدهشده
+
+- **چیست:** استفادهی دوباره از یک توکنِ تمدیدِ باطلشده فقط ۴۰۱ میگیرد؛ اگر دزد اول تمدید کند،
+ صاحبِ حساب بیرون میافتد و دزد میماند. «خروج از همهی دستگاهها» هم نیست.
+- **چرا باز مانده:** باطل کردنِ کلِ زنجیره با استفادهی دوباره، حسابهای دمو را که در ارائه چند
+ نفر همزمان با آنها کار میکنند بیرون میاندازد.
+- **رفع:** شناسهی خانواده روی `Session`؛ با استفادهی دوباره کلِ خانواده باطل شود؛ گزینهی خروج از همهجا.
+
+## ۲۹. پیشنهادِ ثبتنام بعد از حذفِ حساب
+
+- **چیست:** پیشنهادِ خودکارِ ثبتنام به `user_id` گره خورده است؛ حذفِ حساب و ورودِ دوباره با همان
+ شماره یک کاربرِ تازه میسازد و پیشنهاد دوباره داده میشود. فقط وقتی اثر دارد که یک Offerِ
+ ثبتنام فعال باشد.
+- **رفع:** گره زدنِ پیشنهادِ ثبتنام به هشِ شماره.
+
+## ۳۰. مسیرهای خواندنیِ بیthrottle
+
+- **چیست:** `lessons/{id}/questions` و `lessons/{id}/intro` (بیورود)، `progress/nodes`،
+ `user/hearts`، `notifications/{id}/read`، `support/tickets/{id}` و چند مسیرِ دیگر `throttle_scope`
+ ندارند؛ کلِ بانکِ سوال بیورود قابلِ کپی است. (`/api/schema/` سقفِ `read` دارد.)
+- **چرا باز مانده:** سقفِ مهمانها بر اساسِ IP است (مورد ۲۳)؛ افزودنِ سقف به این مسیرها برای کلاسی
+ که پشتِ یک وایفای با مهمان کار میکند ۴۲۹ میسازد. همراهِ تصمیمِ مورد ۲۳ بسته شود.
+
+## ۳۱. لپتاپِ توسعه روی شبکهی محلی
+
+- **چیست:** `mobile-preview/serve.py` عمداً روی کلِ شبکه گوش میدهد (برای گوشیِ روی همان وایفای) و
+ `/api/` را به runserverِ محلی میفرستد؛ در DEBUG کدِ `0000` هر شمارهای را وارد میکند و کلیدِ
+ GapGPTِ لپتاپ همان حسابِ سرور است. (`docker compose` فقط روی `127.0.0.1` باز است و کلیدِ سرویسِ
+ داخلی دیگر پیشفرض ندارد.)
+- **رفع:** روی شبکهی عمومی (کافه، مدرسه) اجرا نشود؛ برای توسعه کلیدهای جدای GapGPT و Qdrant.
+
---
## حفاظهایی که هست (تا دوباره باز نشوند)
@@ -249,7 +331,19 @@
| جدولِ رتبهبندی فقط برای کاربرِ واردشده | `progress/tests.py` (`LeaderboardPeriodValidationTests`) |
| شمارهی موبایل نرمال میشود؛ cooldown، سقفِ روزانهی هر شماره و سقفِ کلِ پیامک در دیتابیس شمرده میشوند | `accounts/tests_otp_limits.py` |
| OTPِ ۶ رقمی با ۵ حدس برای هر کد؛ خروج از حساب توکنهای قبلی را باطل میکند | `accounts/tests_audit_fixes.py` |
-| قلبِ صفر جوابِ سوال و پایانِ درس/جزیره را میبندد؛ چکش داخلِ تراکنش است | `content/tests_hearts_gate.py` |
+| قلبِ صفر جوابِ سوال را میبندد؛ چکش داخلِ تراکنش است (پایانِ جزیره را فقط کامل نمیبندد، مورد ۲۶) | `content/tests_hearts_gate.py` |
| `select_for_update` مؤثر با `transaction_mode: IMMEDIATE`؛ مقایسهی زمانثابتِ `X-Internal-Key` | `payment/tests_audit_fixes.py` |
| دیتابیس و بکاپها فقط برای صاحبِ سرویس خواندنیاند (مرحلهی «مجوزِ فایلهای داده» در `deploy.sh`) | با `sudo -u nobody head -c 15 ~/mathapp-v2/db.sqlite3` روی سرور باید خطای دسترسی بدهد |
| کتابخانهها بدونِ آسیبپذیریِ شناختهشده (`urllib3>=2.8.0`، `python-dotenv 1.2.2`) | پایگاهِ OSV روی `pip freeze` |
+| actionهای لغو/بازپرداختِ سفارش، علامتِ «بررسیشده» و لینکِ بازار اجازهی تغییر میخواهند؛ کارمندِ فقطخواندنی (Supervisor) اجرایشان نمیکند | `payment/tests_security_review.py`، `content/tests_security_review.py` |
+| ورودِ فایلِ سوال/برنامهی درسی اجازهی افزودن (و برای جایگزینی، حذف) میخواهد؛ مرورگرِ محتوا اجازهی دیدنِ سوال | `content/tests_security_review.py` |
+| شناسهی دستگاهِ مهمان فقط برای ابرکاربر در پنل دیده میشود؛ ثبتِ شماره نشستهای قبلیِ مهمان را باطل میکند | `accounts/tests_security_review.py` |
+| حذفِ حساب سابقهی کدِ تخفیف، پیشنهادها و کدهای پیامکیِ شماره را نگه میدارد؛ مهمانِ نمایشی حذف نمیشود | `accounts/tests_security_review.py` |
+| صافیِ اطلاعاتِ تماس در نامِ نمایشی (شماره با هر جداکننده، آیدیِ شبکهی اجتماعی) | `accounts/tests_security_review.py` |
+| کلیدِ کاوهنگار و توکنِ خریدِ بازار در لاگِ خطای شبکه نمیآیند | `accounts/tests_security_review.py` |
+| برگشت از زرینپال بی`Authority` ۴۰۰؛ خریدِ دومِ بازار روی سفارشِ پرداختشده ۴۰۹؛ آنچه داده یا پس گرفته میشود از خودِ سفارش است، نه پلنِ فعلی | `payment/tests_security_review.py` |
+| شکلِ خرابِ مدل جواب را رایگان نمیکند؛ علامتِ «خارج از ریاضی» در هر جای استریم جواب را رد میکند و در کش نمیرود؛ بستنِ صفحه جوابِ ناقص را ثبت میکند؛ collectionِ بیرون از `KNOWN_COLLECTIONS` جستجو نمیشود | `ai_tutor/tests_security_review.py` |
+| ورودیِ عددیِ غیرِ ASCII (مثلِ `²`) ۴۰۰ است نه ۵۰۰؛ متنِ تیکت/گزارشِ باگ حداکثر ۵۰۰۰ نویسه | `content/tests_security_review.py` |
+| `INTERNAL_SERVICE_KEY` در هیچ حالتی پیشفرض ندارد | `payment/tests.py` (`test_debug_mode_has_no_default_internal_key`) |
+| کلیدِ امضای خودساخته (`.django_secret_key`) یک بار و اتمی ساخته میشود؛ سه workerِ همزمان یک کلید دارند | دستی: سه پروسهی همزمان که `config.settings` را import میکنند، بیفایلِ کلید؛ ۲۰ از ۲۰ یک کلید |
+| فرانت: شمارهی سفارشِ برگشتی از درگاه فقط عدد است، مسیرها encode میشوند و «پرداخت موفق» فقط از پاسخِ سرور میآید؛ دو تبِ همزمان با تمدیدِ توکن خارج نمیشوند | مخزنِ فرانت: `src/app/launchParams.ts`، `src/api/http.ts` |
diff --git a/payment/admin.py b/payment/admin.py
index 350c35d..3908f19 100644
--- a/payment/admin.py
+++ b/payment/admin.py
@@ -25,7 +25,7 @@ class OrderAdmin(admin.ModelAdmin):
inlines = [TransactionInline]
actions = ['cancel_pending', 'refund']
- @admin.action(description='لغوِ سفارشِ در انتظار')
+ @admin.action(description='لغوِ سفارشِ در انتظار', permissions=['change'])
def cancel_pending(self, request, queryset):
from .services import OrderStateError, cancel_pending_order
@@ -39,7 +39,7 @@ class OrderAdmin(admin.ModelAdmin):
if done:
self.message_user(request, f'{done} سفارش لغو شد.', messages.SUCCESS)
- @admin.action(description='بازپرداخت (پس گرفتنِ اعتبار/اشتراکِ سفارشِ پرداختشده)')
+ @admin.action(description='بازپرداخت (پس گرفتنِ اعتبار/اشتراکِ سفارشِ پرداختشده)', permissions=['change'])
def refund(self, request, queryset):
from django.template.response import TemplateResponse
@@ -97,7 +97,7 @@ class BazaarCredentialAdmin(admin.ModelAdmin):
def has_add_permission(self, request):
return False
- @admin.action(description='ساختِ لینکِ اتصال به بازار')
+ @admin.action(description='ساختِ لینکِ اتصال به بازار', permissions=['change'])
def make_authorize_link(self, request, queryset):
url = bazaar.start_authorization()
self.message_user(request, format_html(
diff --git a/payment/bazaar.py b/payment/bazaar.py
index cb17252..269cf06 100644
--- a/payment/bazaar.py
+++ b/payment/bazaar.py
@@ -295,8 +295,9 @@ def verify_purchase(*, product_sku: str, purchase_token: str,
url = f'{settings.BAZAAR_API_BASE}/api/validate/{pkg}/inapp/{sku}/purchases/{token}/'
try:
resp = requests.get(url, headers=_auth_headers(), timeout=10)
- except requests.RequestException:
- logger.exception('Bazaar verify-purchase request failed')
+ except requests.RequestException as exc:
+ # فقط نوعِ خطا: متنِ خطا آدرس را دارد و توکنِ خریدِ کاربر بخشی از آن است.
+ logger.error('Bazaar verify-purchase request failed: error=%s', type(exc).__name__)
raise BazaarAPIError()
try:
return resp.status_code, resp.json()
diff --git a/payment/migrations/0011_order_plan_snapshot.py b/payment/migrations/0011_order_plan_snapshot.py
new file mode 100644
index 0000000..a3d07f3
--- /dev/null
+++ b/payment/migrations/0011_order_plan_snapshot.py
@@ -0,0 +1,33 @@
+# Generated by Django 5.2.17 on 2026-10-05 01:06
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('payment', '0010_order_cancel_refund'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='order',
+ name='duration_days',
+ field=models.PositiveIntegerField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='order',
+ name='hearts_days',
+ field=models.PositiveIntegerField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='order',
+ name='question_credits',
+ field=models.PositiveIntegerField(blank=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name='transaction',
+ name='gateway_ref_id',
+ field=models.CharField(blank=True, max_length=300),
+ ),
+ ]
diff --git a/payment/models.py b/payment/models.py
index fa21a0e..63e10eb 100644
--- a/payment/models.py
+++ b/payment/models.py
@@ -30,6 +30,12 @@ class Order(models.Model):
# پیش از ۲۰۲۶-۱۰-۰۱ این دو را ندارند (null / خالی).
list_price = models.PositiveIntegerField(null=True, blank=True)
plan_title = models.CharField(max_length=50, blank=True)
+ # آنچه این سفارش فروخته، در لحظهی ساخت (create_order). پرداخت و بازپرداخت همینها را میدهند و
+ # پس میگیرند، نه عددهای فعلیِ پلن: پیشتر اگر ادمین پلن را بینِ ساختِ سفارش و پرداخت عوض میکرد،
+ # خریدارِ بستهی ۴۵ هزار تومانی ۱۵۰ سوال میگرفت. null یعنی سفارشِ بیپلن یا ساختهشده پیش از این ستون.
+ question_credits = models.PositiveIntegerField(null=True, blank=True)
+ hearts_days = models.PositiveIntegerField(null=True, blank=True)
+ duration_days = models.PositiveIntegerField(null=True, blank=True)
currency = models.CharField(max_length=10, default='IRT')
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')
provider = models.CharField(max_length=10, choices=PROVIDER_CHOICES, default='gateway')
@@ -57,7 +63,8 @@ class Order(models.Model):
class Transaction(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='transactions')
gateway = models.CharField(max_length=30, default='zarinpal')
- gateway_ref_id = models.CharField(max_length=100, blank=True)
+ # ۳۰۰ مثلِ Order.bazaar_purchase_token: توکنِ خریدِ بازار همینجا مینشیند.
+ gateway_ref_id = models.CharField(max_length=300, blank=True)
status = models.CharField(max_length=30)
paid_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
diff --git a/payment/services.py b/payment/services.py
index 5d557e1..25aba1d 100644
--- a/payment/services.py
+++ b/payment/services.py
@@ -100,12 +100,18 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
payment_url = None if existing.provider == 'bazaar' else call_payment_gateway(existing)
return existing, payment_url
+ from subscription.models import Plan
+
+ plan = Plan.objects.filter(pk=plan_id).first() if plan_id else None
try:
with db_transaction.atomic():
order = Order.objects.create(
user_id=user_id,
subscription_id=subscription_id,
plan_id=plan_id,
+ question_credits=plan.question_credits if plan else None,
+ hearts_days=plan.hearts_days if plan else None,
+ duration_days=plan.duration_days if plan else None,
amount=amount,
list_price=amount,
plan_title=plan_title[:50],
@@ -206,7 +212,8 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st
if newly_paid:
if order.subscription_id:
from subscription.services import activate_subscription
- activate_subscription(order.subscription_id, plan_id=order.plan_id)
+ activate_subscription(order.subscription_id, plan_id=order.plan_id,
+ duration_days=order.duration_days)
elif order.plan_id:
# بستهی سوال: اعتبار و روزهای قلبِ نامحدود، بیدست زدن به اشتراک.
from subscription.services import grant_pack
@@ -314,7 +321,7 @@ def refund_order(order: Order, *, staff: str = '') -> dict:
if order.subscription_id:
sub = Subscription.objects.select_for_update().filter(pk=order.subscription_id).first()
- days = plan.duration_days if plan else 30
+ days = order.duration_days if order.duration_days is not None else (plan.duration_days if plan else 30)
if sub is not None and sub.expires_at is not None:
sub.expires_at = max(sub.expires_at - timezone.timedelta(days=days), now)
if sub.expires_at <= now:
@@ -331,12 +338,13 @@ def refund_order(order: Order, *, staff: str = '') -> dict:
if take:
QuestionCredit.objects.create(user_id=order.user_id, delta=-take, reason='refund',
order_id=order.id, note=f'بازپرداختِ سفارشِ {order.id}')
- if plan is not None and plan.hearts_days:
+ hearts_days = order.hearts_days if order.hearts_days is not None else (plan.hearts_days if plan else 0)
+ if hearts_days:
hp = HeartsPass.objects.select_for_update().filter(user_id=order.user_id).first()
if hp is not None:
- hp.expires_at = max(hp.expires_at - timezone.timedelta(days=plan.hearts_days), now)
+ hp.expires_at = max(hp.expires_at - timezone.timedelta(days=hearts_days), now)
hp.save(update_fields=['expires_at'])
- summary['hearts_days'] = plan.hearts_days
+ summary['hearts_days'] = hearts_days
from affiliate.models import Conversion
Conversion.objects.filter(order_id=order.id, status='paid').update(
diff --git a/payment/tests.py b/payment/tests.py
index 3b0382b..a31235c 100644
--- a/payment/tests.py
+++ b/payment/tests.py
@@ -46,7 +46,7 @@ class SecretDefaultsFailClosedTests(TestCase):
)
self.assertTrue(HasInternalServiceKey().has_permission(internal_request, None))
- def test_debug_mode_local_fallback_still_valid(self):
+ def test_debug_mode_has_no_default_internal_key(self):
# settings.py این مقادیر رو یکبار موقعِ import محاسبه میکنه، نه
# چیزی که override_settings بعداً toggle کنه؛ پس برای تستِ این
# منطق باید خودِ ماژول رو reload کرد.
@@ -63,10 +63,8 @@ class SecretDefaultsFailClosedTests(TestCase):
with patch.dict(os.environ, clean_env, clear=True):
importlib.reload(settings_module)
self.assertTrue(settings_module.DEBUG)
- self.assertEqual(
- settings_module.INTERNAL_SERVICE_KEY,
- 'dev-internal-service-key-change-me',
- )
+ # رشتهی پیشفرضِ داخلِ ریپو هر لپتاپِ توسعهی بازِ روی شبکه را به ingest راه میداد.
+ self.assertEqual(settings_module.INTERNAL_SERVICE_KEY, '')
finally:
# همیشه ماژول رو به حالتِ واقعیِ محیطِ تست برمیگردونیم تا روی
# بقیهی تستها اثر نذاره.
diff --git a/payment/tests_security_review.py b/payment/tests_security_review.py
new file mode 100644
index 0000000..0407ea2
--- /dev/null
+++ b/payment/tests_security_review.py
@@ -0,0 +1,120 @@
+"""
+حفاظهای بازبینیِ امنیتیِ پیش از تحویل (پرداخت). هر تست رفتاری را نگه میدارد که پیشتر غلط بود.
+"""
+from unittest.mock import patch
+
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
+from django.core.cache import cache
+from django.core.management import call_command
+from django.test import TestCase, override_settings
+from rest_framework.test import APIClient
+
+from accounts.authentication import issue_access_token
+from accounts.models import User
+from discount.models import DiscountCode
+from subscription.models import Plan
+from subscription.services import credit_balance
+
+from .bazaar import BazaarPurchaseResult
+from .models import Order, Transaction
+from .services import apply_order_payment_result
+
+
+def client_for(user):
+ c = APIClient()
+ c.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
+ return c
+
+
+class _Base(TestCase):
+ def setUp(self):
+ cache.clear()
+ self.user = User.objects.create(phone='09120009901', is_guest=False)
+ self.app = client_for(self.user)
+ self.pack = Plan.objects.get(kind='pack', question_credits=20)
+
+ def tearDown(self):
+ cache.clear()
+
+ def pack_order(self, **extra):
+ r = self.app.post('/api/subscription/purchase', {'planId': str(self.pack.id), **extra}, format='json')
+ return Order.objects.get(pk=r.json()['orderId'])
+
+
+class SupervisorCannotRunMoneyActionsTests(_Base):
+ """گروهِ Supervisor فقط view_order دارد؛ actionِ بیpermissions برای هر بینندهی فهرست اجرا میشد."""
+
+ def setUp(self):
+ super().setUp()
+ call_command('setup_supervisor_group', verbosity=0)
+ staff = get_user_model().objects.create_user('sup', 'sup@example.com', 'pw-for-tests-only', is_staff=True)
+ staff.groups.add(Group.objects.get(name='Supervisor'))
+ self.client.force_login(staff)
+
+ def test_refund_is_refused(self):
+ order = self.pack_order()
+ apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='R1', new_status='paid', event='t')
+ self.client.post('/admin/payment/order/', {'action': 'refund', '_selected_action': [order.pk], 'confirm': 'yes'})
+ order.refresh_from_db()
+ self.assertEqual(order.status, 'paid')
+ self.assertEqual(credit_balance(self.user.id), 20)
+
+ def test_cancel_is_refused(self):
+ order = Order.objects.create(user_id=self.user.id, amount=1000, idempotency_key='sec-c1')
+ self.client.post('/admin/payment/order/', {'action': 'cancel_pending', '_selected_action': [order.pk]})
+ order.refresh_from_db()
+ self.assertEqual(order.status, 'pending')
+
+
+class CallbackWithoutAuthorityTests(_Base):
+ def test_missing_authority_is_400_and_touches_no_order(self):
+ DiscountCode.objects.create(code='SEC50', type='percent', value=50, max_uses=10)
+ order = self.pack_order()
+ self.assertEqual(self.app.post('/api/discount/redeem', {'code': 'SEC50', 'orderId': str(order.id)},
+ format='json').status_code, 200)
+ Order.objects.create(user_id=self.user.id, amount=1000, idempotency_key='sec-b') # دومین سفارشِ بازنشده
+
+ resp = APIClient().get('/api/payment/zarinpal/callback', {'Status': 'NOK'})
+ self.assertEqual(resp.status_code, 400)
+ order.refresh_from_db()
+ self.assertEqual(order.status, 'pending')
+
+
+class PlanEditedBetweenOrderAndPaymentTests(_Base):
+ def test_buyer_gets_what_the_order_sold(self):
+ order = self.pack_order()
+ Plan.objects.filter(pk=self.pack.pk).update(price=order.amount * 5, question_credits=150, hearts_days=60)
+ apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='P1', new_status='paid', event='t')
+ self.assertEqual(credit_balance(self.user.id), 20)
+ self.assertEqual(order.question_credits, 20)
+
+
+@override_settings(BAZAAR_PISHKHAN_API_SECRET='x', BAZAAR_PACKAGE_NAME='com.example')
+class BazaarSecondTokenOnPaidOrderTests(_Base):
+ def setUp(self):
+ super().setUp()
+ Plan.objects.filter(pk=self.pack.pk).update(bazaar_sku='sec-pack20')
+
+ def test_second_token_is_409_and_stays_usable(self):
+ order = self.pack_order(paymentMethod='bazaar')
+ valid = BazaarPurchaseResult(is_valid=True, purchase_state=0)
+ with patch('payment.bazaar.verify_purchase', return_value=valid):
+ first = self.app.post('/api/payment/bazaar/verify',
+ {'orderId': str(order.id), 'purchaseToken': 'TOKEN-1'}, format='json')
+ again = self.app.post('/api/payment/bazaar/verify',
+ {'orderId': str(order.id), 'purchaseToken': 'TOKEN-1'}, format='json')
+ second = self.app.post('/api/payment/bazaar/verify',
+ {'orderId': str(order.id), 'purchaseToken': 'TOKEN-2'}, format='json')
+ self.assertEqual(first.json()['status'], 'paid')
+ self.assertEqual(again.status_code, 200) # همان توکن دوباره: بیاثر و بیخطا
+ self.assertEqual(second.status_code, 409)
+ self.assertFalse(Transaction.objects.filter(gateway_ref_id='TOKEN-2').exists())
+
+ r2 = self.app.post('/api/subscription/purchase', {'planId': str(self.pack.id), 'paymentMethod': 'bazaar'},
+ format='json', HTTP_IDEMPOTENCY_KEY='sec-new')
+ with patch('payment.bazaar.verify_purchase', return_value=valid):
+ fresh = self.app.post('/api/payment/bazaar/verify',
+ {'orderId': r2.json()['orderId'], 'purchaseToken': 'TOKEN-2'}, format='json')
+ self.assertEqual(fresh.json()['status'], 'paid')
+ self.assertEqual(credit_balance(self.user.id), 40)
diff --git a/payment/views.py b/payment/views.py
index e376ea5..383ab59 100644
--- a/payment/views.py
+++ b/payment/views.py
@@ -7,6 +7,7 @@ from django.utils.html import escape
from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
+from rest_framework import status
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
@@ -107,7 +108,7 @@ class CreateOrderView(APIView):
if v.get('planId'):
from subscription.models import Plan
- plan = Plan.objects.filter(pk=v['planId']).first() if str(v['planId']).isdigit() else None
+ plan = Plan.objects.filter(pk=v['planId']).first() if (str(v['planId']).isascii() and str(v['planId']).isdigit()) else None
if plan is None:
raise ValidationError({'planId': 'پلنی با این شناسه وجود ندارد.'})
@@ -225,6 +226,10 @@ class ZarinpalCallbackView(APIView):
def get(self, request):
authority = request.query_params.get('Authority', '')
status_param = request.query_params.get('Status', '')
+ # بیAuthority، جستجو هر سفارشی را میگرفت که صفحهی پرداختش هنوز باز نشده (authority خالی):
+ # یک NOKِ ناشناس آن را «ناموفق» میکرد و با دو سفارش ۵۰۰ میداد.
+ if not authority:
+ raise ValidationError({'Authority': 'لازم است.'})
order = get_object_or_404(Order, gateway_authority=authority, provider='gateway')
if status_param != 'OK':
@@ -338,6 +343,8 @@ class BazaarVerifyPurchaseView(APIView):
# میشد؛ یعنی توکنِ خریدِ ارزانترین محصول سفارشِ گرانترین پلن را «paid» میکرد.
if not order_lookup.bazaar_product_sku:
raise ValidationError({'orderId': 'این سفارش محصولِ بازار ندارد.'})
+ if self._second_purchase_on_paid_order(order_lookup, v['purchaseToken']):
+ return self._second_purchase_response()
result = bazaar.verify_purchase(
product_sku=order_lookup.bazaar_product_sku,
purchase_token=v['purchaseToken'],
@@ -349,6 +356,8 @@ class BazaarVerifyPurchaseView(APIView):
pk=v['orderId'], user_id=request.user.id, provider='bazaar',
)
+ if self._second_purchase_on_paid_order(order, v['purchaseToken']):
+ return self._second_purchase_response()
new_status = 'paid' if result.is_valid else 'failed'
order.bazaar_purchase_token = v['purchaseToken']
order.save(update_fields=['bazaar_purchase_token'])
@@ -367,6 +376,20 @@ class BazaarVerifyPurchaseView(APIView):
data = {'orderId': str(order.id), 'status': order.status, 'valid': result.is_valid}
return Response(BazaarVerifyResponseSerializer(data).data)
+ @staticmethod
+ def _second_purchase_on_paid_order(order, purchase_token: str) -> bool:
+ """
+ خریدِ دیگری برای سفارشی که با یک خریدِ دیگر پرداخت شده. پیشتر پاسخ «paid» بود، چیزی داده
+ نمیشد و توکنِ دوم هم برای هر سفارشِ بعدی سوخته بود؛ کاربر دو بار پول داده بود.
+ """
+ return (order.status == 'paid' and bool(order.bazaar_purchase_token)
+ and order.bazaar_purchase_token != purchase_token)
+
+ @staticmethod
+ def _second_purchase_response():
+ return Response({'detail': 'این سفارش با خریدِ دیگری پرداخت شده است؛ برای این خرید سفارشِ تازه بساز.'},
+ status=status.HTTP_409_CONFLICT)
+
class OrderDetailView(APIView):
"""GET /api/payment/orders/{id} — وضعیت سفارش"""
diff --git a/progress/views.py b/progress/views.py
index a061395..2a0ec36 100644
--- a/progress/views.py
+++ b/progress/views.py
@@ -255,7 +255,10 @@ class SelectCharacterView(APIView):
def post(self, request):
serializer = SelectCharacterRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
- character = get_object_or_404(Character, pk=serializer.validated_data['characterId'])
+ character_id = serializer.validated_data['characterId']
+ if not (character_id.isascii() and character_id.isdigit()):
+ raise ValidationError({'characterId': 'شناسهی کاراکتر باید عدد باشد.'})
+ character = get_object_or_404(Character, pk=int(character_id))
stats, _ = UserStats.objects.get_or_create(user_id=request.user.id)
if stats.xp < character.unlock_xp:
diff --git a/rag/services/diagram.py b/rag/services/diagram.py
index 86257ac..e578a11 100644
--- a/rag/services/diagram.py
+++ b/rag/services/diagram.py
@@ -1053,8 +1053,20 @@ def _normalize(obj):
return obj
+# بزرگترین شکلِ معتبرِ سنجشها چند کیلوبایت بود؛ بیش از این نشانهی خروجیِ خراب یا دستکاریشده است.
+MAX_DIAGRAM_CHARS = 8000
+
+
def parse(body: str) -> dict | None:
+ """
+ شکلِ معتبر یا None. هیچ خطایی بیرون نمیرود: شکل اختیاری است، و خطایی که از اینجا بالا میرفت
+ (OverflowError از عددِ ۴۰۰ رقمی، RecursionError از JSONِ خیلی تودرتو) در استریم کلِ جواب را
+ «شکست» حساب میکرد و سوال را رایگان و بیثبت میگذاشت، در حالی که متن به کاربر رسیده بود.
+ """
body = (body or '').strip()
+ if len(body) > MAX_DIAGRAM_CHARS:
+ logger.warning('diagram: بلوکِ شکل %s نویسه بود (سقف %s)، دور ریخته شد', len(body), MAX_DIAGRAM_CHARS)
+ return None
if body.startswith('```'):
body = body.strip('`').removeprefix('json').strip()
try:
@@ -1062,6 +1074,9 @@ def parse(body: str) -> dict | None:
except (json.JSONDecodeError, TypeError, ValueError):
logger.info('diagram: JSONِ نامعتبر از مدل، دور ریخته شد')
return None
+ except Exception:
+ logger.exception('diagram: خطای پیشبینینشده در خواندنِ شکل، دور ریخته شد')
+ return None
class StreamSplitter:
diff --git a/rag/services/generation.py b/rag/services/generation.py
index a6cca78..2b7492e 100644
--- a/rag/services/generation.py
+++ b/rag/services/generation.py
@@ -402,7 +402,15 @@ def stream_answer(question: str, context_chunks: list[dict] | None,
if not clean:
continue
if released:
- yield clean.replace(guardrails.OFF_TOPIC_MARKER, '')
+ if guardrails.OFF_TOPIC_MARKER in clean:
+ # علامت دیر آمد: بخشِ پیش از آن رفته است، ولی بقیه نمیرود و جواب «ردشده» حساب میشود
+ # تا نه ذخیره شود نه در کش برود (پیشتر کاربرِ بعدی همین متن را از کش میگرفت).
+ head = clean.split(guardrails.OFF_TOPIC_MARKER, 1)[0]
+ if head.strip():
+ yield head
+ refused = True
+ continue
+ yield clean
continue
held += clean
if guardrails.OFF_TOPIC_MARKER in held:
@@ -420,7 +428,7 @@ def stream_answer(question: str, context_chunks: list[dict] | None,
result['diagram'] = None if refused else diagram_mod.fits(
f'{question}\n{focus}\n{_previous_question(history)}', splitter.diagram)
if refused:
- yield guardrails.REFUSAL
+ yield ('\n\n' if released else '') + guardrails.REFUSAL
return
if not released and held.strip():
yield held
diff --git a/rag/services/retrieval.py b/rag/services/retrieval.py
index 9c1c6b2..bed5550 100644
--- a/rag/services/retrieval.py
+++ b/rag/services/retrieval.py
@@ -10,7 +10,9 @@ import logging
from .config import build_collection_name, resolve_grade_title, retrieval_top_k
from .embedding import embed_text
-from .known_collections import filter_key_for_subject, is_math_subject, resolve_math_chapter
+from .known_collections import (
+ KNOWN_COLLECTIONS, filter_key_for_subject, is_math_subject, resolve_math_chapter,
+)
from .qdrant_store import search
logger = logging.getLogger(__name__)
@@ -29,6 +31,12 @@ def resolve_target(subject: str, grade_title: str, grade_id, lesson_number):
return None, None, None
collection = build_collection_name(subject, grade_title)
+ if collection not in KNOWN_COLLECTIONS:
+ # subject و grade میتوانند از بدنهی درخواست بیایند؛ بیاین چک هر کاربری از هر collectionِ
+ # Qdrantِ مشترک (از جمله مالِ تیمِ دیگر) متن میخواند و در usedChunks پس میگرفت.
+ # نتیجه همان رفتارِ collectionِ ناموجود است: جوابِ بیcontext.
+ logger.warning('rag.retrieve: collection %r در KNOWN_COLLECTIONS نیست، جستجو نشد', collection)
+ return None, None, None
filter_key = filter_key_for_subject(subject)
if is_math_subject(subject):
filter_value = resolve_math_chapter(subject, grade_title, lesson_number)
diff --git a/rag/tests.py b/rag/tests.py
index e6b68a5..75873cd 100644
--- a/rag/tests.py
+++ b/rag/tests.py
@@ -65,7 +65,8 @@ class _FakeQdrant:
return SimpleNamespace(points=pts)
-@override_settings(**RAG_ON)
+# «علوم تجربی سوم» در KNOWN_COLLECTIONS هست؛ collectionِ ناشناخته اصلاً جستجو نمیشود.
+@override_settings(**{**RAG_ON, 'RAG_GRADE_TITLE_OVERRIDES': {1: 'سوم'}})
class AskAIRagPathTests(TestCase):
def setUp(self):
cache.clear()
@@ -129,6 +130,7 @@ class IngestTextTests(TestCase):
reset_clients_cache()
self.client = APIClient()
+ @override_settings(INTERNAL_SERVICE_KEY='test-internal-key')
def test_ingest_text_creates_chunks(self):
qdrant = _FakeQdrant()
reply = '{"summary": "خلاصهی این تکه."}'
@@ -142,7 +144,7 @@ class IngestTextTests(TestCase):
'text': 'الف' * 900,
},
format='json',
- HTTP_X_INTERNAL_KEY='dev-internal-service-key-change-me',
+ HTTP_X_INTERNAL_KEY='test-internal-key',
)
self.assertEqual(resp.status_code, 200, resp.content)
self.assertGreater(resp.data['chunksCreated'], 0)
diff --git a/subscription/services.py b/subscription/services.py
index 8adb425..045a026 100644
--- a/subscription/services.py
+++ b/subscription/services.py
@@ -76,15 +76,19 @@ def grant_pack(order) -> bool:
plan = Plan.objects.filter(pk=order.plan_id, kind='pack').first()
if plan is None:
return False
+ # عددها از خودِ سفارش (همان که فروخته شد)؛ سفارشِ بیاین ستونها (پیش از آنها، یا ساختهشده بیرون
+ # از create_order) عددهای فعلیِ پلن را میگیرد، همان رفتارِ پیشین.
+ credits = order.question_credits if order.question_credits is not None else plan.question_credits
+ hearts_days = order.hearts_days if order.hearts_days is not None else plan.hearts_days
try:
with transaction.atomic():
- QuestionCredit.objects.create(user_id=order.user_id, delta=plan.question_credits,
+ QuestionCredit.objects.create(user_id=order.user_id, delta=credits,
reason='purchase', order_id=order.id, note=plan.title[:100])
- if plan.hearts_days:
+ if hearts_days:
now = timezone.now()
hp = HeartsPass.objects.select_for_update().filter(user_id=order.user_id).first()
start = hp.expires_at if hp and hp.expires_at > now else now
- until = start + timezone.timedelta(days=plan.hearts_days)
+ until = start + timezone.timedelta(days=hearts_days)
HeartsPass.objects.update_or_create(user_id=order.user_id, defaults={'expires_at': until})
except IntegrityError:
return False
@@ -111,7 +115,7 @@ def ai_daily_quota(user_id: int) -> int:
return max(quotas) if quotas else settings.AI_DAILY_QUOTA_FREE
-def activate_subscription(subscription_id: int, plan_id: int | None = None):
+def activate_subscription(subscription_id: int, plan_id: int | None = None, duration_days: int | None = None):
"""
بعد از تاییدِ پرداخت صدا زده میشود (کالبکِ زرینپال، تاییدِ بازار).
@@ -136,7 +140,9 @@ def activate_subscription(subscription_id: int, plan_id: int | None = None):
# باید دیده شود. پلن را پاک نکنید؛ on_sale=False کنید.
logger.error('activate_subscription: پلنِ %s برای اشتراکِ %s پیدا نشد', plan_id, subscription_id)
plan = plan or subscription.plan
- duration_days = plan.duration_days if plan else 30
+ # duration_days از سفارش (عددِ لحظهی خرید)؛ None برای سفارشِ پیش از آن ستون.
+ if duration_days is None:
+ duration_days = plan.duration_days if plan else 30
now = timezone.now()
still_running = (
old_status == 'active' and subscription.expires_at is not None
diff --git a/support/serializers.py b/support/serializers.py
index 43cbe66..7c8a0ea 100644
--- a/support/serializers.py
+++ b/support/serializers.py
@@ -3,7 +3,8 @@ from rest_framework import serializers
class BugReportCreateRequestSerializer(serializers.Serializer):
title = serializers.CharField(max_length=150)
- description = serializers.CharField()
+ # سقف: پیشتر هر متن تا حدِ بدنهی درخواست (~۲٫۵ مگابایت) پذیرفته و ذخیره میشد.
+ description = serializers.CharField(max_length=5000)
screenshotUrl = serializers.URLField(required=False, allow_blank=True)
@@ -50,7 +51,7 @@ class TicketDetailResponseSerializer(serializers.Serializer):
class TicketMessageCreateRequestSerializer(serializers.Serializer):
- text = serializers.CharField()
+ text = serializers.CharField(max_length=5000)
class TicketMessageCreateResponseSerializer(serializers.Serializer):