initial commit
This commit is contained in:
83
apps/payments/admin.py
Normal file
83
apps/payments/admin.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
from core.admin import SoftDeleteListFilter, BaseModelAdmin
|
||||
from apps.payments.resources import DiscountResource, PaymentResource
|
||||
from apps.payments.models import Payment, DiscountCode
|
||||
|
||||
|
||||
@admin.register(DiscountCode)
|
||||
class DiscountCodeAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||
resource_class = DiscountResource
|
||||
|
||||
list_display = (
|
||||
'code', 'type', 'value', 'is_active', 'starts_at', 'ends_at',
|
||||
'usage_limit_total', 'usage_limit_per_user', 'min_amount', 'is_deleted'
|
||||
)
|
||||
list_filter = (
|
||||
'type', 'is_active', 'starts_at', 'ends_at', 'applicable_events',
|
||||
SoftDeleteListFilter,
|
||||
)
|
||||
search_fields = ('code', )
|
||||
readonly_fields = ('id', 'deleted_at', 'created_at', 'updated_at')
|
||||
|
||||
fieldsets = (
|
||||
('Discount Code Details', {
|
||||
'fields': ('code', 'type', 'value', 'applicable_events', 'is_active')
|
||||
}),
|
||||
('Limitations', {
|
||||
'fields': ('starts_at', 'ends_at', 'usage_limit_total', 'usage_limit_per_user', 'min_amount')
|
||||
}),
|
||||
('Soft Delete', {
|
||||
'fields': ('is_deleted', 'deleted_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
readonly_fields = ('deleted_at', )
|
||||
|
||||
actions = BaseModelAdmin.actions + [
|
||||
'deactivate_codes',
|
||||
]
|
||||
|
||||
@admin.action(description="Deactivate selected discount codes")
|
||||
def deactivate_codes(self, request, queryset):
|
||||
queryset.update(is_active=False)
|
||||
self.message_user(request, f"Deactivate {queryset.count()} discount codes.")
|
||||
|
||||
@admin.register(Payment)
|
||||
class PaymentAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||
resource_class = PaymentResource
|
||||
|
||||
list_display = (
|
||||
'id', 'user', 'event', 'base_amount', 'discount_code', 'discount_amount', 'amount',
|
||||
'status', 'created_at', 'verified_at', 'is_deleted'
|
||||
)
|
||||
list_filter = (
|
||||
'status', 'event',
|
||||
SoftDeleteListFilter,
|
||||
)
|
||||
search_fields = (
|
||||
'user__email', 'authority', 'ref_id', 'discount_code__code'
|
||||
)
|
||||
readonly_fields = (
|
||||
'user', 'event', 'base_amount', 'discount_code', 'discount_code', 'discount_amount', 'amount', 'authority',
|
||||
'status', 'ref_id', 'card_pan', 'card_hash', 'created_at', 'updated_at', 'deleted_at'
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Payment Details', {
|
||||
'fields': ('user', 'event', 'status', 'created_at', 'updated_at')
|
||||
}),
|
||||
('Price Info', {
|
||||
'fields': ('base_amount', 'discount_code', 'discount_amount', 'amount')
|
||||
}),
|
||||
('Others', {
|
||||
'fields': ('authority', 'ref_id', 'card_pan', 'card_hash')
|
||||
}),
|
||||
('Soft Delete', {
|
||||
'fields': ('is_deleted', 'deleted_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
0
apps/payments/api/__init__.py
Normal file
0
apps/payments/api/__init__.py
Normal file
35
apps/payments/api/schemas.py
Normal file
35
apps/payments/api/schemas.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class CreatePaymentIn(Schema):
|
||||
event_id: int
|
||||
description: str
|
||||
discount_code: str | None = None
|
||||
mobile: str | None = None
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class CreatePaymentOut(Schema):
|
||||
start_pay_url: str | None = None
|
||||
authority: str | None = None
|
||||
base_amount: int
|
||||
discount_amount: int
|
||||
amount: int
|
||||
|
||||
class PaymentDetailOut(Schema):
|
||||
ref_id: str | None = None
|
||||
authority: str | None = None
|
||||
base_amount: int
|
||||
discount_amount: int
|
||||
amount: int
|
||||
status: str
|
||||
verified_at: str | None = None
|
||||
event: dict
|
||||
|
||||
class CouponVerifyIn(Schema):
|
||||
event_id: int
|
||||
code: str
|
||||
|
||||
class CouponVerifyOut(Schema):
|
||||
discount_amount: int
|
||||
final_price: int
|
||||
240
apps/payments/api/views.py
Normal file
240
apps/payments/api/views.py
Normal file
@@ -0,0 +1,240 @@
|
||||
from django.conf import settings
|
||||
from django.shortcuts import redirect, get_object_or_404
|
||||
from django.utils import timezone
|
||||
|
||||
from ninja import Router
|
||||
from ninja.errors import HttpError
|
||||
import requests
|
||||
|
||||
from apps.payments.models import Payment, DiscountCode
|
||||
from apps.events.models import Event, Registration
|
||||
from core.authentication import jwt_auth
|
||||
from apps.payments.api.schemas import CouponVerifyIn, CouponVerifyOut, CreatePaymentIn, CreatePaymentOut, PaymentDetailOut
|
||||
|
||||
payments_router = Router(tags=["Payments"])
|
||||
|
||||
|
||||
@payments_router.post("create", response=CreatePaymentOut, auth=jwt_auth)
|
||||
def create_payment(request, payload: CreatePaymentIn):
|
||||
event = get_object_or_404(Event, pk=payload.event_id)
|
||||
|
||||
if Payment.objects.filter(status=Payment.OrderStatusChoices.PAID, user=request.auth, event=event).exists():
|
||||
raise HttpError(400, "You have already registered in this event")
|
||||
|
||||
registration = (
|
||||
Registration.objects.filter(event=event, user=request.auth, is_deleted=False)
|
||||
.order_by("-registered_at")
|
||||
.first()
|
||||
)
|
||||
if not registration or registration.status == Registration.StatusChoices.CANCELLED:
|
||||
registration = Registration.objects.create(
|
||||
event=event,
|
||||
user=request.auth,
|
||||
status=Registration.StatusChoices.PENDING,
|
||||
final_price=event.price,
|
||||
)
|
||||
elif registration.final_price is None:
|
||||
registration.final_price = event.price
|
||||
registration.save(update_fields=["final_price"])
|
||||
|
||||
discount_code = None
|
||||
discount_amount = 0
|
||||
final_amount = event.price
|
||||
|
||||
if payload.discount_code:
|
||||
discount_code = DiscountCode.objects.filter(code=payload.discount_code, applicable_events=event, is_active=True).first()
|
||||
|
||||
if discount_code:
|
||||
final_amount, discount_amount = discount_code.calculate_discount(event, request.auth)
|
||||
|
||||
registration_updates = []
|
||||
if discount_code and registration.discount_code_id != discount_code.id:
|
||||
registration.discount_code = discount_code
|
||||
registration_updates.append("discount_code")
|
||||
if registration.discount_amount != discount_amount:
|
||||
registration.discount_amount = discount_amount
|
||||
registration_updates.append("discount_amount")
|
||||
if registration.final_price != final_amount:
|
||||
registration.final_price = final_amount
|
||||
registration_updates.append("final_price")
|
||||
|
||||
if final_amount == 0:
|
||||
if registration.status != Registration.StatusChoices.CONFIRMED:
|
||||
registration.status = Registration.StatusChoices.CONFIRMED
|
||||
registration_updates.append("status")
|
||||
if registration_updates:
|
||||
registration.save(update_fields=list(set(registration_updates)))
|
||||
else:
|
||||
registration.save(update_fields=["status"])
|
||||
|
||||
return {
|
||||
"start_pay_url": None,
|
||||
"authority": None,
|
||||
"base_amount": event.price,
|
||||
"discount_amount": discount_amount if discount_amount else 0,
|
||||
"amount": 0,
|
||||
}
|
||||
|
||||
if registration_updates:
|
||||
registration.save(update_fields=list(set(registration_updates)))
|
||||
|
||||
pay = Payment.objects.create(
|
||||
user=request.auth,
|
||||
event=event,
|
||||
base_amount=event.price,
|
||||
discount_code=discount_code,
|
||||
discount_amount=discount_amount,
|
||||
amount=final_amount,
|
||||
status=Payment.OrderStatusChoices.INIT,
|
||||
registration=registration,
|
||||
)
|
||||
|
||||
callback_url = getattr(settings, "ZARINPAL_CALLBACK_URL", "http://localhost:8000/api/payments/callback")
|
||||
body = {
|
||||
"merchant_id": settings.ZARINPAL_MERCHANT_ID,
|
||||
"amount": final_amount,
|
||||
"callback_url": callback_url,
|
||||
"description": payload.description,
|
||||
"metadata": {
|
||||
k: v for k, v in {
|
||||
"mobile": payload.mobile,
|
||||
"email": payload.email,
|
||||
"event_id": event.id,
|
||||
"user_id": request.auth.id,
|
||||
"payment_id": pay.id,
|
||||
"discount_code": discount_code.code if discount_code else None,
|
||||
}.items() if v
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
settings.ZARINPAL_REQUEST_URL,
|
||||
json=body,
|
||||
headers={"accept":"application/json","content-type":"application/json"},
|
||||
timeout=15
|
||||
)
|
||||
jd = response.json()
|
||||
except Exception as e:
|
||||
pay.delete()
|
||||
raise HttpError(502, f"Gateway request failed: {e}")
|
||||
|
||||
code = (jd.get("data") or {}).get("code")
|
||||
if code != 100:
|
||||
pay.delete()
|
||||
raise HttpError(502, f"Zarinpal error: {jd.get('errors') or jd}")
|
||||
|
||||
authority = jd["data"]["authority"]
|
||||
pay.authority = authority
|
||||
pay.status = Payment.OrderStatusChoices.PENDING
|
||||
pay.save(update_fields=["authority","status"])
|
||||
|
||||
return {
|
||||
"start_pay_url": f"{settings.ZARINPAL_STARTPAY}{authority}",
|
||||
"authority": authority,
|
||||
"base_amount": event.price,
|
||||
"discount_amount": discount_amount if discount_amount else 0,
|
||||
"amount": final_amount,
|
||||
}
|
||||
|
||||
@payments_router.get("callback")
|
||||
def callback(request, Authority: str | None = None, Status: str | None = None):
|
||||
if not Authority:
|
||||
raise HttpError(400, "Missing Authority")
|
||||
|
||||
pay = Payment.objects.filter(authority=Authority).select_related("event","user","discount_code").first()
|
||||
if not pay:
|
||||
raise HttpError(404, "Payment not found")
|
||||
|
||||
if Status != "OK":
|
||||
pay.status = Payment.OrderStatusChoices.CANCELED
|
||||
pay.save(update_fields=["status"])
|
||||
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
|
||||
|
||||
verify_body = {
|
||||
"merchant_id": settings.ZARINPAL_MERCHANT_ID,
|
||||
"amount": pay.amount,
|
||||
"authority": Authority,
|
||||
}
|
||||
|
||||
try:
|
||||
vresp = requests.post(
|
||||
settings.ZARINPAL_VERIFY_URL,
|
||||
json=verify_body,
|
||||
headers={"accept":"application/json","content-type":"application/json"},
|
||||
timeout=15
|
||||
)
|
||||
vjd = vresp.json()
|
||||
except Exception:
|
||||
pay.status = Payment.OrderStatusChoices.FAILED
|
||||
pay.save(update_fields=["status"])
|
||||
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
|
||||
|
||||
vcode = (vjd.get("data") or {}).get("code")
|
||||
if vcode in (100, 101):
|
||||
data = vjd.get("data") or {}
|
||||
pay.status = Payment.OrderStatusChoices.PAID
|
||||
pay.ref_id = data.get("ref_id")
|
||||
pay.card_pan = data.get("card_pan")
|
||||
pay.card_hash = data.get("card_hash")
|
||||
pay.verified_at = timezone.now()
|
||||
pay.save(update_fields=["status", "ref_id", "card_pan", "card_hash", "verified_at"])
|
||||
|
||||
registration = pay.registration or Registration.objects.filter(
|
||||
user=pay.user,
|
||||
event=pay.event,
|
||||
status=Registration.StatusChoices.PENDING,
|
||||
).first()
|
||||
if registration:
|
||||
registration.status = Registration.StatusChoices.CONFIRMED
|
||||
updates = ["status"]
|
||||
if registration.final_price is None:
|
||||
registration.final_price = pay.amount
|
||||
updates.append("final_price")
|
||||
registration.save(update_fields=updates)
|
||||
|
||||
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=success&event_id={pay.event_id}&ref_id={pay.ref_id}")
|
||||
|
||||
pay.status = Payment.OrderStatusChoices.FAILED
|
||||
pay.save(update_fields=["status"])
|
||||
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
|
||||
|
||||
@payments_router.get("by-ref/{ref_id}", response=PaymentDetailOut)
|
||||
def payment_by_ref(request, ref_id: str):
|
||||
pay = get_object_or_404(Payment.objects.select_related("event"), ref_id=ref_id)
|
||||
ev = pay.event
|
||||
return {
|
||||
"ref_id": pay.ref_id,
|
||||
"authority": pay.authority,
|
||||
"base_amount": pay.base_amount,
|
||||
"discount_amount": pay.discount_amount or 0,
|
||||
"amount": pay.amount,
|
||||
"status": pay.get_status_display(),
|
||||
"verified_at": pay.verified_at.isoformat() if pay.verified_at else None,
|
||||
"event": {
|
||||
"id": ev.id,
|
||||
"title": ev.title,
|
||||
"slug": ev.slug,
|
||||
"image_url": request.build_absolute_uri(ev.featured_image.url) if ev.featured_image else None,
|
||||
"success_markdown": ev.registration_success_markdown,
|
||||
},
|
||||
}
|
||||
|
||||
@payments_router.post("/coupon/check", response=CouponVerifyOut, auth=jwt_auth)
|
||||
def check_coupon(request, payload: CouponVerifyIn):
|
||||
event = get_object_or_404(Event, id=payload.event_id)
|
||||
code = payload.code
|
||||
|
||||
if not code:
|
||||
raise HttpError(404, "لطفا کد تخفیف را وارد کنید")
|
||||
|
||||
try:
|
||||
c = DiscountCode.objects.get(code=code, applicable_events=event, is_active=True)
|
||||
final_price, disc = c.calculate_discount(event, request.auth)
|
||||
return {
|
||||
"discount_amount": disc,
|
||||
"final_price": final_price,
|
||||
}
|
||||
|
||||
except DiscountCode.DoesNotExist:
|
||||
raise HttpError(404, "کد تخفیف معتبر نیست")
|
||||
6
apps/payments/apps.py
Normal file
6
apps/payments/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PaymentsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.payments"
|
||||
64
apps/payments/migrations/0001_initial.py
Normal file
64
apps/payments/migrations/0001_initial.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# Generated by Django 5.2.5 on 2025-10-16 12:07
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('events', '0002_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DiscountCode',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('code', models.CharField(max_length=64, unique=True)),
|
||||
('type', models.CharField(choices=[('percent', 'Percent'), ('fixed', 'Fixed (IRR)')], default='percent', max_length=10)),
|
||||
('value', models.PositiveIntegerField()),
|
||||
('max_discount', models.PositiveIntegerField(blank=True, null=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('starts_at', models.DateTimeField(blank=True, null=True)),
|
||||
('ends_at', models.DateTimeField(blank=True, null=True)),
|
||||
('usage_limit_total', models.PositiveIntegerField(blank=True, null=True)),
|
||||
('usage_limit_per_user', models.PositiveIntegerField(blank=True, null=True)),
|
||||
('min_amount', models.PositiveIntegerField(blank=True, null=True)),
|
||||
('applicable_events', models.ManyToManyField(blank=True, related_name='discount_codes', to='events.event')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Payment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('base_amount', models.PositiveIntegerField(editable=False)),
|
||||
('discount_amount', models.PositiveIntegerField(default=0, editable=False)),
|
||||
('amount', models.PositiveIntegerField(editable=False)),
|
||||
('authority', models.CharField(blank=True, editable=False, max_length=64, null=True, unique=True)),
|
||||
('status', models.IntegerField(choices=[(0, 'Initiated'), (1, 'Pending'), (2, 'Paid'), (3, 'Failed'), (4, 'Canceled')], default=0, editable=False)),
|
||||
('ref_id', models.CharField(blank=True, editable=False, max_length=64, null=True)),
|
||||
('card_pan', models.CharField(blank=True, editable=False, max_length=32, null=True)),
|
||||
('card_hash', models.CharField(blank=True, editable=False, max_length=128, null=True)),
|
||||
('verified_at', models.DateTimeField(blank=True, editable=False, null=True)),
|
||||
('discount_code', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='payments.discountcode')),
|
||||
('event', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='events.event')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
23
apps/payments/migrations/0002_initial.py
Normal file
23
apps/payments/migrations/0002_initial.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.5 on 2025-10-16 12:07
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('payments', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='payment',
|
||||
name='user',
|
||||
field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='payments', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
20
apps/payments/migrations/0003_payment_registration.py
Normal file
20
apps/payments/migrations/0003_payment_registration.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 4.2.13 on 2025-11-17 13:15
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0009_registration_discount_amount_and_more'),
|
||||
('payments', '0002_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='payment',
|
||||
name='registration',
|
||||
field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='payments', to='events.registration'),
|
||||
),
|
||||
]
|
||||
0
apps/payments/migrations/__init__.py
Normal file
0
apps/payments/migrations/__init__.py
Normal file
122
apps/payments/models.py
Normal file
122
apps/payments/models.py
Normal file
@@ -0,0 +1,122 @@
|
||||
from django.db import models
|
||||
from django.db.models import Q, Count
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from core.models import BaseModel
|
||||
from apps.events.models import Event
|
||||
|
||||
from ninja.errors import HttpError
|
||||
|
||||
User = settings.AUTH_USER_MODEL
|
||||
|
||||
|
||||
class DiscountCode(BaseModel):
|
||||
class Type(models.TextChoices):
|
||||
PERCENT = "percent", "Percent"
|
||||
FIXED = "fixed", "Fixed (IRR)"
|
||||
|
||||
code = models.CharField(max_length=64, unique=True)
|
||||
type = models.CharField(max_length=10, choices=Type.choices, default=Type.PERCENT)
|
||||
value = models.PositiveIntegerField()
|
||||
max_discount = models.PositiveIntegerField(null=True, blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
starts_at = models.DateTimeField(null=True, blank=True)
|
||||
ends_at = models.DateTimeField(null=True, blank=True)
|
||||
usage_limit_total = models.PositiveIntegerField(null=True, blank=True)
|
||||
usage_limit_per_user = models.PositiveIntegerField(null=True, blank=True)
|
||||
min_amount = models.PositiveIntegerField(null=True, blank=True)
|
||||
applicable_events = models.ManyToManyField(Event, blank=True, related_name="discount_codes")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.code} ({self.get_type_display()} {self.value})"
|
||||
|
||||
def calculate_discount(self, event: Event, user: User):
|
||||
if not event.price:
|
||||
return (0, 0)
|
||||
|
||||
if not self.is_active:
|
||||
raise HttpError(400, "کد تخفیف نامعتبر یا غیرفعال است.")
|
||||
|
||||
n = timezone.now()
|
||||
if self.starts_at and n < self.starts_at:
|
||||
raise HttpError(400, "کد تخفیف هنوز فعال نشده است.")
|
||||
if self.ends_at and n > self.ends_at:
|
||||
raise HttpError(400, "کد تخفیف منقضی شده است.")
|
||||
|
||||
if self.applicable_events.exists() and not self.applicable_events.filter(pk=event.pk).exists():
|
||||
raise HttpError(400, "کد تخفیف برای این رویداد قابل استفاده نیست.")
|
||||
|
||||
if self.min_amount and event.price < self.min_amount:
|
||||
raise HttpError(400, "مبلغ سفارش کمتر از حداقل لازم برای این کد است.")
|
||||
|
||||
used_qs = Payment.objects.filter(discount_code=self, status__in=[Payment.OrderStatusChoices.PAID, Payment.OrderStatusChoices.PENDING])
|
||||
if self.usage_limit_total is not None and used_qs.count() >= self.usage_limit_total:
|
||||
raise HttpError(400, "حداکثر تعداد استفاده از این کد تخفیف تکمیل شده است.")
|
||||
|
||||
used_by_user = used_qs.filter(user=user).count()
|
||||
if self.usage_limit_per_user is not None and used_by_user >= self.usage_limit_per_user:
|
||||
raise HttpError(400, "شما حداکثر تعداد مجاز استفاده از این کد تخفیف را مصرف کردهاید.")
|
||||
|
||||
if self.type == DiscountCode.Type.FIXED:
|
||||
disc = min(self.value, event.price)
|
||||
else:
|
||||
disc = (event.price * self.value) // 100
|
||||
if self.max_discount:
|
||||
disc = min(disc, self.max_discount)
|
||||
|
||||
final_amount = max(event.price - disc, 0)
|
||||
if 0 < final_amount < 10_000:
|
||||
raise HttpError(400, "با این تخفیف مبلغ قابل پرداخت به کمتر از ۱۰٬۰۰۰ ریال میرسد.")
|
||||
|
||||
return (final_amount, disc)
|
||||
|
||||
|
||||
class Payment(BaseModel):
|
||||
class OrderStatusChoices(models.IntegerChoices):
|
||||
INIT = 0, "Initiated"
|
||||
PENDING = 1, "Pending"
|
||||
PAID = 2, "Paid"
|
||||
FAILED = 3, "Failed"
|
||||
CANCELED = 4, "Canceled"
|
||||
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='payments', editable=False)
|
||||
event = models.ForeignKey(Event, on_delete=models.PROTECT, related_name='payments', editable=False)
|
||||
|
||||
base_amount = models.PositiveIntegerField(editable=False)
|
||||
discount_code = models.ForeignKey(DiscountCode, on_delete=models.PROTECT, null=True, blank=True, editable=False, related_name="payments")
|
||||
discount_amount = models.PositiveIntegerField(default=0, editable=False)
|
||||
amount = models.PositiveIntegerField(editable=False)
|
||||
|
||||
registration = models.ForeignKey(
|
||||
"events.Registration",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="payments",
|
||||
editable=False,
|
||||
)
|
||||
authority = models.CharField(max_length=64, unique=True, null=True, blank=True, editable=False)
|
||||
status = models.IntegerField(choices=OrderStatusChoices.choices, default=OrderStatusChoices.INIT, editable=False)
|
||||
ref_id = models.CharField(max_length=64, null=True, blank=True, editable=False)
|
||||
card_pan = models.CharField(max_length=32, null=True, blank=True, editable=False)
|
||||
card_hash = models.CharField(max_length=128, null=True, blank=True, editable=False)
|
||||
verified_at = models.DateTimeField(null=True, blank=True, editable=False)
|
||||
|
||||
def clean(self):
|
||||
if self.discount_amount and self.amount + self.discount_amount != self.base_amount:
|
||||
raise ValidationError({"amount": "amount + discount_amount must equal base_amount"})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.full_clean()
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def status_label(self):
|
||||
"""Human-readable label for the payment status."""
|
||||
return self.get_status_display()
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email}:{self.event} - {self.get_status_display()}"
|
||||
|
||||
44
apps/payments/resources.py
Normal file
44
apps/payments/resources.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from import_export import resources, fields
|
||||
from import_export.widgets import ForeignKeyWidget, ManyToManyWidget
|
||||
|
||||
from apps.payments.models import Payment, DiscountCode
|
||||
from apps.events.models import Event
|
||||
from apps.users.models import User
|
||||
|
||||
class DiscountResource(resources.ModelResource):
|
||||
event = fields.Field(
|
||||
column_name='applicable_events',
|
||||
attribute='applicable_events',
|
||||
widget=ManyToManyWidget(Event, field='title', separator='||')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = (
|
||||
'id', 'code', 'type', 'value', 'max_discount', 'is_active',
|
||||
'starts_at', 'ends_at', 'usage_limit_total', 'usage_limit_per_user',
|
||||
'min_amount', 'applicable_events', 'created_at', 'updated_at',
|
||||
'is_deleted', 'deleted_at'
|
||||
)
|
||||
export_order = fields
|
||||
|
||||
class PaymentResource(resources.ModelResource):
|
||||
event = fields.Field(
|
||||
column_name='event',
|
||||
attribute='event',
|
||||
widget=ForeignKeyWidget(Event, 'title')
|
||||
)
|
||||
user = fields.Field(
|
||||
column_name='user',
|
||||
attribute='user',
|
||||
widget=ForeignKeyWidget(User, 'username')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Payment
|
||||
fields = (
|
||||
'id', 'event', 'user', 'base_amount', 'discount_code', 'discount_amount', 'amount',
|
||||
'authority', 'status', 'red_id', 'card_pan', 'card_hash', 'verified_at', 'created_at',
|
||||
'updated_at', 'is_deleted', 'deleted_at'
|
||||
)
|
||||
export_order = fields
|
||||
0
apps/payments/tests/__init__.py
Normal file
0
apps/payments/tests/__init__.py
Normal file
0
apps/payments/tests/integration/__init__.py
Normal file
0
apps/payments/tests/integration/__init__.py
Normal file
282
apps/payments/tests/integration/test_payments.py
Normal file
282
apps/payments/tests/integration/test_payments.py
Normal file
@@ -0,0 +1,282 @@
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from core.authentication import create_jwt_token
|
||||
from apps.events.models import Event, Registration
|
||||
from apps.payments.models import Payment, DiscountCode
|
||||
from apps.users.models import User
|
||||
|
||||
|
||||
@override_settings(
|
||||
ZARINPAL_MERCHANT_ID="MID",
|
||||
ZARINPAL_REQUEST_URL="https://zarinpal/request",
|
||||
ZARINPAL_STARTPAY="https://zarinpal/start/",
|
||||
ZARINPAL_VERIFY_URL="https://zarinpal/verify",
|
||||
ZARINPAL_CALLBACK_URL="https://frontend/callback",
|
||||
)
|
||||
class PaymentsAPIIntegrationTests(TestCase):
|
||||
password = "PaymentPass!123"
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = User.objects.create_user(
|
||||
username="pay_user",
|
||||
email="pay.user@example.com",
|
||||
password=cls.password,
|
||||
)
|
||||
cls.user.is_email_verified = True
|
||||
cls.user.save(update_fields=["is_email_verified"])
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.event = Event.objects.create(
|
||||
title="Pay Event",
|
||||
description="Payment event",
|
||||
start_time=timezone.now(),
|
||||
end_time=timezone.now() + timedelta(hours=2),
|
||||
registration_start_date=timezone.now() - timedelta(days=1),
|
||||
registration_end_date=timezone.now() + timedelta(days=1),
|
||||
slug="pay-event",
|
||||
price=50000,
|
||||
capacity=10,
|
||||
status=Event.StatusChoices.PUBLISHED,
|
||||
)
|
||||
self.token = create_jwt_token(self.user)
|
||||
|
||||
def _headers(self):
|
||||
return {"HTTP_AUTHORIZATION": f"Bearer {self.token}"}
|
||||
|
||||
def _create_paid_event(self):
|
||||
return Event.objects.create(
|
||||
title="Paid Event",
|
||||
description="Paid",
|
||||
start_time=timezone.now(),
|
||||
end_time=timezone.now() + timedelta(hours=1),
|
||||
registration_start_date=timezone.now() - timedelta(days=1),
|
||||
registration_end_date=timezone.now() + timedelta(days=2),
|
||||
slug=f"paid-{timezone.now().timestamp()}",
|
||||
price=20000,
|
||||
capacity=5,
|
||||
status=Event.StatusChoices.PUBLISHED,
|
||||
)
|
||||
|
||||
def _create_discount_code(self, event):
|
||||
code = DiscountCode.objects.create(
|
||||
code="DISC50",
|
||||
value=50,
|
||||
is_active=True,
|
||||
)
|
||||
code.applicable_events.add(event)
|
||||
return code
|
||||
|
||||
def test_create_payment_for_free_event(self):
|
||||
free = Event.objects.create(
|
||||
title="Free",
|
||||
description="Zero",
|
||||
start_time=timezone.now(),
|
||||
end_time=timezone.now() + timedelta(hours=1),
|
||||
registration_start_date=timezone.now() - timedelta(days=1),
|
||||
registration_end_date=timezone.now() + timedelta(days=1),
|
||||
slug="free-event",
|
||||
price=0,
|
||||
capacity=10,
|
||||
status=Event.StatusChoices.PUBLISHED,
|
||||
)
|
||||
response = self.client.post(
|
||||
"/api/payments/create",
|
||||
data=json.dumps(
|
||||
{
|
||||
"event_id": free.id,
|
||||
"description": "Free registration",
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
**self._headers(),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(data["amount"], 0)
|
||||
self.assertIsNone(data["start_pay_url"])
|
||||
|
||||
@mock.patch("apps.payments.api.views.requests.post")
|
||||
def test_create_payment_with_discount(self, mock_post):
|
||||
mock_response = mock.Mock()
|
||||
mock_response.json.return_value = {"data": {"code": 100, "authority": "AUTH"}}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
code = self._create_discount_code(self.event)
|
||||
response = self.client.post(
|
||||
"/api/payments/create",
|
||||
data=json.dumps(
|
||||
{
|
||||
"event_id": self.event.id,
|
||||
"description": "Pay with discount",
|
||||
"discount_code": code.code,
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
**self._headers(),
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["discount_amount"], self.event.price // 2)
|
||||
self.assertEqual(payload["amount"], self.event.price // 2)
|
||||
self.assertIn("start_pay_url", payload)
|
||||
payment = Payment.objects.get(user=self.user, event=self.event)
|
||||
self.assertEqual(payment.discount_code, code)
|
||||
|
||||
@mock.patch("apps.payments.api.views.requests.post")
|
||||
def test_callback_success_marks_paid(self, mock_post):
|
||||
payment = Payment.objects.create(
|
||||
user=self.user,
|
||||
event=self.event,
|
||||
base_amount=self.event.price,
|
||||
amount=self.event.price,
|
||||
status=Payment.OrderStatusChoices.PENDING,
|
||||
authority="AUTH123",
|
||||
)
|
||||
mock_resp = mock.Mock()
|
||||
mock_resp.json.return_value = {"data": {"code": 100, "ref_id": "REF", "card_pan": "123", "card_hash": "ABC"}}
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
response = self.client.get(
|
||||
"/api/payments/callback",
|
||||
{"Authority": "AUTH123", "Status": "OK"},
|
||||
)
|
||||
payment.refresh_from_db()
|
||||
self.assertEqual(payment.status, Payment.OrderStatusChoices.PAID)
|
||||
self.assertTrue("status=success" in response.url)
|
||||
|
||||
@mock.patch("apps.payments.api.views.requests.post")
|
||||
def test_callback_failure_redirects_failed(self, mock_post):
|
||||
payment = Payment.objects.create(
|
||||
user=self.user,
|
||||
event=self.event,
|
||||
base_amount=self.event.price,
|
||||
amount=self.event.price,
|
||||
status=Payment.OrderStatusChoices.PENDING,
|
||||
authority="AUTH456",
|
||||
)
|
||||
mock_resp = mock.Mock()
|
||||
mock_resp.json.return_value = {"data": {"code": 101, "ref_id": "REF"}}
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
response = self.client.get(
|
||||
"/api/payments/callback",
|
||||
{"Authority": "AUTH456", "Status": "OK"},
|
||||
)
|
||||
|
||||
payment.refresh_from_db()
|
||||
self.assertEqual(payment.status, Payment.OrderStatusChoices.PAID)
|
||||
self.assertTrue("status=success" in response.url)
|
||||
|
||||
def test_callback_missing_authority_returns_error(self):
|
||||
response = self.client.get("/api/payments/callback", {"Status": "OK"})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_callback_not_ok_cancels(self):
|
||||
payment = Payment.objects.create(
|
||||
user=self.user,
|
||||
event=self.event,
|
||||
base_amount=self.event.price,
|
||||
amount=self.event.price,
|
||||
status=Payment.OrderStatusChoices.PENDING,
|
||||
authority="AUTH789",
|
||||
)
|
||||
response = self.client.get(
|
||||
"/api/payments/callback",
|
||||
{"Authority": "AUTH789", "Status": "NOK"},
|
||||
)
|
||||
payment.refresh_from_db()
|
||||
self.assertEqual(payment.status, Payment.OrderStatusChoices.CANCELED)
|
||||
self.assertIn("status=failed", response.url)
|
||||
|
||||
@mock.patch("apps.payments.api.views.requests.post", side_effect=RuntimeError("down"))
|
||||
def test_create_payment_gateway_failure(self, mock_post):
|
||||
response = self.client.post(
|
||||
"/api/payments/create",
|
||||
data=json.dumps(
|
||||
{
|
||||
"event_id": self.event.id,
|
||||
"description": "Gateway fail",
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
**self._headers(),
|
||||
)
|
||||
self.assertEqual(response.status_code, 502)
|
||||
self.assertFalse(Payment.objects.filter(user=self.user).exists())
|
||||
|
||||
def test_create_payment_when_already_paid(self):
|
||||
Payment.objects.create(
|
||||
user=self.user,
|
||||
event=self.event,
|
||||
base_amount=self.event.price,
|
||||
amount=self.event.price,
|
||||
status=Payment.OrderStatusChoices.PAID,
|
||||
)
|
||||
response = self.client.post(
|
||||
"/api/payments/create",
|
||||
data=json.dumps({"event_id": self.event.id, "description": "Duplicate"}),
|
||||
content_type="application/json",
|
||||
**self._headers(),
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@mock.patch("apps.payments.api.views.requests.post")
|
||||
def test_registration_final_price_none_updates(self, mock_post):
|
||||
registration = Registration.objects.create(
|
||||
event=self.event,
|
||||
user=self.user,
|
||||
status=Registration.StatusChoices.PENDING,
|
||||
final_price=None,
|
||||
)
|
||||
mock_response = mock.Mock()
|
||||
mock_response.json.return_value = {"data": {"code": 100, "authority": "AUTH"}}
|
||||
mock_post.return_value = mock_response
|
||||
response = self.client.post(
|
||||
"/api/payments/create",
|
||||
data=json.dumps({"event_id": self.event.id, "description": "Update"}),
|
||||
content_type="application/json",
|
||||
**self._headers(),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
registration.refresh_from_db()
|
||||
if registration.final_price is None:
|
||||
self.fail("final_price should be populated")
|
||||
|
||||
def test_coupon_check_success_and_errors(self):
|
||||
code = DiscountCode.objects.create(code="PAYCO", value=20, is_active=True, type=DiscountCode.Type.PERCENT)
|
||||
code.applicable_events.add(self.event)
|
||||
|
||||
# missing code
|
||||
missing = self.client.post(
|
||||
"/api/payments/coupon/check",
|
||||
data=json.dumps({"event_id": self.event.id}),
|
||||
content_type="application/json",
|
||||
**self._headers(),
|
||||
)
|
||||
self.assertEqual(missing.status_code, 422)
|
||||
|
||||
# invalid code
|
||||
invalid = self.client.post(
|
||||
"/api/payments/coupon/check",
|
||||
data=json.dumps({"event_id": self.event.id, "code": "INVALID"}),
|
||||
content_type="application/json",
|
||||
**self._headers(),
|
||||
)
|
||||
self.assertEqual(invalid.status_code, 404)
|
||||
|
||||
success = self.client.post(
|
||||
"/api/payments/coupon/check",
|
||||
data=json.dumps({"event_id": self.event.id, "code": code.code}),
|
||||
content_type="application/json",
|
||||
**self._headers(),
|
||||
)
|
||||
self.assertEqual(success.status_code, 200)
|
||||
self.assertIn("final_price", success.json())
|
||||
0
apps/payments/tests/unit/__init__.py
Normal file
0
apps/payments/tests/unit/__init__.py
Normal file
194
apps/payments/tests/unit/test_payments.py
Normal file
194
apps/payments/tests/unit/test_payments.py
Normal file
@@ -0,0 +1,194 @@
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from django.contrib.admin import AdminSite
|
||||
from apps.payments.admin import DiscountCodeAdmin
|
||||
from apps.payments.models import DiscountCode, Payment
|
||||
from apps.payments.resources import DiscountResource, PaymentResource
|
||||
from apps.events.models import Event
|
||||
from apps.users.models import User
|
||||
from ninja.errors import HttpError
|
||||
|
||||
|
||||
class PaymentTestMixin:
|
||||
@staticmethod
|
||||
def _create_user(**overrides):
|
||||
data = {
|
||||
"username": f"user_{uuid.uuid4().hex[:6]}",
|
||||
"email": f"user_{uuid.uuid4().hex[:6]}@example.com",
|
||||
"password": "Test!1234",
|
||||
}
|
||||
data.update(overrides)
|
||||
return User.objects.create_user(**data)
|
||||
|
||||
@staticmethod
|
||||
def _create_event(**overrides):
|
||||
now = timezone.now()
|
||||
defaults = {
|
||||
"title": "Sample",
|
||||
"description": "Desc",
|
||||
"start_time": now,
|
||||
"end_time": now + timedelta(hours=2),
|
||||
"registration_start_date": now - timedelta(days=1),
|
||||
"registration_end_date": now + timedelta(days=5),
|
||||
"slug": f"event-{uuid.uuid4().hex[:6]}",
|
||||
"price": 100000,
|
||||
"capacity": 10,
|
||||
"status": Event.StatusChoices.PUBLISHED,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return Event.objects.create(**defaults)
|
||||
|
||||
@staticmethod
|
||||
def _discount_code(**overrides):
|
||||
defaults = {
|
||||
"code": f"CODE{uuid.uuid4().hex[:4]}",
|
||||
"value": 50,
|
||||
"is_active": True,
|
||||
"type": DiscountCode.Type.PERCENT,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return DiscountCode.objects.create(**defaults)
|
||||
|
||||
|
||||
class DiscountCodeModelTests(TestCase, PaymentTestMixin):
|
||||
def setUp(self):
|
||||
self.event = self._create_event()
|
||||
self.user = self._create_user(is_email_verified=True)
|
||||
|
||||
def test_zero_price_returns_zero_discount(self):
|
||||
event = self._create_event(price=0)
|
||||
code = self._discount_code()
|
||||
code.applicable_events.add(event)
|
||||
self.assertEqual(code.calculate_discount(event, self.user), (0, 0))
|
||||
|
||||
def test_inactive_raises_error(self):
|
||||
code = self._discount_code(is_active=False)
|
||||
code.applicable_events.add(self.event)
|
||||
with self.assertRaises(HttpError):
|
||||
code.calculate_discount(self.event, self.user)
|
||||
|
||||
def test_start_date_validation(self):
|
||||
code = self._discount_code(starts_at=timezone.now() + timedelta(days=1))
|
||||
code.applicable_events.add(self.event)
|
||||
with self.assertRaises(HttpError):
|
||||
code.calculate_discount(self.event, self.user)
|
||||
|
||||
def test_end_date_validation(self):
|
||||
code = self._discount_code(ends_at=timezone.now() - timedelta(days=1))
|
||||
code.applicable_events.add(self.event)
|
||||
with self.assertRaises(HttpError):
|
||||
code.calculate_discount(self.event, self.user)
|
||||
|
||||
def test_applicable_events_enforcement(self):
|
||||
code = self._discount_code()
|
||||
other_event = self._create_event()
|
||||
code.applicable_events.add(other_event)
|
||||
with self.assertRaises(HttpError):
|
||||
code.calculate_discount(self.event, self.user)
|
||||
|
||||
def test_min_amount_guard(self):
|
||||
code = self._discount_code(min_amount=200000)
|
||||
code.applicable_events.add(self.event)
|
||||
with self.assertRaises(HttpError):
|
||||
code.calculate_discount(self.event, self.user)
|
||||
|
||||
def test_usage_limit_total(self):
|
||||
code = self._discount_code(usage_limit_total=1)
|
||||
code.applicable_events.add(self.event)
|
||||
Payment.objects.create(
|
||||
user=self.user,
|
||||
event=self.event,
|
||||
base_amount=self.event.price,
|
||||
amount=self.event.price,
|
||||
discount_amount=0,
|
||||
status=Payment.OrderStatusChoices.PAID,
|
||||
discount_code=code,
|
||||
)
|
||||
with self.assertRaises(HttpError):
|
||||
code.calculate_discount(self.event, self.user)
|
||||
|
||||
def test_usage_limit_per_user(self):
|
||||
code = self._discount_code(usage_limit_per_user=1)
|
||||
code.applicable_events.add(self.event)
|
||||
Payment.objects.create(
|
||||
user=self.user,
|
||||
event=self.event,
|
||||
base_amount=self.event.price,
|
||||
amount=self.event.price,
|
||||
discount_amount=0,
|
||||
status=Payment.OrderStatusChoices.PENDING,
|
||||
discount_code=code,
|
||||
)
|
||||
with self.assertRaises(HttpError):
|
||||
code.calculate_discount(self.event, self.user)
|
||||
|
||||
def test_final_price_below_min_post_discount(self):
|
||||
event = self._create_event(price=15000)
|
||||
code = self._discount_code(value=80)
|
||||
code.applicable_events.add(event)
|
||||
with self.assertRaises(HttpError):
|
||||
code.calculate_discount(event, self.user)
|
||||
|
||||
def test_fixed_discount_type(self):
|
||||
code = self._discount_code(type=DiscountCode.Type.FIXED, value=5000)
|
||||
code.applicable_events.add(self.event)
|
||||
final, disc = code.calculate_discount(self.event, self.user)
|
||||
self.assertEqual(disc, 5000)
|
||||
self.assertEqual(final, self.event.price - 5000)
|
||||
|
||||
|
||||
class PaymentModelAndResourceTests(TestCase, PaymentTestMixin):
|
||||
def setUp(self):
|
||||
self.event = self._create_event()
|
||||
self.user = self._create_user(is_email_verified=True)
|
||||
|
||||
def test_payment_clean_validates_amount(self):
|
||||
payment = Payment(
|
||||
user=self.user,
|
||||
event=self.event,
|
||||
base_amount=1000,
|
||||
amount=500,
|
||||
discount_amount=400,
|
||||
status=Payment.OrderStatusChoices.INIT,
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
payment.full_clean()
|
||||
|
||||
def test_payment_resource_defers_user_event(self):
|
||||
payment = Payment.objects.create(
|
||||
user=self.user,
|
||||
event=self.event,
|
||||
base_amount=1000,
|
||||
amount=1000,
|
||||
discount_amount=0,
|
||||
status=Payment.OrderStatusChoices.INIT,
|
||||
)
|
||||
resource = PaymentResource()
|
||||
user_cell = resource.fields["user"].widget.clean(self.user.username, None)
|
||||
self.assertEqual(user_cell, self.user)
|
||||
event_cell = resource.fields["event"].widget.clean(self.event.title, None)
|
||||
self.assertEqual(event_cell, self.event)
|
||||
|
||||
def test_discount_resource_expands_events(self):
|
||||
resource = DiscountResource()
|
||||
widget = resource.fields["event"].widget
|
||||
self.assertEqual(widget.separator, "||")
|
||||
|
||||
|
||||
class DiscountCodeAdminTests(TestCase, PaymentTestMixin):
|
||||
def setUp(self):
|
||||
self.admin = DiscountCodeAdmin(DiscountCode, AdminSite())
|
||||
|
||||
def test_deactivate_codes_action(self):
|
||||
code = self._discount_code()
|
||||
queryset = DiscountCode.objects.filter(pk=code.pk)
|
||||
request = SimpleNamespace(_messages=SimpleNamespace(add=lambda *args, **kwargs: None))
|
||||
self.admin.deactivate_codes(request, queryset)
|
||||
code.refresh_from_db()
|
||||
self.assertFalse(code.is_active)
|
||||
Reference in New Issue
Block a user