From 93b451d422d8b7e1fdc4abce4fd047ef7b0107ab Mon Sep 17 00:00:00 2001 From: Zareian Date: Tue, 29 Sep 2026 21:18:12 +0330 Subject: [PATCH] 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 --- .env.example | 234 +++++++++++++------------------------- README.md | 6 +- content/views.py | 20 +++- docs/content-authoring.md | 2 +- support/serializers.py | 13 +++ support/tests.py | 38 +++++++ support/views.py | 42 ++++++- 7 files changed, 192 insertions(+), 163 deletions(-) diff --git a/.env.example b/.env.example index b3f70a1..de64468 100644 --- a/.env.example +++ b/.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 diff --git a/README.md b/README.md index 2336199..c32406c 100644 --- a/README.md +++ b/README.md @@ -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`) | +| **محتوا** | ۸ پایه · ۵۸ فصل · ۲۶۳ نود · ۲٬۶۱۷ درس · ۲٬۵۴۵ سوال | --- diff --git a/content/views.py b/content/views.py index d9f8f00..4d79d57 100644 --- a/content/views.py +++ b/content/views.py @@ -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: diff --git a/docs/content-authoring.md b/docs/content-authoring.md index 673be95..38499b7 100644 --- a/docs/content-authoring.md +++ b/docs/content-authoring.md @@ -11,7 +11,7 @@ | فایل | محتوا | |---|---| | `curriculum.json` | ۸ پایه، ۵۸ فصل، ۲۶۳ نود، ۲٬۶۱۷ درس | -| `questions.json` | ۲٬۴۶۳ سوالِ چندگزینه‌ای | +| `questions.json` | ۲٬۵۴۵ سوالِ چندگزینه‌ای | `deploy.sh` در هر انتشار `seed_content` را اجرا می‌کند، پس یک سرورِ تازه خودش را با یک فرمان پر می‌کند — **بدونِ Qdrant، بدونِ اعتبارِ API، بدونِ نشستِ SSH.** diff --git a/support/serializers.py b/support/serializers.py index 5b28bc6..43cbe66 100644 --- a/support/serializers.py +++ b/support/serializers.py @@ -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() diff --git a/support/tests.py b/support/tests.py index d3ded87..c4362cf 100644 --- a/support/tests.py +++ b/support/tests.py @@ -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) diff --git a/support/views.py b/support/views.py index d40e9ca..54fde44 100644 --- a/support/views.py +++ b/support/views.py @@ -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,