Initial commit - clean project structure

This commit is contained in:
2026-08-18 16:48:15 +03:30
commit 331148037e
147 changed files with 5543 additions and 0 deletions

0
subscription/__init__.py Normal file
View File

14
subscription/admin.py Normal file
View File

@@ -0,0 +1,14 @@
from django.contrib import admin
from .models import Plan, Subscription
@admin.register(Plan)
class PlanAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'price', 'currency', 'discount_percent', 'popular')
@admin.register(Subscription)
class SubscriptionAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'plan', 'status', 'started_at', 'expires_at', 'order_id')
list_filter = ('status',)

6
subscription/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class SubscriptionConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'subscription'

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.2.17 on 2026-08-10 12:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Plan',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=50)),
('price', models.PositiveIntegerField()),
('currency', models.CharField(default='IRT', max_length=10)),
('discount_percent', models.PositiveSmallIntegerField(default=0)),
('popular', models.BooleanField(default=False)),
],
),
migrations.CreateModel(
name='Subscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True, unique=True)),
('status', models.CharField(choices=[('pending', 'در انتظار پرداخت'), ('active', 'فعال'), ('expired', 'منقضی'), ('canceled', 'لغوشده')], default='pending', max_length=15)),
('started_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField(blank=True, null=True)),
('order_id', models.IntegerField(blank=True, null=True)),
('plan', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='subscriptions', to='subscription.plan')),
],
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-08-10 17:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('subscription', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='plan',
name='duration_days',
field=models.PositiveIntegerField(default=30),
),
]

View File

55
subscription/models.py Normal file
View File

@@ -0,0 +1,55 @@
from django.db import models
class Plan(models.Model):
"""
⚠️ اصلاح نسبت به سند اولیه: نمونه‌ی پاسخ GET /api/subscription/plans در
سند Endpointهای API یک فیلد boolean به اسم `popular` داره («ماهانه» →
false، «سالانه» → true) که در دیاگرام مدل‌ها (سند معماری داده) برای
Plan اصلاً ذکر نشده. چون بدون این فیلد نمی‌شه دقیقاً همون response
schema رو پیاده‌سازی کرد، اینجا اضافه شده — دقیقاً مثل فیکس is_correct
روی content.Choice در فاز ۱.
"""
title = models.CharField(max_length=50)
price = models.PositiveIntegerField()
currency = models.CharField(max_length=10, default='IRT')
discount_percent = models.PositiveSmallIntegerField(default=0)
popular = models.BooleanField(default=False) # <-- اصلاح/افزوده شده
# ⚠️ اصلاح دیگه: سند طراحی هیچ فیلد duration‌ای برای Plan نداره، در
# نتیجه‌ی این نبود، activate_subscription قبلاً مدت اشتراک رو با یک
# قاعده‌ی stub (چک کردن کلمه‌ی «سالانه»/yearly در عنوان) حدس می‌زد که
# شکننده و وابسته به متن title بود. duration_days اینجا به‌عنوان منبع
# صریح و قابل‌اتکای مدت اشتراک اضافه شده؛ پیش‌فرض ۳۰ روز.
duration_days = models.PositiveIntegerField(default=30) # <-- اصلاح/افزوده شده
def __str__(self):
return self.title
class Subscription(models.Model):
"""
⚠️ اصلاح نسبت به سند اولیه: STATUS_CHOICES در سند فقط شامل
active/expired/canceled بود. اما در جریان POST /api/subscription/purchase
یک Subscription قبل از تایید پرداخت (که با payment webhook میاد) ساخته
می‌شه؛ بدون یک وضعیت میانی، راهی برای نمایش «سفارش ثبت شده ولی هنوز
پرداخت تایید نشده» وجود نداره. برای همین وضعیت pending اضافه و به‌عنوان
پیش‌فرض ساخت (به‌جای active در سند) تنظیم شده؛ فعال‌سازی واقعی توسط
subscription.services.activate_subscription بعد از webhook پرداخت انجام
می‌شه.
"""
STATUS_CHOICES = [
('pending', 'در انتظار پرداخت'), # <-- اصلاح/افزوده شده
('active', 'فعال'),
('expired', 'منقضی'),
('canceled', 'لغوشده'),
]
user_id = models.IntegerField(unique=True, db_index=True) # ارجاع سبک به accounts.User
plan = models.ForeignKey(Plan, on_delete=models.SET_NULL, null=True, related_name='subscriptions')
status = models.CharField(max_length=15, choices=STATUS_CHOICES, default='pending')
started_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(null=True, blank=True)
order_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به payment.Order
def __str__(self):
return f"sub-user{self.user_id}-{self.status}"

View File

@@ -0,0 +1,33 @@
from rest_framework import serializers
class PlanSerializer(serializers.Serializer):
id = serializers.CharField()
title = serializers.CharField()
price = serializers.IntegerField()
currency = serializers.CharField(required=False)
popular = serializers.BooleanField()
discountPercent = serializers.IntegerField(required=False)
durationDays = serializers.IntegerField(required=False)
class PlansListResponseSerializer(serializers.Serializer):
plans = PlanSerializer(many=True)
class PurchaseRequestSerializer(serializers.Serializer):
# هدر Idempotency-Key هم روی همین درخواست لازمه (بخش ۴.۳ سند API)؛
# از request.headers خونده می‌شه، نه از بدنه.
planId = serializers.CharField()
paymentMethod = serializers.CharField(required=False, default='zarinpal')
class PurchaseResponseSerializer(serializers.Serializer):
orderId = serializers.CharField()
paymentUrl = serializers.CharField()
class SubscriptionStatusResponseSerializer(serializers.Serializer):
active = serializers.BooleanField()
planId = serializers.CharField(allow_null=True)
expiresAt = serializers.DateTimeField(allow_null=True)

59
subscription/services.py Normal file
View File

@@ -0,0 +1,59 @@
from django.utils import timezone
from statuslog.signals import status_changed
from .models import Subscription
def activate_subscription(subscription_id: int):
"""
بعد از تایید پرداخت (payment webhook) صدا زده می‌شه؛ اشتراک رو active
می‌کنه و expires_at رو بر اساس Plan.duration_days محاسبه می‌کنه.
(قبلاً چون Plan فیلد duration‌ای نداشت، این مقدار با یک قاعده‌ی stub
بر اساس متن title حدس زده می‌شد؛ الان duration_days مستقیماً از مدل
Plan خونده می‌شه.)
"""
try:
subscription = Subscription.objects.get(pk=subscription_id)
except Subscription.DoesNotExist:
return None
old_status = subscription.status
duration_days = subscription.plan.duration_days if subscription.plan else 30
subscription.status = 'active'
subscription.expires_at = timezone.now() + timezone.timedelta(days=duration_days)
subscription.save(update_fields=['status', 'expires_at'])
status_changed.send(
sender='subscription.Subscription', object_id=subscription.id,
old_status=old_status, new_status='active',
event='payment_confirmed', user_id=subscription.user_id,
)
return subscription
def grant_free_subscription(user_id: int, *, duration_days: int | None = None, event: str = 'offer_applied'):
"""
فعال‌سازی اشتراک بدون هیچ Order/پرداختی — برای Offer (آفر رایگان
محدود/کامل). duration_days=None یعنی نامحدود (free_full)؛ عدد یعنی
trial با همون تعداد روز.
از discount/services.py (apply_offer) صدا زده می‌شه؛ مثل بقیه‌ی
ارتباطات بین‌اپیِ این پروژه، یک import مستقیم به تابع سرویس، نه FK
واقعی و نه HTTP call به خودمون.
"""
subscription, _ = Subscription.objects.get_or_create(user_id=user_id)
old_status = subscription.status
subscription.status = 'active'
subscription.expires_at = (
timezone.now() + timezone.timedelta(days=duration_days) if duration_days else None
)
subscription.save(update_fields=['status', 'expires_at'])
status_changed.send(
sender='subscription.Subscription', object_id=subscription.id,
old_status=old_status, new_status='active',
event=event, user_id=user_id,
)
return subscription

3
subscription/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

9
subscription/urls.py Normal file
View File

@@ -0,0 +1,9 @@
from django.urls import path
from .views import PlansListView, PurchaseView, SubscriptionStatusView
urlpatterns = [
path('subscription/plans', PlansListView.as_view(), name='subscription-plans'),
path('subscription/purchase', PurchaseView.as_view(), name='subscription-purchase'),
path('subscription/status', SubscriptionStatusView.as_view(), name='subscription-status'),
]

115
subscription/views.py Normal file
View File

@@ -0,0 +1,115 @@
from django.db import transaction
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.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from payment.services import create_order
from .models import Plan, Subscription
from .serializers import (
PlansListResponseSerializer,
PurchaseRequestSerializer,
PurchaseResponseSerializer,
SubscriptionStatusResponseSerializer,
)
class PlansListView(APIView):
"""GET /api/subscription/plans — لیست پلن‌ها"""
@extend_schema(responses=PlansListResponseSerializer, tags=['Subscription (subscription)'])
def get(self, request):
plans = Plan.objects.all()
data = {
'plans': [
{
'id': str(p.id),
'title': p.title,
'price': p.price,
'currency': p.currency,
'popular': p.popular,
'discountPercent': p.discount_percent,
'durationDays': p.duration_days,
}
for p in plans
]
}
return Response(PlansListResponseSerializer(data).data)
class PurchaseView(APIView):
"""
POST /api/subscription/purchase — شروع خرید (با هدر Idempotency-Key)
یک Subscription در وضعیت pending می‌سازه/به‌روز می‌کنه، بعد مستقیماً
تابع سرویس اپ Payment رو صدا می‌زنه تا Order بسازه (همون «فراخوانی
سرور-به-سرور» سند API که در این مونولیت به شکل import مستقیم پیاده‌سازی
شده، نه یک HTTP call جدا به خودمون).
"""
permission_classes = [IsAuthenticated]
# ⚠️ افزوده شده: مستندسازی هدر Idempotency-Key که قبلاً در Swagger UI
# قابل تنظیم/تست نبود.
@extend_schema(
request=PurchaseRequestSerializer,
responses=PurchaseResponseSerializer,
parameters=[
OpenApiParameter(
'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False,
description=(
'کلید یکتای idempotency برای جلوگیری از ساخت سفارش تکراری هنگام retry. '
'در صورت عدم ارسال، یک کلید تصادفی ساخته می‌شود.'
),
),
],
tags=['Subscription (subscription)'],
)
def post(self, request):
serializer = PurchaseRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
user = request.user
plan = get_object_or_404(Plan, pk=v['planId'])
# ⚠️ اصلاح: قبلاً اگه create_order (مثلاً به‌خاطر IdempotencyKeyConflict)
# خطا می‌داد، Subscription که یک خط بالاتر update_or_create شده بود
# نیمه‌کاره (status='pending' ولی بدون order_id معتبر) در دیتابیس
# باقی می‌موند. حالا کل عملیات در یک تراکنش اتمیک انجام می‌شه.
with transaction.atomic():
subscription, _created = Subscription.objects.update_or_create(
user_id=user.id,
defaults={'plan': plan, 'status': 'pending'},
)
order, payment_url = create_order(
user_id=user.id,
amount=plan.price,
currency=plan.currency,
subscription_id=subscription.id,
idempotency_key=request.headers.get('Idempotency-Key'),
)
subscription.order_id = order.id
subscription.save(update_fields=['order_id'])
data = {'orderId': str(order.id), 'paymentUrl': payment_url}
return Response(PurchaseResponseSerializer(data).data)
class SubscriptionStatusView(APIView):
"""GET /api/subscription/status — وضعیت فعلی اشتراک"""
permission_classes = [IsAuthenticated]
@extend_schema(responses=SubscriptionStatusResponseSerializer, tags=['Subscription (subscription)'])
def get(self, request):
subscription = Subscription.objects.filter(user_id=request.user.id).first()
data = {
'active': bool(subscription and subscription.status == 'active'),
'planId': str(subscription.plan_id) if subscription and subscription.plan_id else None,
'expiresAt': subscription.expires_at if subscription else None,
}
return Response(SubscriptionStatusResponseSerializer(data).data)