Initial commit - clean project structure
This commit is contained in:
0
affiliate/__init__.py
Normal file
0
affiliate/__init__.py
Normal file
21
affiliate/admin.py
Normal file
21
affiliate/admin.py
Normal 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
6
affiliate/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AffiliateConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'affiliate'
|
||||
41
affiliate/migrations/0001_initial.py
Normal file
41
affiliate/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
affiliate/migrations/__init__.py
Normal file
0
affiliate/migrations/__init__.py
Normal file
39
affiliate/models.py
Normal file
39
affiliate/models.py
Normal 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
28
affiliate/serializers.py
Normal 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
3
affiliate/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
9
affiliate/urls.py
Normal file
9
affiliate/urls.py
Normal 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
92
affiliate/views.py
Normal 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)
|
||||
Reference in New Issue
Block a user