diff --git a/README.md b/README.md index 189e65d..fcdfcd2 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ | **وضعیت** | روی پروداکشن، در حالِ سرویس‌دهی | | **آدرسِ زنده** | `https://api.math.tadrisino2.ir` · [سلامت](https://api.math.tadrisino2.ir/api/health) | | **استک** | Python 3.11+ · Django 5.2 · DRF · SQLite · Qdrant (RAG) | -| **API** | ۵۵ اندپوینت · مستنداتِ زنده در `/api/docs/` | +| **API** | ۵۶ اندپوینت · مستنداتِ زنده در `/api/docs/` | | **تست** | ۴۸۱ تست، همه سبز (`python manage.py test`) | | **محتوا** | ۸ پایه · ۵۸ فصل · ۲۶۳ نود · ۲٬۶۱۷ درس · ۲٬۴۶۳ سوال | diff --git a/accounts/views.py b/accounts/views.py index ebef00a..d9b67b0 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -37,7 +37,7 @@ logger = logging.getLogger(__name__) class GuestAwareAccountThrottle(ScopedRateThrottle): """ - ⚠️ اضافه شده: همون الگویِ GuestAwareContentThrottle (content/views.py)، + همان الگویِ GuestAwareContentThrottle (content/views.py)، GuestAwareAIThrottle (ai_tutor/views.py) و بقیه؛ چون GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک ScopedRateThrottleِ سادهٔ per-user توسطِ چرخشِ حساب مهمان دورزدنی @@ -100,8 +100,11 @@ class GuestSessionView(APIView): class RequestOTPView(APIView): """ - POST /api/auth/otp/request — درخواست کد OTP (کمکی، برای تست از طریق Swagger) - در محیط توسعه، کد ساخته‌شده در پاسخ برگردونده می‌شه تا نیازی به SMS واقعی نباشه. + POST /api/auth/otp/request — درخواستِ کدِ ورود. + + کد فقط با پیامک تحویل داده می‌شود. دو استثنا: در حالتِ DEBUG کد در فیلدِ + devOtp برمی‌گردد تا تستِ محلی به پیامکِ واقعی نیاز نداشته باشد، و برای + شماره‌های TEST_LOGIN_PHONES کدِ ثابتِ از پیش تعریف‌شده معتبر است. """ throttle_scope = 'otp_request' @@ -117,13 +120,13 @@ class RequestOTPView(APIView): serializer.is_valid(raise_exception=True) phone = serializer.validated_data['phone'] - # شماره‌ی تستِ تیم (TEST_LOGIN_PHONES): کدش ثابت و از قبل معلومه، پس نه - # ردیفِ OTP تازه‌ای لازمه نه پیامکی. cooldown و سقفِ ساعتی هم رد می‌شن — - # اون‌ها برای محافظت از گیرنده‌ی واقعیِ پیامک‌اند و اینجا پیامکی در کار - # نیست؛ نگه‌داشتنشون فقط توسعه‌دهنده رو ۲ دقیقه معطل می‌کرد. - # ⚠️ کد عمداً در پاسخ برنمی‌گرده (برخلافِ devOtp در حالتِ DEBUG): این - # مسیر روی سرورِ عمومی هم فعاله، و برگردوندنش یعنی هر کسی که شماره رو - # بداند می‌تواند وارد شود. سقفِ تلاشِ ورود در LoginView سرِ جاشه. + # شماره‌ی تستِ تیم: کد ثابت و از قبل معلوم است، پس نه ردیفِ OTP تازه‌ای + # لازم است نه پیامکی. cooldown و سقفِ ساعتی هم رد می‌شوند چون هر دو از + # گیرنده‌ی واقعیِ پیامک محافظت می‌کنند و اینجا پیامکی در کار نیست. + # + # ⚠️ کد عمداً در پاسخ برنمی‌گردد، برخلافِ devOtp در حالتِ DEBUG: این + # مسیر روی سرورِ عمومی هم فعال است، پس برگرداندنش یعنی هر کسی که شماره + # را بداند می‌تواند وارد شود. if phone in settings.TEST_LOGIN_PHONES: logger.info('OTP request for configured test phone: phone_suffix=%s', phone[-4:]) data = { @@ -377,10 +380,9 @@ class SetUserGradeView(APIView): """POST /api/user/grade — ثبت پایه‌ی کاربر (نیاز به احراز هویت)""" permission_classes = [IsAuthenticated] - # ⚠️ اضافه شده: این endpoint قبلاً هیچ throttleای نداشت؛ یک کاربرِ - # (از جمله مهمان) احرازهویت‌شده می‌تونست تعدادِ نامحدودی درخواستِ - # نوشتنِ دیتابیس بفرسته. طبقِ همون الگویِ سایرِ اپ‌ها، یک - # throttle_scope مجزا با GuestAwareAccountThrottle اضافه شد. + # بدونِ throttle، یک کاربرِ احرازهویت‌شده (از جمله مهمان) می‌تواند + # نامحدود درخواستِ نوشتن روی دیتابیس بفرستد. کلیدِ شمارش برای مهمان‌ها + # باید IP باشد نه pk — دلیلش در GuestAwareAccountThrottle بالا. throttle_scope = 'user_profile_write' throttle_classes = [GuestAwareAccountThrottle] diff --git a/affiliate/views.py b/affiliate/views.py index eb7ee47..b6ea267 100644 --- a/affiliate/views.py +++ b/affiliate/views.py @@ -17,7 +17,7 @@ from .serializers import ( class GuestAwareAffiliateThrottle(ScopedRateThrottle): """ - ⚠️ اضافه شده: همون الگویِ GuestAwareDiscountThrottle/GuestAwareFamilyThrottle + همان الگویِ GuestAwareDiscountThrottle/GuestAwareFamilyThrottle و بقیه؛ چرخشِ حسابِ مهمان (deviceId جدید → pk جدید) یک ScopedRateThrottleِ سادهٔ per-user رو دور می‌زد. اینجا حساس‌تره چون referralCode یک شناسه‌ی حدس‌زدنی‌ست (404 برای کدِ نامعتبر = oracle) و یک حدسِ درست diff --git a/ai_tutor/views.py b/ai_tutor/views.py index 8a37e63..270f1ff 100644 --- a/ai_tutor/views.py +++ b/ai_tutor/views.py @@ -58,7 +58,7 @@ def _annotation(retrieval_meta: dict, generation_meta: dict, *, from_cache: bool class GuestAwareAIThrottle(ScopedRateThrottle): """ - ⚠️ اضافه شده: ScopedRateThrottle استاندارد برای کاربرِ احرازهویت‌شده + ScopedRateThrottle استاندارد برای کاربرِ احرازهویت‌شده (مهمان یا نه) از request.user.pk به‌عنوانِ کلیدِ throttle استفاده می‌کنه. چون GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ حساب‌های diff --git a/config/settings.py b/config/settings.py index 45b3a87..a9648fb 100644 --- a/config/settings.py +++ b/config/settings.py @@ -180,30 +180,28 @@ INSTALLED_APPS = [ 'drf_spectacular', 'corsheaders', - # Tadrisino apps (فاز اول - ۴ از ۱۱ اپ) + # اپ‌های خودمان. هر کدام یک مسئولیت و مدل‌های خودش را دارد؛ ارجاعِ بینِ + # اپ‌ها با user_id/object_id انجام می‌شود نه ForeignKey، تا هر کدام بعداً + # قابلِ جدا شدن بماند (docs/architecture.md). + + # مسیرِ اصلیِ کاربر: ورود، محتوا، پیشرفت، دستیار 'accounts', 'content', 'progress', 'ai_tutor', - # Tadrisino apps (فاز دوم - بخش ۱ از ۳: subscription, payment, discount) + # کسبِ درآمد 'subscription', 'payment', 'discount', - # Tadrisino apps (فاز دوم - بخش ۲: notifications) + # ارتباط با کاربر و پشتیبانی 'notifications', - - # Tadrisino apps (فاز دوم - بخش ۳: affiliate) 'affiliate', - - # Tadrisino apps (فاز دوم - بخش ۴: support) 'support', - - # Tadrisino apps (فاز دوم - بخش ۵: family) 'family', - # Tadrisino apps (سیستم پرداخت بازار + آفر/تخفیف + لاگ عمومی تغییر Status) + # لاگِ عمومیِ تغییرِ وضعیت، مستقل از بقیه‌ی اپ‌ها 'statuslog', # Tadrisino apps (مدلِ مینیمالِ ذخیره‌سازیِ کمپین‌های پیامی، بدونِ منطقِ ارسال) @@ -429,12 +427,100 @@ REST_FRAMEWORK = { SPECTACULAR_SETTINGS = { 'TITLE': 'Tadrisino API', + # این متن قراردادِ بینِ ما و تیمِ فرانت است و تنها جایی‌ست که هر دو طرف + # می‌بینند. پس به‌جای فهرستِ امکانات، به سه سوالِ اولِ هر مصرف‌کننده جواب + # می‌دهد: چطور توکن بگیرم، خطا چه شکلی برمی‌گردد، و کِی محدود می‌شوم. + # Swagger UI و Redoc هر دو Markdown را رندر می‌کنند. 'DESCRIPTION': ( - 'بک‌اند اپلیکیشن آموزشی Tadrisino — احراز هویت با OTP، محتوای درسی ' - 'و پیشرفت کاربر، دستیار هوش مصنوعی، اشتراک و پرداخت، تخفیف، ' - 'اعلان‌ها، همکاری در فروش، پشتیبانی و حساب خانواده.' + 'بک‌اندِ اپلیکیشنِ آموزشیِ تدریسینو: برنامه‌ی درسی، آزمون، پیشرفتِ ' + 'کاربر، دستیارِ هوشِ مصنوعی، اشتراک و پرداخت.\n' + '\n' + '## احرازِ هویت\n' + '\n' + 'همه‌ی درخواست‌های احرازشده با یک توکنِ Bearer انجام می‌شوند:\n' + '\n' + '```\n' + 'Authorization: Bearer \n' + '```\n' + '\n' + 'دو راه برای گرفتنِ توکن هست:\n' + '\n' + '| مسیر | کاربرد |\n' + '|---|---|\n' + '| `POST /api/auth/session` | ورودِ مهمان با `deviceId` — بدونِ شماره، بلافاصله |\n' + '| `POST /api/auth/otp/request` + `POST /api/auth/login` | ورودِ کامل با شماره‌ی موبایل |\n' + '\n' + 'کاربرِ مهمان می‌تواند بعداً شماره‌اش را وصل کند و همان حساب را نگه ' + 'دارد. توکنِ دسترسی یک ساعت اعتبار دارد؛ با `POST /api/auth/refresh` ' + 'تازه‌اش کنید.\n' + '\n' + '**در همین صفحه:** روی **Authorize** بزنید و *فقط خودِ توکن* را وارد ' + 'کنید — کلمه‌ی `Bearer` را Swagger خودش اضافه می‌کند.\n' + '\n' + 'اندپوینتی که توکن نگیرد، درخواست را ناشناس می‌بیند؛ اینکه ورود لازم ' + 'باشد یا نه، به خودِ آن اندپوینت بستگی دارد.\n' + '\n' + '## خطاها\n' + '\n' + 'هر خطا — حتی خطای پیش‌بینی‌نشده — یک پاسخِ JSON است، نه صفحه‌ی HTML. ' + 'پیامِ `detail` فارسی و قابلِ نمایش به کاربرِ نهایی است.\n' + '\n' + '```json\n' + '{"detail": "کد OTP نامعتبر یا منقضی‌شده است."}\n' + '```\n' + '\n' + '| کد | معنی |\n' + '|---|---|\n' + '| `400` | ورودی نامعتبر است |\n' + '| `401` | توکن نیست، نامعتبر است یا منقضی شده |\n' + '| `403` | توکن معتبر است ولی اجازه‌ی این کار را ندارد |\n' + '| `404` | چیزی با این شناسه وجود ندارد |\n' + '| `429` | از محدودیتِ نرخ گذشتید — بخشِ بعد |\n' + '| `503` | سرویسِ بیرونی (پیامک، پرداخت) در دسترس نیست |\n' + '\n' + '`503` عمدی‌ست: وقتی سرویسِ بیرونی تنظیم یا در دسترس نباشد، پاسخِ ' + 'صریح می‌گیرید، نه موفقیتِ ساختگی.\n' + '\n' + '## محدودیتِ نرخ\n' + '\n' + 'اندپوینت‌های حساس (ورود، درخواستِ کد، پرسش از دستیار) سقفِ مستقل ' + 'دارند. عبور از سقف یعنی `429`، و هر جا که معنی داشته باشد هدرِ ' + '`Retry-After` هم می‌آید. کاربرِ مهمان بر اساسِ IP شمرده می‌شود.\n' + '\n' + 'مسیرِ ورود سقفِ جداگانه‌ای بر اساسِ *شماره* هم دارد، مستقل از IP، تا ' + 'پخش کردنِ تلاش بینِ چند IP فایده‌ای نداشته باشد.\n' + '\n' + '## قراردادها\n' + '\n' + '- نامِ فیلدها `camelCase` است (`deviceId`, `refreshToken`, `nodeId`).\n' + '- زمان‌ها ISO 8601 با منطقه‌ی زمانی‌اند.\n' + '- متنِ رو به کاربر فارسی است؛ شناسه‌ها و نامِ فیلدها انگلیسی.\n' + '- این اسکیما **مرجعِ قرارداد** است. کلاینت را از روی حافظه ننویسید — ' + 'از `/api/schema/` تولیدش کنید.\n' ), 'VERSION': '1.0.0', + 'CONTACT': {'name': 'تیمِ بک‌اندِ تدریسینو'}, + 'EXTERNAL_DOCS': { + 'description': 'راهنمای اتصالِ فرانت‌اند', + 'url': 'https://gitea.tadrisino2.ir/Tadrisino/MathApp/src/branch/main/docs/frontend/README.md', + }, + # ترتیبِ تگ‌ها در Swagger به همین ترتیب است، نه الفبایی — یعنی مسیرِ + # واقعیِ یک کاربر: اول وارد شو، بعد محتوا بگیر، بعد پیشرفت و بقیه. + 'TAGS': [ + {'name': 'Identity & Profile (accounts)', 'description': 'ورود، session، پروفایل'}, + {'name': 'Curriculum (content)', 'description': 'پایه، فصل، نود، درس، سوال، آزمون'}, + {'name': 'Progress & Gamification (progress)', 'description': 'آمار، streak، لیدربرد، شخصیت‌ها'}, + {'name': 'AI Tutor (ai_tutor)', 'description': 'پرسش از دستیار و بازخورد روی پاسخ'}, + {'name': 'RAG (rag)', 'description': 'بازیابی از محتوای کتاب — داخلی'}, + {'name': 'Subscription (subscription)', 'description': 'پلن‌ها و اشتراکِ کاربر'}, + {'name': 'Payment (payment)', 'description': 'سفارش و تراکنش (زرین‌پال و کافه‌بازار)'}, + {'name': 'Discount (discount)', 'description': 'کدِ تخفیف و آفرها'}, + {'name': 'Notifications (notifications)', 'description': 'اعلان‌های کاربر'}, + {'name': 'Family (family)', 'description': 'کنترلِ والدین و گزارشِ هفتگی'}, + {'name': 'Support (support)', 'description': 'تیکت و گزارشِ اشکال'}, + {'name': 'Affiliate (affiliate)', 'description': 'برنامه‌ی معرفی و پورسانت'}, + {'name': 'System', 'description': 'سلامتِ سرویس'}, + ], 'SERVE_INCLUDE_SCHEMA': False, 'COMPONENT_SPLIT_REQUEST': True, 'SWAGGER_UI_SETTINGS': { @@ -483,35 +569,14 @@ OTP_REQUEST_PHONE_COOLDOWN_SECONDS = 120 # فقط وقتی DEBUG=True مقدار دارد؛ در پروداکشن None است. DEV_MASTER_OTP = '0000' if DEBUG else None -# ----------------------------------------------------------------------- -# شماره‌های تستِ ورود (TEST_LOGIN_PHONES) -# ----------------------------------------------------------------------- -# مشکلی که این حل می‌کند: با DEBUG=0 و بدونِ کاوه‌نگار، هیچ توسعه‌دهنده‌ای -# نمی‌تواند وارد API شود مگر اینکه کسی با دسترسیِ ادمین دستی یک ردیفِ OTPCode -# بسازد. یعنی ورودِ هر عضوِ تیم به یک آدمِ خاص وابسته بود. +# شماره‌های تستِ ورود: «شماره:کد» با کامای جداکننده. برای این شماره‌ها پیامک +# ارسال نمی‌شود و همان کدِ ثابت معتبر است — تا ورودِ تیمِ توسعه به تنظیم بودنِ +# کاوه‌نگار یا به یک آدمِ خاص وابسته نباشد. برخلافِ DEV_MASTER_OTP «هر +# شماره‌ای» نیست و به DEBUG گره نخورده. روالِ استفاده در docs/operations.md. # -# این همان الگویی است که اپ‌های واقعی برای همین کار دارند (Firebase Auth -# «fictional phone numbers»، Apple «demo account» برای بررسیِ App Store): -# چند شماره‌ی مشخص با کدِ ثابت، که پیامک برایشان ارسال نمی‌شود. برخلافِ -# DEV_MASTER_OTP این «هر شماره‌ای» نیست — فقط همین چند شماره، و هر کدام -# کدِ خودش را دارد. -# -# TEST_LOGIN_PHONES=09120000001:834512,09120000002:110947 -# -# قواعدی که عمداً رعایت می‌شوند: -# • کد هیچ‌وقت در پاسخِ API برنمی‌گردد — هر کس شماره را بداند هم نمی‌تواند -# کد را از سرور بیرون بکشد. -# • سقفِ تلاشِ ورودِ هر شماره (OTP_LOGIN_PHONE_MAX_ATTEMPTS) روی این‌ها هم -# اعمال می‌شود، پس حدس‌زدنِ کد با brute-force بسته است. -# • ورودیِ خراب باعثِ بالا نیامدنِ سرویس می‌شود، نه نادیده گرفته شدنِ بی‌صدا. -# • /api/health وجودشان را هشدار می‌دهد تا روی سرور فراموش نشوند. -# -# ⚠️ شماره باید *دقیقاً* همان رشته‌ای باشد که کلاینت می‌فرستد. این کدبیس شماره +# ⚠️ شماره باید *دقیقاً* همان رشته‌ای باشد که کلاینت می‌فرستد: این کدبیس شماره # را normalize نمی‌کند (accounts/serializers.py فقط CharField است)، پس -# «09121111111» و «+989121111111» دو کاربرِ جدا هستند و تطبیق رشته‌ای است. -# -# ⚠️ این حساب‌ها حسابِ تست‌اند: کدِ واقعی و تصادفی بگذارید (نه 1234)، و هیچ -# داده‌ی واقعیِ کاربری رویشان نگذارید. +# «09121111111» و «+989121111111» دو کاربرِ جدا هستند. def _parse_test_login_phones(raw: str) -> dict[str, str]: pairs = {} for item in raw.split(','): diff --git a/config/urls.py b/config/urls.py index 6814d8e..5d78161 100644 --- a/config/urls.py +++ b/config/urls.py @@ -19,6 +19,11 @@ from drf_spectacular.views import ( SpectacularSwaggerView, ) + +@extend_schema(exclude=True) +class HealthViewSlashAlias(HealthView): + """نسخه‌ی اسلش‌دارِ `/api/health`، فقط برای پایشِ بیرونی — بیرون از اسکیما.""" + logger = logging.getLogger(__name__) @@ -52,37 +57,32 @@ urlpatterns = [ # هر `git pull` بعدی روی همین فایل گیر می‌کرد. یک مسیرِ اضافه ارزانش # است در برابرِ یک فایلِ دست‌کاری‌شده روی پروداکشن. path('api/health', HealthView.as_view(), name='health'), - # همان ویو، فقط برای پایشی که با اسلش صدا می‌زند. از اسکیما بیرون است تا دو - # مسیرِ یکسان operationIdهای health_retrieve و health_retrieve_2 نسازند. - path('api/health/', extend_schema(exclude=True)(HealthView).as_view(), - name='health-slash'), + # همان ویو، فقط برای پایشی که با اسلش صدا می‌زند. از اسکیما بیرون است تا + # دو مسیرِ یکسان operationIdهای health_retrieve و health_retrieve_2 نسازند. + # + # ⚠️ زیرکلاس لازم است، نه exclude روی خودِ HealthView. extend_schema روی + # یک کلاس همان کلاس را تغییر می‌دهد، و چون هر دو مسیر به یک کلاس اشاره + # می‌کردند، `/api/health` هم با خودش از اسکیما بیرون می‌افتاد — یعنی + # اندپوینتِ سلامت اصلاً در Swagger دیده نمی‌شد. + path('api/health/', HealthViewSlashAlias.as_view(), name='health-slash'), # OpenAPI schema + Swagger UI + Redoc path('api/schema/', SpectacularAPIView.as_view(), name='schema'), path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), - # اپ‌های فاز ۱ + # همه زیرِ /api/ جمع می‌شوند؛ هر اپ مسیرهای خودش را نگه می‌دارد، پس + # ترتیبِ این فهرست اهمیتی ندارد و تداخلی بینشان نیست. path('api/', include('accounts.urls')), path('api/', include('content.urls')), path('api/', include('progress.urls')), path('api/', include('ai_tutor.urls')), path('api/', include('rag.urls')), - - # اپ‌های فاز ۲ / بخش ۱ (subscription, payment, discount) path('api/', include('subscription.urls')), path('api/', include('payment.urls')), path('api/', include('discount.urls')), - - # اپ‌های فاز ۲ / بخش ۲ (notifications) path('api/', include('notifications.urls')), - - # اپ‌های فاز ۲ / بخش ۳ (affiliate) path('api/', include('affiliate.urls')), - - # اپ‌های فاز ۲ / بخش ۴ (support) path('api/', include('support.urls')), - - # اپ‌های فاز ۲ / بخش ۵ (family) path('api/', include('family.urls')), ] diff --git a/content/views.py b/content/views.py index 2082453..edef678 100644 --- a/content/views.py +++ b/content/views.py @@ -626,7 +626,7 @@ class QuestionReportView(APIView): @extend_schema( request=QuestionReportRequestSerializer, responses=QuestionReportResponseSerializer, - tags=['محتوا (content)'], + tags=['Curriculum (content)'], ) def post(self, request, question_id: int): question = Question.objects.filter(pk=question_id).first() diff --git a/discount/views.py b/discount/views.py index d8fec5a..6f5d507 100644 --- a/discount/views.py +++ b/discount/views.py @@ -22,7 +22,7 @@ from .services import redeem_offer_code class GuestAwareDiscountThrottle(ScopedRateThrottle): """ - ⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py) و + همان مشکلِ GuestAwareAIThrottle (ai_tutor/views.py) و GuestAwareFamilyThrottle (family/views.py) اینجا هم هست. ScopedRateThrottle استاندارد برای درخواستِ احرازهویت‌شده از request.user.pk به‌عنوانِ کلیدِ throttle استفاده می‌کنه؛ چون diff --git a/family/views.py b/family/views.py index f05b644..d954ecd 100644 --- a/family/views.py +++ b/family/views.py @@ -33,7 +33,7 @@ def _week_start(dt): class GuestAwareFamilyThrottle(ScopedRateThrottle): """ - ⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py) اینجا + همان مشکلِ GuestAwareAIThrottle (ai_tutor/views.py) اینجا هم هست. ScopedRateThrottle استاندارد برای درخواستِ احرازهویت‌شده از request.user.pk به‌عنوانِ کلیدِ throttle استفاده می‌کنه؛ چون GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) diff --git a/support/views.py b/support/views.py index f0fafb5..d40e9ca 100644 --- a/support/views.py +++ b/support/views.py @@ -19,7 +19,7 @@ from .serializers import ( class GuestAwareSupportThrottle(ScopedRateThrottle): """ - ⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py)، + همان مشکلِ GuestAwareAIThrottle (ai_tutor/views.py)، GuestAwareFamilyThrottle (family/views.py) و GuestAwareDiscountThrottle/ GuestAwarePurchaseThrottle (discount/views.py، subscription/views.py) اینجا هم هست. ScopedRateThrottle استاندارد برای درخواستِ