list a user's own support tickets, Persian digits in completion notifications, rewrite .env.example to match the code
GET /api/support/tickets returns the caller's last 20 tickets with their support-reply count, on the read throttle so listing never spends the 30/hour create quota. Before it the app kept ticket ids only in one browser's localStorage, so a ticket and its answer vanished with the device. Completion notifications now use Persian digits and drop the 'with 0 stars ... 0 points' sentence when nothing was earned. .env.example: NUM_PROXIES=2 (ArvanCloud + nginx), KAVENEGAR_OTP_TEMPLATE, BAZAAR_PISHKHAN_API_SECRET, sandbox off by default; removed variables the code no longer reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
234
.env.example
234
.env.example
@@ -1,189 +1,117 @@
|
||||
# نمونهی فایلِ تنظیمات — هم برای ماشینِ خودتان، هم برای سرور.
|
||||
# نمونهی تنظیماتِ بکاندِ تدریسینو — برای لپتاپ و سرور.
|
||||
#
|
||||
# cp .env.example .env
|
||||
#
|
||||
# تقریباً هیچکدام اجباری نیستند. هر چیزی که از قبل قابلِ تصمیم بود در خودِ
|
||||
# کد پیشفرض شده: دامنهها، origin های مجاز، و کلیدِ امضا (اولین اجرا خودش
|
||||
# یک کلیدِ تصادفی میسازد و در .django_secret_key نگه میدارد). حتی با یک
|
||||
# فایلِ خالی هم سرویس بالا میآید.
|
||||
# هیچکدام برای بالا آمدنِ سرویس اجباری نیست: دامنهها، CORS، کلیدِ امضا و کش در
|
||||
# خودِ config/settings.py پیشفرضِ کارا دارند. مقدارهای این فایل با آن پیشفرضها
|
||||
# ادغام میشوند، جایگزینشان نمیشوند. شرحِ کامل: docs/operations.md.
|
||||
#
|
||||
# روی سرور، بعد از یکبار ساختنِ این فایل، هر آپدیت فقط:
|
||||
# git pull && ./deploy.sh
|
||||
# مقدارِ واقعیِ کلیدها فقط در .env (که در git نیست)، هرگز در این فایل یا در چت.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# پایه
|
||||
# -----------------------------------------------------------------------
|
||||
# DEBUG اینجا عمداً 0 است.
|
||||
#
|
||||
# این فایل هم روی لپتاپِ توسعهدهنده کپی میشود هم روی سرور، و حالتِ پیشفرض
|
||||
# باید آنی باشد که اگر کسی فراموشش کند، بیخطر بماند. یک بار همین فایل با
|
||||
# مقدارِ 1 روی سرور نشست و ماهها آنجا ماند.
|
||||
#
|
||||
# ⚠️ DEBUG=1 روی سرورِ عمومی دو ریسک دارد:
|
||||
# • صفحههای خطا کلِ تنظیمات و traceback را نشان میدهند
|
||||
# • کدِ ورودِ آزمایشیِ 0000 برای هر شمارهای پذیرفته میشود، یعنی هر
|
||||
# کسی میتواند بهجای هر کاربری وارد شود
|
||||
#
|
||||
# روی ماشینِ خودتان (و فقط آنجا) این را 1 کنید تا خطاها کامل دیده شوند.
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ۱) پایه
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
# روی لپتاپ 1، روی سرور همیشه 0.
|
||||
# DEBUG=1 روی سرور یعنی کدِ ورودِ 0000 برای هر شمارهای و صفحهی خطا با تنظیمات.
|
||||
DJANGO_DEBUG=0
|
||||
|
||||
# خالی بگذارید تا خودش ساخته و در .django_secret_key ذخیره شود.
|
||||
# خالی بماند: بارِ اول یک کلیدِ تصادفی ساخته و در .django_secret_key (0600) نگه
|
||||
# داشته میشود. هرگز مقدارِ django-insecure-… داخلِ settings.py را اینجا نگذارید.
|
||||
DJANGO_SECRET_KEY=
|
||||
|
||||
# بدونِ Redis سرویس بالا میآید ولی محدودیتِ نرخ بینِ workerها مشترک نیست؛
|
||||
# /api/health همین را هشدار میدهد. برای پروداکشنِ واقعی پرش کنید.
|
||||
# Redis برای محدودیتِ نرخِ مشترک بینِ workerها؛ خالی = کشِ حافظهی هر worker
|
||||
# (/api/health هشدار میدهد). مثال: redis://127.0.0.1:6379/1
|
||||
CACHE_URL=
|
||||
|
||||
# نامِ سرویسِ systemd تا deploy.sh خودش ریاستارت کند (فقط روی سرور).
|
||||
# TADRISINO_SERVICE=
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# آدرسِ سرویس — دو آدرس، بقیه از آنها ساخته میشوند
|
||||
# -----------------------------------------------------------------------
|
||||
# ALLOWED_HOSTS، CSRF_TRUSTED_ORIGINS، CORS_ALLOWED_ORIGINS، callbackِ زرینپال،
|
||||
# برگشت از پرداخت و redirectِ بازار همه از این دو ساخته میشوند. پیشفرضشان
|
||||
# دامنهی فعلی است و لازم نیست ست شوند. برای جابهجایی دامنه فقط همین دو را
|
||||
# باز کنید (ترتیبِ کار در docs/operations.md، «عوض کردنِ دامنه»):
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ۲) دامنهها — دو آدرس، بقیه از آنها ساخته میشوند
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ALLOWED_HOSTS، CSRF، CORS، callbackِ زرینپال، برگشت از پرداخت و redirectِ بازار
|
||||
# همه از این دو ساخته میشوند. پیشفرضشان همین دامنههای فعلی است.
|
||||
# جابهجاییِ دامنه: docs/operations.md، بخشِ «عوض کردنِ دامنه».
|
||||
#
|
||||
# PUBLIC_BASE_URL=https://mathapi.tadrisino2.ir
|
||||
# FRONTEND_URL=https://math.tadrisino2.ir
|
||||
#
|
||||
# این سه فقط برای دامنهی *اضافه* لازماند (مثلاً دامنهی قبلی در دورانِ جابهجایی)؛
|
||||
# مقدارشان به پیشفرضها اضافه میشود، جایگزینشان نمیشود:
|
||||
#
|
||||
# فقط برای دامنهی *اضافه* (مثلاً دامنهی قبلی در دورانِ جابهجایی):
|
||||
# DJANGO_ALLOWED_HOSTS=old.example.ir
|
||||
# CSRF_TRUSTED_ORIGINS=https://old.example.ir
|
||||
# CORS_ALLOWED_ORIGINS=https://old-app.example.ir
|
||||
|
||||
# اختیاری — بدونش OTP واقعی ارسال نمیشه (فقط خطای صریح ۵۰۳ برمیگرده،
|
||||
# طبق رفتارِ موجودِ accounts/services.py)
|
||||
KAVENEGAR_API_KEY=
|
||||
# تعدادِ پروکسیهای جلوی اپ. سرورِ فعلی: ArvanCloud → nginx، یعنی 2.
|
||||
# بیشتر از واقعیت = جعلِ IP و دور زدنِ محدودیتِ نرخ؛ کمتر = همهی مهمانها یک IP.
|
||||
NUM_PROXIES=2
|
||||
|
||||
# شمارههای تستِ تیم — «شماره:کد» با کامای جداکننده.
|
||||
#
|
||||
# بدونِ این، روی سروری که DEBUG=0 است و کاوهنگار ندارد، هیچ توسعهدهندهای
|
||||
# نمیتواند وارد API شود مگر اینکه کسی دستی از پنلِ ادمین یک OTPCode بسازد.
|
||||
# با این، هر عضوِ تیم شماره و کدِ خودش را دارد و به کسی وابسته نیست.
|
||||
#
|
||||
# برای این شمارهها پیامکی ارسال نمیشود و کدشان هیچوقت در پاسخِ API
|
||||
# برنمیگردد. هر شمارهی دیگری مسیرِ کاملاً عادیِ OTP را میرود.
|
||||
#
|
||||
# کد را تصادفی بگذارید (نه 1234)، شماره را دقیقاً همانطور که کلاینت
|
||||
# میفرستد بنویسید، و پیش از باز کردنِ سرویس روی کاربرانِ واقعی خالیاش کنید —
|
||||
# /api/health تا وقتی پر باشد هشدار میدهد.
|
||||
#
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ۳) پیامکِ کدِ ورود (کاوهنگار)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# بدونِ کلید، درخواستِ کد خطای صریحِ ۵۰۳ میدهد (نه موفقیتِ ساختگی).
|
||||
KAVENEGAR_API_KEY=
|
||||
# نامِ الگوی تأییدشده در پنلِ کاوهنگار (verify/lookup). روی سرورِ فعلی SMSVerify.
|
||||
# خطِ این حساب پیامکِ متنِ آزاد را لغو میکند، پس بدونِ الگو کدی نمیرسد.
|
||||
KAVENEGAR_OTP_TEMPLATE=
|
||||
# خطِ اختصاصی؛ خالی = خطِ پیشفرضِ حساب.
|
||||
KAVENEGAR_SENDER=
|
||||
|
||||
# شمارههای تستیِ تیم با کدِ ثابت، بدونِ پیامک: «شماره:کد» با کاما.
|
||||
# کد تصادفی باشد؛ پیش از کاربرِ واقعی خالی شود (/api/health تا آنوقت هشدار میدهد).
|
||||
# TEST_LOGIN_PHONES=09120000001:834512,09120000002:110947
|
||||
TEST_LOGIN_PHONES=
|
||||
|
||||
# رمزِ ادمینِ Grafana (کاربرِ admin) — فقط برای دسترسیِ محلی
|
||||
GRAFANA_ADMIN_PASSWORD=admin
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# RAG / AI tutor (اپ rag) — موقعِ تست پر کنید
|
||||
# -----------------------------------------------------------------------
|
||||
# بدونِ OPENAI_API_KEY و QDRANT_API_KEY، مسیرِ RAG غیرفعاله و
|
||||
# POST /api/ai/ask همون پاسخِ stubِ قبلی رو میده (سازگاریِ عقبرو).
|
||||
# فعلاً از GapGPT (پروکسیِ OpenAI برای ایران) استفاده میکنیم.
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ۴) دستیارِ هوش مصنوعی (RAG)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# بدونِ این دو کلید، POST /api/ai/ask پاسخِ آزمایشیِ ثابت (stub) میدهد.
|
||||
# GapGPT پروکسیِ OpenAI است و فقط از داخلِ ایران جواب میدهد.
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=https://api.gapgpt.app/v1
|
||||
# اگه بعداً خواستی مستقیم OpenAI واقعی رو تست کنی: کلیدِ واقعی رو این پایین
|
||||
# پارک کن، بعد فقط دو خطِ بالا رو باهاش عوض کن. هیچ منطقِ سوییچی در کد نیست
|
||||
# تا ساده و بیاشتباه بمونه.
|
||||
# OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
OPENAI_API_KEY_OPENAI_DIRECT=
|
||||
OPENAI_CHAT_MODEL=gpt-4.1-mini
|
||||
OPENAI_VISION_MODEL=gpt-4.1-mini
|
||||
# این رو تغییر نده؛ باید با مدلی که تیمِ دیگه باهاش collectionها رو ساخته یکی باشه.
|
||||
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
||||
|
||||
QDRANT_URL=https://qdrant.tadrisino2.ir:443
|
||||
QDRANT_API_KEY=
|
||||
|
||||
# نامِ collection = f"{subject} {grade}".lower() . collectionهای موجود روی
|
||||
# سرورِ تیمِ دیگه (بررسیشده): «علوم تجربی سوم» ، «مطالعات اجتماعی چهارم» ،
|
||||
# «مطالعات اجتماعی ششم» ، «ریاضی سوم» ، «ریاضی ششم» ، «ریاضی هفتم».
|
||||
# یعنی جزءِ گرید فقط «سوم/چهارم/ششم/هفتم» است (بدونِ «دبستان»).
|
||||
#
|
||||
# کلیدِ فیلترِ درس دستی تنظیم نمیشه — rag/services/known_collections.py خودش
|
||||
# تشخیص میده: ریاضی → `chapter` (و شمارهی فصل رو به رشتهی دقیق تبدیل میکنه)،
|
||||
# بقیه → `lesson_number`.
|
||||
RAG_COLLECTION_NAME_TEMPLATE={subject} {grade}
|
||||
RAG_RETRIEVAL_TOP_K=4
|
||||
# RAG_FORCE_MOCK=1 یعنی حتی با وجودِ کلیدها هم مسیرِ RAG خاموش بمونه.
|
||||
RAG_FORCE_MOCK=
|
||||
# مدلها؛ پیشفرضها در settings.py همیناند و سنجیده شدهاند (docs/architecture.md).
|
||||
# OPENAI_CHAT_MODEL=gpt-4.1-mini
|
||||
# OPENAI_VISION_MODEL=gpt-4.1-mini
|
||||
# مدلِ جدا فقط برای پایههای ۱۰ تا ۱۲؛ خالی = همان مدلِ بالا.
|
||||
# OPENAI_CHAT_MODEL_UPPER=
|
||||
# embedding را عوض نکنید مگر collectionهای Qdrant هم با همان مدل بازساخته شوند.
|
||||
# OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# زرینپال (اپ payment) — درگاهِ اصلیِ پرداختِ ما
|
||||
# -----------------------------------------------------------------------
|
||||
# بدونِ ZARINPAL_MERCHANT_ID، مسیرِ پرداخت fail-closed میمونه (خطای صریحِ
|
||||
# ۵۰۳ بهجایِ پرداختِ ساختگیِ موفق).
|
||||
# callbackِ زرینپال از PUBLIC_BASE_URL (بالاتر) ساخته میشود.
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ۵) پرداخت
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# زرینپال. بدونِ مرچنت، پرداخت ۵۰۳ میدهد. مرچنت باید برای دامنهی PUBLIC_BASE_URL
|
||||
# ثبت شده باشد، وگرنه زرینپال خطای -14 میدهد.
|
||||
ZARINPAL_MERCHANT_ID=
|
||||
# 1 = محیطِ sandbox زرینپال (برای تست بدونِ پرداختِ واقعی)
|
||||
ZARINPAL_SANDBOX=1
|
||||
# اختیاری — بعدِ تاییدِ پرداخت، کاربر به این URL (مثلاً یک deep link اپ) با
|
||||
# ?orderId=...&status=... ریدایرکت میشه. خالی = پاسخِ JSON ساده.
|
||||
ZARINPAL_REDIRECT_URL=
|
||||
# 1 = محیطِ آزمایشیِ زرینپال (بیپولِ واقعی). روی سرورِ فعلی خالی است: درگاهِ واقعی.
|
||||
ZARINPAL_SANDBOX=
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# تنظیماتِ مقیاسپذیری (همه دیفالتِ امن دارن)
|
||||
# -----------------------------------------------------------------------
|
||||
# persistent DB connection (ثانیه). با SQLite بیاثر؛ بعد از Postgres مهم.
|
||||
DB_CONN_MAX_AGE=60
|
||||
# کشِ کوتاهِ ردیفِ کاربر در auth تا هر request یک SELECT نزنه. 0 = خاموش
|
||||
# (پیشفرض). توصیهی پروداکشن با Redis: 30. با هر save/delete روی User باطل میشه.
|
||||
AUTH_USER_CACHE_TTL=0
|
||||
# سقفِ ارزانِ خواندن روی endpointهای پرترافیکِ GET (home/leaderboard/summary/...).
|
||||
READ_THROTTLE_RATE=120/min
|
||||
|
||||
# ⚠️ تعدادِ پروکسیهایی که جلوی اپ نشستهاند (nginx = ۱). محدودیتِ نرخِ
|
||||
# کاربرِ مهمان بر اساسِ IP است و IP از X-Forwarded-For خوانده میشود؛ اگر این
|
||||
# عدد از تعدادِ واقعیِ پروکسیها بیشتر باشد، کلاینت میتواند هدر را جعل کند و
|
||||
# محدودیت را دور بزند. کمتر باشد، همه پشتِ IP پروکسی یکی شمرده میشوند.
|
||||
NUM_PROXIES=1
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# بقیه — همه پیشفرضِ کارا دارند و معمولاً لازم نیست دست بخورند
|
||||
# -----------------------------------------------------------------------
|
||||
# پیامک: بدونِ SENDER هم کاوهنگار با خطِ پیشفرضِ حساب میفرستد، ولی اگر خطِ
|
||||
# اختصاصی دارید اینجا بگذارید. قالبِ متنِ پیامک هم قابلِ تغییر است ({code} لازم).
|
||||
KAVENEGAR_SENDER=
|
||||
# OTP_SMS_TEMPLATE=کد ورود شما به تدریسینو: {code}
|
||||
|
||||
# کلیدِ endpointهای داخلی (ingest در اپ rag، ساختِ سفارشِ سرور-به-سرور). با
|
||||
# DEBUG=1 مقدارِ توسعه دارد؛ با DEBUG=0 خالی است و آن مسیرها بسته میمانند
|
||||
# تا وقتی مقدارِ واقعی بگذارید.
|
||||
INTERNAL_SERVICE_KEY=
|
||||
|
||||
# با صفر قلب جلوی جواب دادن گرفته شود؟ (۱ = بله). فقط وقتی فرانت برای ۴۰۳ِ
|
||||
# no_hearts صفحهی «قلبها تمام شد» دارد.
|
||||
# HEARTS_ENFORCED=0
|
||||
|
||||
# پرداختِ درونبرنامهایِ کافهبازار. بدونِ این چهار تا، مسیرِ بازار
|
||||
# fail-closed میماند (payment/bazaar.py). REFRESH_TOKEN یکبار و دستی از
|
||||
# پنلِ devapi گرفته میشود.
|
||||
# کافهبازار (فقط خریدِ درونبرنامهایِ اپِ اندروید). روشِ ترجیحی توکنِ ثابتِ پیشخان
|
||||
# است؛ سه متغیرِ OAuth فقط وقتی آن نیست. بدونِ هیچکدام، مسیرِ بازار بسته میماند.
|
||||
BAZAAR_PISHKHAN_API_SECRET=
|
||||
BAZAAR_CLIENT_ID=
|
||||
BAZAAR_CLIENT_SECRET=
|
||||
BAZAAR_REFRESH_TOKEN=
|
||||
# BAZAAR_PACKAGE_NAME=ir.tadrisino.app
|
||||
# BAZAAR_API_BASE=https://pardakht.cafebazaar.ir/devapi/v2
|
||||
|
||||
# آدرسهای زرینپال از روی ZARINPAL_SANDBOX بالا انتخاب میشوند؛ فقط اگر
|
||||
# زرینپال دامنهاش را عوض کرد این سه را دستی بدهید.
|
||||
# ZARINPAL_API_BASE=
|
||||
# ZARINPAL_STARTPAY_BASE=
|
||||
# ZARINPAL_CALLBACK_URL=
|
||||
|
||||
# نوشتنِ لاگ در فایل علاوه بر stdout (داخلِ Docker ست میشود). خالی = فقط stdout.
|
||||
LOG_DIR=
|
||||
# سقفِ گیرندهی اکشنِ «ارسالِ پیامک به انتخابشدهها» در پنلِ ادمین. این اکشن
|
||||
# داخلِ همان request اجرا میشود، پس سقف جلوی تایماوت را میگیرد.
|
||||
ADMIN_SMS_MAX_RECIPIENTS=200
|
||||
# سقفِ نرخِ /api/health برای پایشِ بیرونی.
|
||||
HEALTH_THROTTLE_RATE=60/min
|
||||
# تایماوت و پارامترهای RAG. EMBEDDING_DIM باید با مدلِ embedding بالا بخواند.
|
||||
OPENAI_TIMEOUT_SECONDS=60
|
||||
QDRANT_TIMEOUT_SECONDS=30
|
||||
OPENAI_EMBEDDING_DIM=1536
|
||||
# کشِ پاسخِ دستیار (ثانیه) — سوالِ تکراری دوباره به مدل نمیرود.
|
||||
RAG_ANSWER_CACHE_TTL=1800
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ۶) اختیاری — پیشفرضها معمولاً کافیاند
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# کلیدِ مسیرهای داخلیِ سرور-به-سرور؛ خالی روی سرور = آن مسیرها بستهاند.
|
||||
# INTERNAL_SERVICE_KEY=
|
||||
# با صفر قلب جلوی جواب دادن گرفته شود؟ فقط وقتی فرانت صفحهی «قلب تمام شد» دارد.
|
||||
# HEARTS_ENFORCED=0
|
||||
# سقفهای کدِ ورود: هر شماره در روز، و کلِ سامانه در روز (ضدِ SMS pumping).
|
||||
# OTP_REQUEST_PHONE_DAILY_MAX=10
|
||||
# OTP_SMS_GLOBAL_DAILY_MAX=300
|
||||
# سقفِ خواندن روی اندپوینتهای پرترافیک، و سقفِ /api/health.
|
||||
# READ_THROTTLE_RATE=120/min
|
||||
# HEALTH_THROTTLE_RATE=60/min
|
||||
# کشِ پاسخِ دستیار (ثانیه)؛ سوالِ تکراری دوباره به مدل نمیرود.
|
||||
# RAG_ANSWER_CACHE_TTL=1800
|
||||
# 1 = دستیار حتی با کلیدها هم پاسخِ آزمایشی بدهد.
|
||||
# RAG_FORCE_MOCK=
|
||||
# لاگ در فایل علاوه بر stdout (فقط داخلِ Docker).
|
||||
# LOG_DIR=
|
||||
# رمزِ ادمینِ Grafana در docker compose (فقط محلی).
|
||||
# GRAFANA_ADMIN_PASSWORD=admin
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
| **وضعیت** | روی پروداکشن، در حالِ سرویسدهی |
|
||||
| **آدرسِ زنده** | `https://mathapi.tadrisino2.ir` · [سلامت](https://mathapi.tadrisino2.ir/api/health) |
|
||||
| **استک** | Python 3.11+ · Django 5.2 · DRF · SQLite · Qdrant (RAG) |
|
||||
| **API** | ۶۰ اندپوینت · مستنداتِ زنده در `/api/docs/` |
|
||||
| **تست** | ۵۳۰ تست، همه سبز (`python manage.py test`) |
|
||||
| **محتوا** | ۸ پایه · ۵۸ فصل · ۲۶۳ نود · ۲٬۶۱۷ درس · ۲٬۴۶۳ سوال |
|
||||
| **API** | ۶۱ اندپوینت · مستنداتِ زنده در `/api/docs/` |
|
||||
| **تست** | ۶۸۵ تست، همه سبز (`python manage.py test`) |
|
||||
| **محتوا** | ۸ پایه · ۵۸ فصل · ۲۶۳ نود · ۲٬۶۱۷ درس · ۲٬۵۴۵ سوال |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -189,6 +189,19 @@ def _record_streak(user_id):
|
||||
logger.exception('streak: ثبتِ فعالیتِ کاربر %s شکست خورد', user_id)
|
||||
|
||||
|
||||
_FA_DIGITS = str.maketrans('0123456789', '۰۱۲۳۴۵۶۷۸۹')
|
||||
|
||||
|
||||
def _completion_body(title, stars, xp):
|
||||
"""
|
||||
متنِ اعلانِ «تمام کردی». عددها فارسی، چون اپ متنِ اعلان را همانطور که
|
||||
هست نشان میدهد؛ و بیامتیاز جملهی «با ۰ ستاره … ۰ امتیاز» ساخته نمیشود.
|
||||
"""
|
||||
if not xp:
|
||||
return f'«{title}» را تمام کردی.'
|
||||
return f'«{title}» را با {stars} ستاره تمام کردی و {xp} امتیاز گرفتی.'.translate(_FA_DIGITS)
|
||||
|
||||
|
||||
def _node_status(lesson_ids, completed_ids, is_first_unlocked_candidate, quiz_completed=False):
|
||||
# آزمونِ مبحث تمام شده → مبحث تمام است، مستقل از تکتکِ درسها.
|
||||
if quiz_completed:
|
||||
@@ -639,7 +652,7 @@ class LessonCompleteView(APIView):
|
||||
user_id=user_id,
|
||||
type='lesson_completed',
|
||||
title='درس را کامل کردی',
|
||||
body=f'«{lesson.title}» را با {stars} ستاره تمام کردی و {xp_earned} امتیاز گرفتی.',
|
||||
body=_completion_body(lesson.title, stars, xp_earned),
|
||||
related_id=lesson.id,
|
||||
)
|
||||
except IntegrityError:
|
||||
@@ -970,10 +983,7 @@ class NodeQuizCompleteView(APIView):
|
||||
user_id=user_id,
|
||||
type='node_completed',
|
||||
title='جزیره را تمام کردی',
|
||||
body=(
|
||||
f'«{node.title}» را با {stars} ستاره تمام کردی و '
|
||||
f'{completion.xp_earned} امتیاز گرفتی.'
|
||||
),
|
||||
body=_completion_body(node.title, stars, completion.xp_earned),
|
||||
related_id=node.id,
|
||||
)
|
||||
except IntegrityError:
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
| فایل | محتوا |
|
||||
|---|---|
|
||||
| `curriculum.json` | ۸ پایه، ۵۸ فصل، ۲۶۳ نود، ۲٬۶۱۷ درس |
|
||||
| `questions.json` | ۲٬۴۶۳ سوالِ چندگزینهای |
|
||||
| `questions.json` | ۲٬۵۴۵ سوالِ چندگزینهای |
|
||||
|
||||
`deploy.sh` در هر انتشار `seed_content` را اجرا میکند، پس یک سرورِ تازه خودش
|
||||
را با یک فرمان پر میکند — **بدونِ Qdrant، بدونِ اعتبارِ API، بدونِ نشستِ SSH.**
|
||||
|
||||
@@ -23,6 +23,19 @@ class TicketCreateResponseSerializer(serializers.Serializer):
|
||||
status = serializers.CharField()
|
||||
|
||||
|
||||
class TicketSummarySerializer(serializers.Serializer):
|
||||
ticketId = serializers.CharField()
|
||||
subject = serializers.CharField()
|
||||
category = serializers.CharField()
|
||||
status = serializers.CharField()
|
||||
createdAt = serializers.DateTimeField()
|
||||
supportReplies = serializers.IntegerField()
|
||||
|
||||
|
||||
class TicketListResponseSerializer(serializers.Serializer):
|
||||
tickets = TicketSummarySerializer(many=True)
|
||||
|
||||
|
||||
class TicketMessageItemSerializer(serializers.Serializer):
|
||||
id = serializers.CharField()
|
||||
sender = serializers.CharField()
|
||||
|
||||
@@ -354,3 +354,41 @@ class SupportCreateGuestThrottleBypassTests(TestCase):
|
||||
|
||||
self.assertEqual(BugReport.objects.count(), bug_count_before)
|
||||
self.assertEqual(Ticket.objects.count(), ticket_count_before)
|
||||
|
||||
|
||||
class TicketListTests(TestCase):
|
||||
"""GET /api/support/tickets: فقط تیکتهای خودِ کاربر، تازهترین اول، با شمارِ پاسخِ پشتیبانی."""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-ticket-list-1', is_guest=True, name='')
|
||||
self.other = User.objects.create(device_id='device-ticket-list-2', is_guest=True, name='')
|
||||
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(self.user.id)}'}
|
||||
|
||||
def test_lists_only_own_tickets_with_reply_count(self):
|
||||
old = Ticket.objects.create(user_id=self.user.id, subject='قدیمی', category='learning')
|
||||
TicketMessage.objects.create(ticket=old, sender='user', text='سلام')
|
||||
TicketMessage.objects.create(ticket=old, sender='support', text='جواب')
|
||||
new = Ticket.objects.create(user_id=self.user.id, subject='تازه', category='payment')
|
||||
Ticket.objects.create(user_id=self.other.id, subject='مالِ دیگری', category='other')
|
||||
|
||||
response = self.client.get('/api/support/tickets', **self.auth)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
tickets = response.json()['tickets']
|
||||
self.assertEqual([t['ticketId'] for t in tickets], [str(new.pk), str(old.pk)])
|
||||
self.assertEqual(tickets[1]['supportReplies'], 1)
|
||||
self.assertEqual(tickets[0]['supportReplies'], 0)
|
||||
|
||||
def test_requires_authentication(self):
|
||||
self.assertIn(self.client.get('/api/support/tickets').status_code, (401, 403))
|
||||
|
||||
def test_listing_does_not_spend_the_create_quota(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}):
|
||||
for _ in range(3):
|
||||
self.assertEqual(self.client.get('/api/support/tickets', **self.auth).status_code, 200)
|
||||
response = self.client.post(
|
||||
'/api/support/tickets', {'subject': 'س', 'category': 'other'}, format='json', **self.auth,
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.db.models import Count, Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
@@ -12,6 +13,7 @@ from .serializers import (
|
||||
TicketCreateRequestSerializer,
|
||||
TicketCreateResponseSerializer,
|
||||
TicketDetailResponseSerializer,
|
||||
TicketListResponseSerializer,
|
||||
TicketMessageCreateRequestSerializer,
|
||||
TicketMessageCreateResponseSerializer,
|
||||
)
|
||||
@@ -88,7 +90,15 @@ class BugReportCreateView(APIView):
|
||||
|
||||
|
||||
class TicketCreateView(APIView):
|
||||
"""POST /api/support/tickets — ایجاد تیکت"""
|
||||
"""
|
||||
GET /api/support/tickets — تیکتهای همین کاربر (۲۰ تای آخر)
|
||||
POST /api/support/tickets — ایجاد تیکت
|
||||
|
||||
GET تا ۲۰۲۶-۰۹-۲۹ نبود و اپ شناسهی تیکتها را فقط در حافظهی همان مرورگر
|
||||
نگه میداشت؛ با عوض شدنِ دستگاه، تیکت و پاسخِ پشتیبانی از دید پنهان میشد.
|
||||
"""
|
||||
|
||||
LIST_LIMIT = 20
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
# مثلِ BugReportCreateView: هیچ idempotency ای نیست، پس این endpoint هم
|
||||
@@ -99,6 +109,36 @@ class TicketCreateView(APIView):
|
||||
throttle_scope = 'support_create'
|
||||
throttle_classes = [GuestAwareSupportThrottle]
|
||||
|
||||
def get_throttles(self):
|
||||
# GET سهمِ خواندن را میخورد، نه سقفِ ۳۰ در ساعتِ ساختِ رکورد.
|
||||
if self.request.method == 'GET':
|
||||
self.throttle_scope = 'read'
|
||||
return super().get_throttles()
|
||||
|
||||
@extend_schema(
|
||||
operation_id='support_tickets_list',
|
||||
responses=TicketListResponseSerializer,
|
||||
tags=['Support (support)'],
|
||||
)
|
||||
def get(self, request):
|
||||
tickets = (
|
||||
Ticket.objects.filter(user_id=request.user.id)
|
||||
.annotate(support_replies=Count('messages', filter=Q(messages__sender='support')))
|
||||
.order_by('-created_at')[: self.LIST_LIMIT]
|
||||
)
|
||||
data = {'tickets': [
|
||||
{
|
||||
'ticketId': str(t.pk),
|
||||
'subject': t.subject,
|
||||
'category': t.category,
|
||||
'status': t.status,
|
||||
'createdAt': t.created_at,
|
||||
'supportReplies': t.support_replies,
|
||||
}
|
||||
for t in tickets
|
||||
]}
|
||||
return Response(TicketListResponseSerializer(data).data)
|
||||
|
||||
@extend_schema(
|
||||
request=TicketCreateRequestSerializer,
|
||||
responses=TicketCreateResponseSerializer,
|
||||
|
||||
Reference in New Issue
Block a user