Initial commit

This commit is contained in:
2026-08-11 16:43:12 +03:30
commit e1d830f6a9
127 changed files with 4454 additions and 0 deletions

0
discount/__init__.py Normal file
View File

19
discount/admin.py Normal file
View File

@@ -0,0 +1,19 @@
from django.contrib import admin
from .models import DiscountCode, Redemption
class RedemptionInline(admin.TabularInline):
model = Redemption
extra = 0
@admin.register(DiscountCode)
class DiscountCodeAdmin(admin.ModelAdmin):
list_display = ('id', 'code', 'type', 'value', 'max_uses', 'expires_at')
inlines = [RedemptionInline]
@admin.register(Redemption)
class RedemptionAdmin(admin.ModelAdmin):
list_display = ('id', 'discount_code', 'user_id', 'order_id', 'redeemed_at')

6
discount/apps.py Normal file
View File

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

View File

@@ -0,0 +1,36 @@
# 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='DiscountCode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=30, unique=True)),
('type', models.CharField(choices=[('percent', 'درصدی'), ('fixed', 'مبلغ ثابت')], max_length=10)),
('value', models.PositiveIntegerField()),
('max_uses', models.PositiveIntegerField(default=1)),
('expires_at', models.DateTimeField(blank=True, null=True)),
],
),
migrations.CreateModel(
name='Redemption',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True)),
('order_id', models.IntegerField()),
('redeemed_at', models.DateTimeField(auto_now_add=True)),
('discount_code', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='redemptions', to='discount.discountcode')),
],
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.2.17 on 2026-08-10 17:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('discount', '0001_initial'),
]
operations = [
migrations.AddConstraint(
model_name='redemption',
constraint=models.UniqueConstraint(fields=('discount_code', 'user_id'), name='unique_redemption_per_user_per_code'),
),
]

View File

34
discount/models.py Normal file
View File

@@ -0,0 +1,34 @@
from django.db import models
class DiscountCode(models.Model):
TYPE_CHOICES = [('percent', 'درصدی'), ('fixed', 'مبلغ ثابت')]
code = models.CharField(max_length=30, unique=True)
type = models.CharField(max_length=10, choices=TYPE_CHOICES)
value = models.PositiveIntegerField()
max_uses = models.PositiveIntegerField(default=1)
expires_at = models.DateTimeField(null=True, blank=True)
def __str__(self):
return self.code
class Redemption(models.Model):
discount_code = models.ForeignKey(DiscountCode, on_delete=models.CASCADE, related_name='redemptions')
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
order_id = models.IntegerField() # ارجاع سبک به payment.Order
redeemed_at = models.DateTimeField(auto_now_add=True)
class Meta:
# هر کاربر برای یک کد تخفیف حداکثر یک‌بار می‌تونه redeem کنه؛
# قبلاً هیچ محدودیتی در سطح دیتابیس نبود و همراه با نبود چک max_uses
# در RedeemDiscountView، امکان استفاده‌ی نامحدود از یک کد وجود داشت.
constraints = [
models.UniqueConstraint(
fields=['discount_code', 'user_id'], name='unique_redemption_per_user_per_code'
)
]
def __str__(self):
return f"redemption-{self.discount_code_id}-order{self.order_id}"

18
discount/serializers.py Normal file
View File

@@ -0,0 +1,18 @@
from rest_framework import serializers
class ValidateResponseSerializer(serializers.Serializer):
valid = serializers.BooleanField()
code = serializers.CharField(required=False)
type = serializers.CharField(required=False)
value = serializers.IntegerField(required=False)
class RedeemRequestSerializer(serializers.Serializer):
code = serializers.CharField()
orderId = serializers.CharField()
class RedeemResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
discountAmount = serializers.IntegerField()

3
discount/tests.py Normal file
View File

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

8
discount/urls.py Normal file
View File

@@ -0,0 +1,8 @@
from django.urls import path
from .views import RedeemDiscountView, ValidateDiscountView
urlpatterns = [
path('discount/validate', ValidateDiscountView.as_view(), name='discount-validate'),
path('discount/redeem', RedeemDiscountView.as_view(), name='discount-redeem'),
]

129
discount/views.py Normal file
View File

@@ -0,0 +1,129 @@
from django.db import IntegrityError, transaction
from django.shortcuts import get_object_or_404
from django.utils import timezone
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.views import APIView
from .models import DiscountCode, Redemption
from .serializers import (
RedeemRequestSerializer,
RedeemResponseSerializer,
ValidateResponseSerializer,
)
def _is_usable(discount: DiscountCode) -> bool:
return bool(
discount.redemptions.count() < discount.max_uses
and (discount.expires_at is None or discount.expires_at >= timezone.now())
)
class ValidateDiscountView(APIView):
"""GET /api/discount/validate?code= — اعتبارسنجی کد پیش از خرید"""
@extend_schema(
parameters=[OpenApiParameter('code', OpenApiTypes.STR, required=True)],
responses=ValidateResponseSerializer,
tags=['Discount (discount)'],
)
def get(self, request):
code = request.query_params.get('code', '')
discount = DiscountCode.objects.filter(code=code).first()
if not discount or not _is_usable(discount):
return Response(ValidateResponseSerializer({'valid': False}).data)
data = {'valid': True, 'code': discount.code, 'type': discount.type, 'value': discount.value}
return Response(ValidateResponseSerializer(data).data)
class RedeemDiscountView(APIView):
"""
POST /api/discount/redeem — ثبت مصرف کد هنگام خرید
قبلاً فقط ValidateDiscountView چک max_uses/expires_at رو انجام می‌داد؛
خود Redeem هیچ‌کدوم از این محدودیت‌ها رو دوباره چک نمی‌کرد و بین
validate و redeem هم race condition ممکن بود (دو درخواست هم‌زمان هر دو
از سد شمارش رد بشن). اینجا:
- همون شرط‌های usable بودن (max_uses/expires_at) دوباره در لحظه‌ی
redeem چک می‌شه، نه فقط در validate.
- با select_for_update روی DiscountCode، شمارش و ساخت Redemption در
یک تراکنش اتمیک و با قفل ردیف انجام می‌شه تا از over-redemption
هم‌زمان جلوگیری بشه.
- هر کاربر برای یک کد، حداکثر یک‌بار می‌تونه redeem کنه (unique
constraint روی discount_code+user_id در سطح مدل/migration).
طبق سند، این اپ هیچ محاسبه‌ی مبلغی رو دوباره خودش «ذخیره» نمی‌کنه؛ اما
برای پر کردن discountAmount در پاسخ، باید مبلغ سفارش رو از یک جایی
بخونه. چون هیچ اپ دیگه‌ای به این اپ ارجاع نمی‌ده (نه برعکس)، اینجا فقط
یک خواندن با id از payment.models.Order انجام می‌شه — دقیقاً همون
الگویی که progress/views.py برای خوندن نام کاربر از accounts.models.User
استفاده کرده (import مستقیم + فیلتر با id، نه FK واقعی و نه join).
"""
permission_classes = [IsAuthenticated]
@extend_schema(request=RedeemRequestSerializer, responses=RedeemResponseSerializer,
tags=['Discount (discount)'])
def post(self, request):
serializer = RedeemRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
order_id = int(v['orderId'])
from payment.models import Order
# ⚠️ اصلاح امنیتی: قبلاً مالکیتِ سفارش چک نمی‌شد، پس هر کاربر
# لاگین‌کرده‌ای می‌تونست با حدس‌زدن یک orderId، کد تخفیف رو روی
# سفارشِ کاربر دیگری اعمال کنه. حالا سفارش باید متعلق به همون
# کاربر احرازهویت‌شده باشه.
order = get_object_or_404(Order, pk=order_id, user_id=request.user.id)
try:
with transaction.atomic():
discount = get_object_or_404(
DiscountCode.objects.select_for_update(), code=v['code']
)
if not _is_usable(discount):
raise ValidationError({'code': 'این کد دیگر معتبر نیست یا سقف استفاده‌اش پر شده.'})
if discount.redemptions.filter(user_id=request.user.id).exists():
raise ValidationError({'code': 'شما قبلاً این کد را استفاده کرده‌اید.'})
# فقط سفارش‌های در انتظار پرداخت می‌تونن تخفیف بگیرن؛ سفارش
# پرداخت‌شده/ناموفق نباید عقب‌گرد تغییر کنه.
if order.status != 'pending':
raise ValidationError({'code': 'این سفارش دیگر قابل اعمال تخفیف نیست.'})
order_locked = Order.objects.select_for_update().get(pk=order.id)
if discount.type == 'percent':
discount_amount = order_locked.amount * discount.value // 100
else:
discount_amount = min(discount.value, order_locked.amount)
Redemption.objects.create(
discount_code=discount,
user_id=request.user.id,
order_id=order_id,
)
# ⚠️ اصلاح: قبلاً discountAmount فقط محاسبه و در پاسخ
# برگردونده می‌شد ولی هیچ‌وقت روی خودِ Order اعمال نمی‌شد،
# یعنی جریان واقعی پرداخت مبلغ تخفیف‌نخورده رو دریافت می‌کرد.
# حالا مبلغ سفارش واقعاً کم می‌شه تا مرحله‌ی پرداخت همین
# مبلغ تخفیف‌خورده رو ببینه.
order_locked.amount = max(order_locked.amount - discount_amount, 0)
order_locked.save(update_fields=['amount'])
except IntegrityError:
# fallback در برابر race condition روی unique constraint، حتی
# اگه به هر دلیلی قفل select_for_update رد بشه.
raise ValidationError({'code': 'این کد دیگر معتبر نیست یا سقف استفاده‌اش پر شده.'})
data = {'success': True, 'discountAmount': discount_amount}
return Response(RedeemResponseSerializer(data).data)