Grades 4 and 5, Bazaar purchase from the app, pack SKUs
- rag: MATH_4 / MATH_5 textbooks (TOC and page offsets read from the PDFs) - content: grades 4 and 5 in the seed files: 7 units / 28 nodes each, 204 + 194 questions (every node >= 4), all re-solved blind once - subscription: Plan.bazaar_sku (admin); POST /api/subscription/purchase accepts paymentMethod=bazaar for packs, rejects unknown methods (was ignored) - payment: idempotency key cannot switch provider; discount codes refused on Bazaar orders (Bazaar charges the fixed SKU price) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
21
CLAUDE.md
21
CLAUDE.md
@@ -435,6 +435,27 @@ preferred setup, because there is then no key to leak. **Never let the server ru
|
||||
`django-insecure-…` fallback from `config/settings.py`: that string is committed here, so
|
||||
anyone who can read this repo can forge a token for any user.**
|
||||
|
||||
## وضعیت ۲۰۲۶-۱۰-۰۳ (عصر) — پایهی چهارم و پنجم، خریدِ بازار از اپ، بهبودِ UI (هنوز روی سرور نیست)
|
||||
|
||||
- **چهارم و پنجم** (درخواستِ دکتر: «از سوم به بعد»): `MATH_4`/`MATH_5` در `rag/services/textbooks.py`
|
||||
(فهرست و offset از خودِ PDF؛ پنجم چاپِ ۱۴۰۵ با offset صفر). ingest با gpt-4.1-mini ≈ ۰٫۷ دلار
|
||||
(۵۹۰ + ۵۹۴ chunk)، verify ≈ ۰٫۲؛ هر دو روی Qdrant. هر پایه ۷ فصل و ۲۸ جزیره؛ **۲۰۴ و ۱۹۴ سوال**
|
||||
با subagentِ Sonnet (حداکثر ۸ نامزد برای هر جزیره، نه همهی ۵۷۳ جفت)، و هر جزیره دستِکم ۴ سوال.
|
||||
**همهی ۳۹۸ سوال یک بار کور دوباره حل شدند** (subagentِ دیگر، بیدیدنِ کلید): ۳ مورد واقعاً غلط/مبهم
|
||||
بود و اصلاح شد (یکی کلیدِ غلط: «عدد ششمِ ۱۲۰، ۲۴۰، ۳۶۰» = ۷۲۰ نه ۶۰۰). دو جزیرهی تقارنِ پنجم در
|
||||
کتاب فقط رسم روی شطرنجی دارند؛ ۱۲ سوالِ بیشکل برایشان نوشته شد. `seed_content` روی DBِ خالی آزموده شد.
|
||||
⚠️ `fill_lesson_texts` همزمان با `rag_verify_solutions` روی SQLite «database is locked» داد و verify
|
||||
را نیمهکاره گذاشت (بیصدا تا شمردنِ رأیها)؛ دو فرمانِ نویسنده را همزمان اجرا نکنید.
|
||||
- **خریدِ بازار از مسیرِ کاربر:** تا امروز سفارشِ بازار فقط با کلیدِ داخلی ساخته میشد و `paymentMethod`
|
||||
هر مقداری را بیصدا نادیده میگرفت. حالا `Plan.bazaar_sku` (ادمین، `subscription.0012`) و
|
||||
`POST /api/subscription/purchase` با `paymentMethod: "bazaar"` (فقط بسته)؛ کدِ تخفیف روی سفارشِ بازار
|
||||
۴۰۰، Idempotency-Key با روشِ پرداختِ دیگر ۴۰۹. فرانت: `src/lib/bazaar.ts` (پل به اپِ اندروید) و
|
||||
`android/` (پوستهی WebView + Poolakey) — **هرگز build نشده** (این ماشین Android SDK ندارد). از سنیور
|
||||
لازم: اسمِ پکیج، کلیدِ RSA، SKUِ بستهها در پیشخان. شرح: `docs/operations.md` «فروشِ بسته در کافهبازار».
|
||||
- **UI** (فرانت `f8cf86e`): رنگِ متنِ کمرنگ #727299 (کنتراست ۲٫۶ → ۴٫۵)، برچسبِ تب ۱۱px، عنوانِ فصل
|
||||
در نقشه دوخطی، کاهشِ حرکت، حلقهی فوکوس، بی pull-to-refresh، لرزشِ کوتاهِ جواب. با اسکرینشاتِ
|
||||
قبل/بعد وارسی شد. manifestِ PWA عمداً اضافه نشد (رفتارِ برگشت از زرینپال در حالتِ standalone آزموده نشده).
|
||||
|
||||
## وضعیت ۲۰۲۶-۱۰-۰۲ — تصمیمهای سینیور پس از ارائه
|
||||
|
||||
- **بخشِ والدین از ریشه حذف شد.** اپِ `family` دیگر وجود ندارد. جدولهایش، مجوزهای ادمینش و ردش در
|
||||
|
||||
@@ -27,6 +27,9 @@ from rag.services.config import build_collection_name
|
||||
from rag.services.textbooks import TEXTBOOKS, get_textbook
|
||||
|
||||
GRADE_STYLE = {
|
||||
# چهارم/پنجم (۲۰۲۶-۱۰-۰۳): رنگهای گرمِ روشن، کنارِ سبزِ ششم، تا سه پایهی دبستان یک خانواده باشند.
|
||||
'چهارم': ('grade-4', '#FFB300'),
|
||||
'پنجم': ('grade-5', '#FF7043'),
|
||||
'ششم': ('grade-6', '#4CAF50'),
|
||||
'هفتم': ('grade-7', '#2196F3'),
|
||||
'هشتم': ('grade-8', '#9C27B0'),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,8 @@
|
||||
| سوم، ششم، هفتم | روی Qdrant هست (ساختهی تیم دیگر) — PDF نداریم جز هفتم |
|
||||
| هشتم، نهم، دهم | ingest شده؛ بازخوانیِ صفحههای شکلدار مانده |
|
||||
| یازدهم، دوازدهم | در نوبتِ ingest (۱۴۰۵/۰۶/۲۸) |
|
||||
| اول، دوم، چهارم، پنجم | PDF موجود است، خارج از اولویتِ فعلی |
|
||||
| چهارم، پنجم | ingest شد ۲۰۲۶-۱۰-۰۳ (درخواستِ دکتر: «از سوم به بعد»)؛ پنجم چاپِ ۱۴۰۵ است |
|
||||
| اول، دوم | PDF موجود است، خارج از اولویتِ فعلی |
|
||||
|
||||
⚠️ کتابِ ریاضیِ یازدهم و دوازدهم چند رشته دارد (تجربی، ریاضیفیزیک، انسانی).
|
||||
نامِ فایل و `Textbook.grade` باید رشته را مشخص کند، وگرنه دو کتابِ متفاوت
|
||||
|
||||
@@ -128,6 +128,10 @@ class RedeemDiscountView(APIView):
|
||||
from payment.models import Order
|
||||
# سفارش باید متعلق به همان کاربر احرازهویتشده باشد (جلوگیری از اعمال تخفیف روی سفارش دیگران).
|
||||
order = get_object_or_404(Order, pk=order_id, user_id=request.user.id)
|
||||
# بازار قیمتِ ثابتِ SKU را میگیرد، هرچه مبلغِ سفارش باشد؛ تخفیف روی سفارشِ بازار فقط
|
||||
# کد را میسوزاند و کاربر همان قیمتِ کامل را میپرداخت.
|
||||
if order.provider == 'bazaar':
|
||||
raise ValidationError({'orderId': 'کدِ تخفیف روی خرید از کافهبازار اعمال نمیشود.'})
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
|
||||
@@ -197,6 +197,23 @@ callbackِ زرینپال، برگشت از پرداخت، `BAZAAR_REDIRECT_U
|
||||
«پرداختها و فاکتورها» میبیند. کد در `payment/services.py` (`refund_order`، `cancel_pending_order`)
|
||||
است و تستهایش در `payment/tests_refund.py`.
|
||||
|
||||
## فروشِ بسته در کافهبازار
|
||||
|
||||
از ۲۰۲۶-۱۰-۰۳. اپِ اندروید (پوستهی WebViewِ همین سایت، `android/` در ریپوی فرانت) بستهها را با
|
||||
پرداختِ درونبرنامهایِ بازار میفروشد؛ در مرورگرِ معمولی همهچیز مثلِ قبل از زرینپال است.
|
||||
|
||||
1. در پیشخانِ بازار برای هر بسته یک **محصولِ مصرفشدنی** با همان قیمت بسازید.
|
||||
2. شناسهی آن (SKU) را در `/admin/subscription/plan/` در فیلدِ «Bazaar sku» همان بسته بنویسید. سرور SKU
|
||||
را فقط از همینجا میگیرد، نه از اپ؛ بستهی بیSKU داخلِ اپ «بهزودی در بازار» نشان داده میشود و
|
||||
درخواستش ۴۰۰ است. یک SKU روی دو بسته نمینشیند.
|
||||
3. کلیدِ RSAِ عمومیِ اپ (از پیشخان) در `android/app/build.gradle.kts` میرود، نه در `.env`.
|
||||
|
||||
مسیر: `POST /api/subscription/purchase` با `paymentMethod: "bazaar"` (فقط بسته) سفارش میسازد و
|
||||
`bazaarSku` برمیگرداند؛ اپ با Poolakey میخرد (`payload` = شمارهی سفارش)؛ `POST /api/payment/bazaar/verify`
|
||||
توکن را از خودِ بازار میسنجد و اعتبار میدهد. کدِ تخفیف روی سفارشِ بازار رد میشود (بازار قیمتِ
|
||||
ثابتِ محصول را میگیرد). تستها: `payment/tests_bazaar_pack.py`. **هنوز هیچ خریدِ واقعیای از بازار
|
||||
انجام نشده.**
|
||||
|
||||
## ورودِ توسعهدهندهها (بدونِ سرویسِ پیامک)
|
||||
|
||||
تا وقتی `KAVENEGAR_API_KEY` ست نشده، با `DEBUG=0` هیچکس نمیتواند با شماره
|
||||
|
||||
@@ -25,7 +25,8 @@ class IdempotencyKeyConflict(APIException):
|
||||
|
||||
|
||||
def _ensure_idempotent_match(order: Order, *, user_id: int, amount: int, currency: str,
|
||||
subscription_id: int | None, plan_id: int | None = None) -> None:
|
||||
subscription_id: int | None, plan_id: int | None = None,
|
||||
provider: str | None = None) -> None:
|
||||
if order.user_id != user_id:
|
||||
raise IdempotencyKeyConflict(
|
||||
'این Idempotency-Key متعلق به کاربر دیگری است و نمیتواند استفاده شود.'
|
||||
@@ -35,7 +36,8 @@ def _ensure_idempotent_match(order: Order, *, user_id: int, amount: int, currenc
|
||||
discounted = order.amount < amount and order.status == 'pending'
|
||||
if ((order.amount != amount and not discounted) or order.currency != currency
|
||||
or order.subscription_id != subscription_id
|
||||
or (plan_id is not None and order.plan_id not in (None, plan_id))):
|
||||
or (plan_id is not None and order.plan_id not in (None, plan_id))
|
||||
or (provider is not None and order.provider != provider)):
|
||||
raise IdempotencyKeyConflict(
|
||||
'این Idempotency-Key قبلاً با پارامترهای متفاوتی استفاده شده است.'
|
||||
)
|
||||
@@ -70,7 +72,7 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
|
||||
if existing is not None:
|
||||
_ensure_idempotent_match(
|
||||
existing, user_id=user_id, amount=amount, currency=currency,
|
||||
subscription_id=subscription_id, plan_id=plan_id,
|
||||
subscription_id=subscription_id, plan_id=plan_id, provider=provider,
|
||||
)
|
||||
payment_url = None if existing.provider == 'bazaar' else call_payment_gateway(existing)
|
||||
return existing, payment_url
|
||||
@@ -97,7 +99,7 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
|
||||
raise
|
||||
_ensure_idempotent_match(
|
||||
order, user_id=user_id, amount=amount, currency=currency,
|
||||
subscription_id=subscription_id, plan_id=plan_id,
|
||||
subscription_id=subscription_id, plan_id=plan_id, provider=provider,
|
||||
)
|
||||
|
||||
# برای provider='bazaar' هیچ paymentUrlی وجود ندارد: کلاینت اندروید مستقیماً
|
||||
|
||||
@@ -94,3 +94,79 @@ class BazaarSkuComesFromTheOrderTests(TestCase):
|
||||
verify.assert_not_called()
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(order.status, 'pending')
|
||||
|
||||
|
||||
class BazaarPurchaseFromTheAppTests(TestCase):
|
||||
"""
|
||||
خریدِ بازار از مسیرِ خودِ کاربر: `POST /api/subscription/purchase` با
|
||||
`paymentMethod: "bazaar"` (۲۰۲۶-۱۰-۰۳). پیش از آن تنها راهِ ساختِ سفارشِ بازار کلیدِ
|
||||
داخلی بود و `paymentMethod` هر مقداری را میپذیرفت و نادیده میگرفت.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.user = User.objects.create(phone='09120007103', is_guest=False)
|
||||
self.client = APIClient()
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
|
||||
self.pack = Plan.objects.get(kind='pack', question_credits=20)
|
||||
self.pack.bazaar_sku = 'tadrisino_pack_20'
|
||||
self.pack.save()
|
||||
|
||||
def tearDown(self):
|
||||
cache.clear()
|
||||
|
||||
def buy(self, plan, method='bazaar', **headers):
|
||||
return self.client.post('/api/subscription/purchase',
|
||||
{'planId': str(plan.id), 'paymentMethod': method}, format='json', **headers)
|
||||
|
||||
def test_bazaar_order_carries_the_plans_sku_and_no_payment_url(self):
|
||||
response = self.buy(self.pack)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
body = response.json()
|
||||
self.assertIsNone(body['paymentUrl'])
|
||||
self.assertEqual(body['bazaarSku'], 'tadrisino_pack_20')
|
||||
order = Order.objects.get(pk=body['orderId'])
|
||||
self.assertEqual((order.provider, order.bazaar_product_sku, order.plan_id),
|
||||
('bazaar', 'tadrisino_pack_20', self.pack.id))
|
||||
|
||||
def test_whole_flow_gives_the_pack_credits(self):
|
||||
order_id = self.buy(self.pack).json()['orderId']
|
||||
with patch('payment.bazaar.verify_purchase',
|
||||
return_value=BazaarPurchaseResult(is_valid=True, purchase_state=0)) as verify:
|
||||
response = self.client.post('/api/payment/bazaar/verify',
|
||||
{'orderId': order_id, 'purchaseToken': 'tok-app-1'}, format='json')
|
||||
self.assertEqual(response.json()['status'], 'paid')
|
||||
self.assertEqual(verify.call_args.kwargs['product_sku'], 'tadrisino_pack_20')
|
||||
self.assertEqual(credit_balance(self.user.id), 20)
|
||||
|
||||
def test_pack_without_a_sku_is_refused_not_sent_to_zarinpal(self):
|
||||
other = Plan.objects.get(kind='pack', question_credits=60)
|
||||
response = self.buy(other)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(Order.objects.filter(user_id=self.user.id).exists())
|
||||
|
||||
def test_unknown_payment_method_is_a_400(self):
|
||||
self.assertEqual(self.buy(self.pack, method='paypal').status_code, 400)
|
||||
|
||||
def test_zarinpal_is_still_the_default(self):
|
||||
response = self.client.post('/api/subscription/purchase', {'planId': str(self.pack.id)}, format='json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('/pay', response.json()['paymentUrl'])
|
||||
self.assertEqual(Order.objects.get(pk=response.json()['orderId']).provider, 'gateway')
|
||||
|
||||
def test_same_idempotency_key_cannot_switch_payment_method(self):
|
||||
self.assertEqual(self.buy(self.pack, HTTP_IDEMPOTENCY_KEY='k-1').status_code, 200)
|
||||
self.assertEqual(self.buy(self.pack, method='zarinpal', HTTP_IDEMPOTENCY_KEY='k-1').status_code, 409)
|
||||
|
||||
def test_discount_code_is_refused_on_a_bazaar_order(self):
|
||||
from discount.models import DiscountCode
|
||||
|
||||
DiscountCode.objects.create(code='HALF', type='percent', value=50, max_uses=10)
|
||||
order_id = self.buy(self.pack).json()['orderId']
|
||||
response = self.client.post('/api/discount/redeem', {'code': 'HALF', 'orderId': order_id}, format='json')
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(Order.objects.get(pk=order_id).amount, self.pack.price)
|
||||
|
||||
def test_plans_list_exposes_the_sku(self):
|
||||
plans = self.client.get('/api/subscription/plans').json()['plans']
|
||||
self.assertEqual(next(p for p in plans if p['id'] == str(self.pack.id))['bazaarSku'], 'tadrisino_pack_20')
|
||||
|
||||
@@ -95,6 +95,112 @@ MATH_3 = Textbook(
|
||||
),
|
||||
)
|
||||
|
||||
# چهارم و پنجم: درخواستِ دکتر (۲۰۲۶-۱۰-۰۳) که «از سوم به بعد» همهی پایهها باشند.
|
||||
# فهرست و offset از خودِ PDFها خوانده شد: در چهارم PDF ۸ = صفحهی چاپیِ ۲، در
|
||||
# پنجم (چاپِ تازهی ۱۴۰۵) شمارهی PDF همان شمارهی چاپی است. «حلِ مسئله»ی آغازِ
|
||||
# هر فصل جزءِ درسِ اول حساب میشود (`_sectioned` درسِ اول را تا ابتدای فصل
|
||||
# عقب میکشد) و «مرور فصل» جزءِ درسِ آخر.
|
||||
MATH_4 = Textbook(
|
||||
subject='ریاضی', grade='چهارم', grade_label='چهارم دبستان',
|
||||
filename='ریاضی چهارم.pdf', page_offset=6,
|
||||
chapters=_ch(
|
||||
(1, 'فصل ۱: اعداد و الگوها', 1, 23, (
|
||||
(1, 'عددنویسی', 4),
|
||||
(2, 'الگوها', 8),
|
||||
(3, 'ماشین ورودی ـ خروجی', 12),
|
||||
(4, 'معرفی میلیون', 16),
|
||||
)),
|
||||
(2, 'فصل ۲: کسر', 24, 45, (
|
||||
(1, 'شناخت کسرها', 26),
|
||||
(2, 'جمع و تفریق کسرها', 30),
|
||||
(3, 'تساوی کسرها', 34),
|
||||
(4, 'ضرب عدد در کسر', 38),
|
||||
)),
|
||||
(3, 'فصل ۳: ضرب و تقسیم', 46, 75, (
|
||||
(1, 'ضرب دو عدد دورقمی', 48),
|
||||
(2, 'محاسبهٔ حاصلضرب', 52),
|
||||
(3, 'محاسبههای تقریبی', 56),
|
||||
(4, 'تقسیم و بخشپذیری', 60),
|
||||
(5, 'تقسیم بر عددهای یکرقمی', 64),
|
||||
(6, 'تقسیم بر عددهای دورقمی', 68),
|
||||
)),
|
||||
(4, 'فصل ۴: اندازهگیری', 76, 97, (
|
||||
(1, 'زاویه', 78),
|
||||
(2, 'اندازهگیری زاویه', 82),
|
||||
(3, 'اندازهگیری زمان', 86),
|
||||
(4, 'اندازهگیری طول', 90),
|
||||
)),
|
||||
(5, 'فصل ۵: عدد مخلوط و عدد اعشاری', 98, 119, (
|
||||
(1, 'عدد مخلوط', 100),
|
||||
(2, 'عدد اعشاری', 104),
|
||||
(3, 'جمع و تفریق اعشاری', 108),
|
||||
(4, 'ارزش مکانی عددهای اعشاری', 112),
|
||||
)),
|
||||
(6, 'فصل ۶: شکلهای هندسی', 120, 141, (
|
||||
(1, 'عمود و موازی', 122),
|
||||
(2, 'چهارضلعیها', 126),
|
||||
(3, 'مساحت متوازیالاضلاع و مثلث', 130),
|
||||
(4, 'محیط و مساحت', 134),
|
||||
)),
|
||||
(7, 'فصل ۷: آمار و احتمال', 142, 154, (
|
||||
(1, 'نمودار خط شکسته', 144),
|
||||
(2, 'احتمال', 148),
|
||||
)),
|
||||
),
|
||||
)
|
||||
|
||||
# شروعِ هر فصل یک صفحه پیش از درسِ اولش گرفته شد (صفحهی جداکننده)؛ فقط برای
|
||||
# فصل ۱ با چشم دیده شد (PDF ۱۱). جداکنندهی بقیه اگر دو صفحه باشد، یک صفحهی
|
||||
# تصویریِ بیمسئله به درسِ آخرِ فصلِ قبل میافتد. ۲۰۷–۲۰۸ پشتِ جلد است.
|
||||
MATH_5 = Textbook(
|
||||
subject='ریاضی', grade='پنجم', grade_label='پنجم دبستان',
|
||||
filename='ریاضی پنجم.pdf', page_offset=0,
|
||||
chapters=_ch(
|
||||
(1, 'فصل ۱: عددنویسی و الگوها', 11, 38, (
|
||||
(1, 'یادآوری عددنویسی و محاسبات عددی', 12),
|
||||
(2, 'معرفی میلیارد', 18),
|
||||
(3, 'جمع و تفریق عددهای مرکب', 25),
|
||||
(4, 'الگوها', 30),
|
||||
)),
|
||||
(2, 'فصل ۲: کسر', 39, 76, (
|
||||
(1, 'کسرهای بزرگتر از واحد', 40),
|
||||
(2, 'جمع و تفریق عددهای مخلوط', 46),
|
||||
(3, 'ضرب کسرها', 53),
|
||||
(4, 'تقسیم کسرها', 59),
|
||||
(5, 'ضرب عددهای مخلوط', 66),
|
||||
)),
|
||||
(3, 'فصل ۳: نسبت، تناسب و درصد', 77, 102, (
|
||||
(1, 'نسبت', 78),
|
||||
(2, 'نسبتهای مساوی', 83),
|
||||
(3, 'تناسب', 88),
|
||||
(4, 'درصد', 93),
|
||||
)),
|
||||
(4, 'فصل ۴: تقارن و چندضلعیها', 103, 128, (
|
||||
(1, 'تقارن محوری', 104),
|
||||
(2, 'تقارن مرکزی', 110),
|
||||
(3, 'زاویه و نیمساز', 113),
|
||||
(4, 'چندضلعیها و مجموع زاویههای آنها', 118),
|
||||
)),
|
||||
(5, 'فصل ۵: عددهای اعشاری', 129, 156, (
|
||||
(1, 'عددهای اعشاری', 130),
|
||||
(2, 'جمع عددهای اعشاری', 139),
|
||||
(3, 'تفریق عددهای اعشاری', 143),
|
||||
(4, 'ضرب عددهای اعشاری', 147),
|
||||
)),
|
||||
(6, 'فصل ۶: اندازهگیری', 157, 184, (
|
||||
(1, 'مساحت لوزی و ذوزنقه', 158),
|
||||
(2, 'محیط دایره', 165),
|
||||
(3, 'حجم', 170),
|
||||
(4, 'گنجایش', 177),
|
||||
)),
|
||||
(7, 'فصل ۷: آمار و احتمال', 185, 206, (
|
||||
(1, 'جمعآوری و نمایش دادهها', 186),
|
||||
(2, 'میانگین', 193),
|
||||
(3, 'احتمال', 197),
|
||||
)),
|
||||
),
|
||||
)
|
||||
|
||||
# هشتم تنها کتابیست که هم PDF دارد و هم روی Qdrant نیست — یعنی فصلبندی و
|
||||
# درسهایش کاملاً از فهرستِ خودِ کتاب میآید و ingest دقیقاً همین رشتهها را
|
||||
# مینویسد. «مرور فصل» جزء آخرین درسِ هر فصل حساب شده است.
|
||||
@@ -365,7 +471,7 @@ CALCULUS_12 = Textbook(
|
||||
|
||||
|
||||
TEXTBOOKS: dict[str, Textbook] = {
|
||||
b.key: b for b in (MATH_3, MATH_6, MATH_7, MATH_8, MATH_9, MATH_10,
|
||||
b.key: b for b in (MATH_3, MATH_4, MATH_5, MATH_6, MATH_7, MATH_8, MATH_9, MATH_10,
|
||||
CALCULUS_11, CALCULUS_12)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ from .models import Plan, Subscription
|
||||
|
||||
@admin.register(Plan)
|
||||
class PlanAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'title', 'price', 'currency', 'duration_days', 'ai_daily_quota', 'discount_percent', 'popular')
|
||||
list_display = ('id', 'title', 'price', 'currency', 'duration_days', 'ai_daily_quota', 'discount_percent', 'popular',
|
||||
'bazaar_sku')
|
||||
list_editable = ('ai_daily_quota',)
|
||||
|
||||
|
||||
|
||||
22
subscription/migrations/0012_plan_bazaar_sku.py
Normal file
22
subscription/migrations/0012_plan_bazaar_sku.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.17 on 2026-10-03 12:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('subscription', '0011_questioncredit_refund_reason'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='bazaar_sku',
|
||||
field=models.CharField(blank=True, default='', help_text='SKUِ محصولِ درون\u200cبرنامه\u200cای در پیشخانِ کافه\u200cبازار؛ خالی = در بازار فروخته نمی\u200cشود.', max_length=100),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='plan',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('bazaar_sku', ''), _negated=True), fields=('bazaar_sku',), name='plan_bazaar_sku_unique_when_set'),
|
||||
),
|
||||
]
|
||||
@@ -43,6 +43,18 @@ class Plan(models.Model):
|
||||
default=0, help_text='فقط بسته: چند روز قلبِ نامحدود هدیه.')
|
||||
# پلنی که دیگر فروخته نمیشود ولی مشترکهای فعلیاش هنوز به آن وصلاند.
|
||||
on_sale = models.BooleanField(default=True, help_text='در صفحهی خرید نشان داده شود.')
|
||||
# شناسهی همین بسته در پیشخانِ کافهبازار (محصولِ درونبرنامهای، مصرفشدنی). خریدِ بازار
|
||||
# SKU را فقط از اینجا میگیرد، هرگز از کلاینت: وگرنه توکنِ ارزانترین محصول بستهی گران را
|
||||
# فعال میکرد. خالی یعنی این بسته در بازار فروخته نمیشود (درخواستش ۴۰۰ میگیرد).
|
||||
bazaar_sku = models.CharField(
|
||||
max_length=100, blank=True, default='',
|
||||
help_text='SKUِ محصولِ درونبرنامهای در پیشخانِ کافهبازار؛ خالی = در بازار فروخته نمیشود.')
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['bazaar_sku'], condition=~models.Q(bazaar_sku=''),
|
||||
name='plan_bazaar_sku_unique_when_set'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@@ -15,6 +15,8 @@ class PlanSerializer(serializers.Serializer):
|
||||
kind = serializers.ChoiceField(choices=['pack', 'subscription'])
|
||||
questionCredits = serializers.IntegerField(help_text='بسته: سوالهایی که به اعتبار اضافه میشود (منقضی نمیشود).')
|
||||
heartsDays = serializers.IntegerField(help_text='بسته: روزهای قلبِ نامحدودِ هدیه.')
|
||||
bazaarSku = serializers.CharField(
|
||||
allow_blank=True, help_text='SKUِ همین بسته در کافهبازار؛ خالی یعنی در بازار خریدنی نیست.')
|
||||
|
||||
|
||||
class PlansListResponseSerializer(serializers.Serializer):
|
||||
@@ -27,12 +29,17 @@ class PurchaseRequestSerializer(serializers.Serializer):
|
||||
# هدر Idempotency-Key هم روی همین درخواست لازمه (بخش ۴.۳ سند API)؛
|
||||
# از request.headers خونده میشه، نه از بدنه.
|
||||
planId = serializers.CharField()
|
||||
paymentMethod = serializers.CharField(required=False, default='zarinpal')
|
||||
# تا ۲۰۲۶-۱۰-۰۳ هر مقداری پذیرفته و نادیده گرفته میشد، پس «bazaar» بیصدا به زرینپال میرفت.
|
||||
paymentMethod = serializers.ChoiceField(choices=['zarinpal', 'bazaar'], required=False, default='zarinpal')
|
||||
|
||||
|
||||
class PurchaseResponseSerializer(serializers.Serializer):
|
||||
orderId = serializers.CharField()
|
||||
paymentUrl = serializers.CharField()
|
||||
paymentUrl = serializers.CharField(
|
||||
allow_null=True, help_text='زرینپال: آدرسی که کاربر به آن برود. بازار: null.')
|
||||
bazaarSku = serializers.CharField(
|
||||
required=False, help_text='فقط بازار: SKUی که اپِ اندروید با Poolakey میخرد؛ '
|
||||
'developerPayload همان orderId باشد.')
|
||||
|
||||
|
||||
class SubscriptionStatusResponseSerializer(serializers.Serializer):
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.db.models import F
|
||||
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.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
@@ -69,6 +70,7 @@ class PlansListView(APIView):
|
||||
'kind': p.kind,
|
||||
'questionCredits': p.question_credits,
|
||||
'heartsDays': p.hearts_days,
|
||||
'bazaarSku': p.bazaar_sku,
|
||||
}
|
||||
for p in Plan.objects.filter(on_sale=True).order_by('kind', 'price')
|
||||
],
|
||||
@@ -119,6 +121,21 @@ class PurchaseView(APIView):
|
||||
plan = get_object_or_404(Plan, pk=v['planId'], on_sale=True)
|
||||
idempotency_key = request.headers.get('Idempotency-Key')
|
||||
|
||||
if v['paymentMethod'] == 'bazaar':
|
||||
# فقط بسته: اشتراکها دیگر فروخته نمیشوند و مسیرِ اشتراکِ بازار (تمدیدِ خودکار) هرگز
|
||||
# با خریدِ واقعی آزموده نشده. SKU از پلن، نه از کلاینت.
|
||||
if plan.kind != 'pack':
|
||||
raise ValidationError({'paymentMethod': 'خرید از بازار فقط برای بستههای سوال است.'})
|
||||
if not plan.bazaar_sku:
|
||||
raise ValidationError({'planId': 'این بسته هنوز در کافهبازار تعریف نشده است.'})
|
||||
order, _ = create_order(
|
||||
user_id=user.id, amount=plan.price, currency=plan.currency,
|
||||
idempotency_key=idempotency_key, plan_id=plan.id, plan_title=plan.title,
|
||||
provider='bazaar', bazaar_product_sku=plan.bazaar_sku,
|
||||
)
|
||||
return Response(PurchaseResponseSerializer(
|
||||
{'orderId': str(order.id), 'paymentUrl': None, 'bazaarSku': order.bazaar_product_sku}).data)
|
||||
|
||||
if plan.kind == 'pack':
|
||||
# بسته به Subscription دست نمیزند: اعتبار و روزهای قلب بعد از پرداخت از روی
|
||||
# خودِ سفارش داده میشوند (payment.services → subscription.services.grant_pack).
|
||||
|
||||
Reference in New Issue
Block a user