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
affiliate/__init__.py Normal file
View File

21
affiliate/admin.py Normal file
View File

@@ -0,0 +1,21 @@
from django.contrib import admin
from .models import AffiliateAccount, Conversion
class ConversionInline(admin.TabularInline):
model = Conversion
extra = 0
@admin.register(AffiliateAccount)
class AffiliateAccountAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'referral_code', 'commission_rate', 'created_at')
search_fields = ('referral_code',)
inlines = [ConversionInline]
@admin.register(Conversion)
class ConversionAdmin(admin.ModelAdmin):
list_display = ('id', 'affiliate_account', 'referred_user_id', 'status', 'commission_amount', 'created_at')
list_filter = ('status',)

6
affiliate/apps.py Normal file
View File

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

View File

@@ -0,0 +1,41 @@
# Generated by Django 6.1 on 2026-08-10 15:43
import affiliate.models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='AffiliateAccount',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True, unique=True)),
('referral_code', models.CharField(default=affiliate.models.generate_referral_code, max_length=20, unique=True)),
('commission_rate', models.FloatField(default=0.1)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='Conversion',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('referred_user_id', models.IntegerField(db_index=True, unique=True)),
('order_id', models.IntegerField(blank=True, null=True)),
('status', models.CharField(choices=[('pending', 'در انتظار'), ('paid', 'پرداخت\u200cشده')], default='pending', max_length=10)),
('commission_amount', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('affiliate_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='conversions', to='affiliate.affiliateaccount')),
],
options={
'ordering': ['-created_at'],
},
),
]

View File

39
affiliate/models.py Normal file
View File

@@ -0,0 +1,39 @@
import secrets
import string
from django.db import models
def generate_referral_code() -> str:
"""کد ۸ کاراکتری حروف‌بزرگ/عدد، مثل SARA10 در سند نمونه."""
alphabet = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(8))
class AffiliateAccount(models.Model):
user_id = models.IntegerField(unique=True, db_index=True) # ارجاع سبک به accounts.User
referral_code = models.CharField(max_length=20, unique=True, default=generate_referral_code)
commission_rate = models.FloatField(default=0.1)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"affiliate-user{self.user_id}-{self.referral_code}"
class Conversion(models.Model):
STATUS_CHOICES = [('pending', 'در انتظار'), ('paid', 'پرداخت‌شده')]
affiliate_account = models.ForeignKey(
AffiliateAccount, on_delete=models.CASCADE, related_name='conversions'
)
referred_user_id = models.IntegerField(unique=True, db_index=True) # ارجاع سبک به accounts.User
order_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به payment.Order
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')
commission_amount = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"conversion-{self.pk}-referred{self.referred_user_id}"

28
affiliate/serializers.py Normal file
View File

@@ -0,0 +1,28 @@
from rest_framework import serializers
class AffiliateMeResponseSerializer(serializers.Serializer):
referralCode = serializers.CharField()
commissionRate = serializers.FloatField()
class TrackRequestSerializer(serializers.Serializer):
referralCode = serializers.CharField()
referredUserId = serializers.CharField()
class TrackResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
conversionId = serializers.CharField()
status = serializers.CharField()
class ConversionItemSerializer(serializers.Serializer):
id = serializers.CharField()
referredUserId = serializers.CharField()
status = serializers.CharField()
commissionAmount = serializers.IntegerField()
class ConversionListResponseSerializer(serializers.Serializer):
conversions = ConversionItemSerializer(many=True)

3
affiliate/tests.py Normal file
View File

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

9
affiliate/urls.py Normal file
View File

@@ -0,0 +1,9 @@
from django.urls import path
from .views import AffiliateMeView, ConversionsListView, TrackConversionView
urlpatterns = [
path('affiliate/me', AffiliateMeView.as_view(), name='affiliate-me'),
path('affiliate/track', TrackConversionView.as_view(), name='affiliate-track'),
path('affiliate/conversions', ConversionsListView.as_view(), name='affiliate-conversions'),
]

92
affiliate/views.py Normal file
View File

@@ -0,0 +1,92 @@
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import 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 AffiliateAccount, Conversion
from .serializers import (
AffiliateMeResponseSerializer,
ConversionListResponseSerializer,
TrackRequestSerializer,
TrackResponseSerializer,
)
class AffiliateMeView(APIView):
"""GET /api/affiliate/me — حساب و کد ارجاع کاربر"""
permission_classes = [IsAuthenticated]
@extend_schema(responses=AffiliateMeResponseSerializer, tags=['Affiliate (affiliate)'])
def get(self, request):
account, _ = AffiliateAccount.objects.get_or_create(user_id=request.user.id)
data = {'referralCode': account.referral_code, 'commissionRate': account.commission_rate}
return Response(AffiliateMeResponseSerializer(data).data)
class TrackConversionView(APIView):
"""
POST /api/affiliate/track — ثبت کلیک/ارجاع
طبق سند، این اپ فقط با referralCode به AffiliateAccount وصل می‌شه
(نه توکن کاربر)، چون referredUserId خودش کاربر تازه‌ارجاع‌شده‌ست که
لزوماً صاحب حساب افیلیت نیست؛ پس اینجا permission پیش‌فرض (AllowAny)
نگه داشته شده، دقیقاً مثل ValidateDiscountView در اپ discount.
"""
@extend_schema(
request=TrackRequestSerializer, responses=TrackResponseSerializer, tags=['Affiliate (affiliate)']
)
def post(self, request):
serializer = TrackRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
account = get_object_or_404(AffiliateAccount, referral_code=v['referralCode'])
referred_user_id = int(v['referredUserId'])
# ⚠️ اصلاح امنیتی: قبلاً referredUserId بدون هیچ اعتبارسنجی‌ای
# مستقیم مصرف می‌شد؛ یعنی می‌شد برای یک کاربر ناموجود Conversion
# ساخت یا با فرستادن userId خودِ صاحب حساب affiliate، به خودش
# ارجاع داد (self-referral). حالا هر دو حالت رد می‌شن.
from accounts.models import User as AccountUser
if not AccountUser.objects.filter(pk=referred_user_id).exists():
raise ValidationError({'referredUserId': 'چنین کاربری وجود ندارد.'})
if referred_user_id == account.user_id:
raise ValidationError({'referredUserId': 'امکان ثبت ارجاع برای خودِ صاحب کد وجود ندارد.'})
conversion, _ = Conversion.objects.get_or_create(
referred_user_id=referred_user_id,
defaults={'affiliate_account': account},
)
data = {'success': True, 'conversionId': str(conversion.pk), 'status': conversion.status}
return Response(TrackResponseSerializer(data).data)
class ConversionsListView(APIView):
"""GET /api/affiliate/conversions — لیست تبدیل‌ها و کمیسیون"""
permission_classes = [IsAuthenticated]
@extend_schema(responses=ConversionListResponseSerializer, tags=['Affiliate (affiliate)'])
def get(self, request):
account = AffiliateAccount.objects.filter(user_id=request.user.id).first()
conversions = account.conversions.all() if account else Conversion.objects.none()
items = [
{
'id': str(c.pk),
'referredUserId': str(c.referred_user_id),
'status': c.status,
'commissionAmount': c.commission_amount,
}
for c in conversions
]
data = {'conversions': items}
return Response(ConversionListResponseSerializer(data).data)