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:
2026-09-29 21:18:12 +03:30
parent 8321dc22c3
commit 93b451d422
7 changed files with 192 additions and 163 deletions

View File

@@ -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

View File

@@ -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`) |
| **محتوا** | ۸ پایه · ۵۸ فصل · ۲۶۳ نود · ۲٬۶۱۷ درس · ۲٬۵۴۵ سوال |
---

View File

@@ -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:

View File

@@ -11,7 +11,7 @@
| فایل | محتوا |
|---|---|
| `curriculum.json` | ۸ پایه، ۵۸ فصل، ۲۶۳ نود، ۲٬۶۱۷ درس |
| `questions.json` | ۲٬۴۶۳ سوالِ چندگزینه‌ای |
| `questions.json` | ۲٬۵۴۵ سوالِ چندگزینه‌ای |
`deploy.sh` در هر انتشار `seed_content` را اجرا می‌کند، پس یک سرورِ تازه خودش
را با یک فرمان پر می‌کند — **بدونِ Qdrant، بدونِ اعتبارِ API، بدونِ نشستِ SSH.**

View File

@@ -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()

View File

@@ -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)

View File

@@ -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,