Files
shopship/apps/billing/admin.py
T
2026-09-04 13:05:54 +03:30

232 lines
9.7 KiB
Python

from django.contrib import admin, messages
from django.db import transaction as db_transaction
from django.shortcuts import redirect, render
from django.urls import path, reverse
from django.utils.html import format_html
from apps.account.models import User
from .api.services import GemWalletService
from .forms import GemGiftForm
from .models import BazaarPurchase, BazaarSettings, FeatureActivation, FeaturePlan, FeaturePlanTier, GemPack, GemTransaction
from .providers.bazaar import BazaarAuthError, build_authorize_url, exchange_code_for_tokens
@admin.register(BazaarSettings)
class BazaarSettingsAdmin(admin.ModelAdmin):
"""تک‌ردیفی — لیست را رد می‌کنیم و مستقیم فرم ویرایش همان یک ردیف را نشان می‌دهیم.
برای اتصال با روش OAuth (تمدید خودکار access_token): این سه فیلد را پر
کن — Client ID، Client Secret، application id اپ — ذخیره کن، بعد از
«وضعیت اتصال کافه‌بازار» پایین فرم روی «شروع اتصال» بزن. اگر Redirect URI
که موقع ساخت Client در پیشخان ثبت کرده‌ای با آدرسِ نشان‌داده‌شده اینجا فرق
دارد، بعد از تایید در کافه‌بازار، مقدار code= را از آدرسِ برگشتی کپی و
خودت به همان آدرس callback اضافه کن (?code=...) — نیازی به فیلد جداگانه نیست.
یا ساده‌تر: اگر همین حالا یک توکن ثابتِ «روش جدید» پیشخان داری، مستقیم در
Access Token بچسبان و Access Token Expires At را خالی بگذار — کارش تمام
است، فقط اگر روزی باطل شد باید دستی توکن تازه بگیری."""
list_display = ("package_name", "updated_at")
readonly_fields = ("oauth_status",)
fields = (
"package_name",
"access_token",
"access_token_expires_at",
"refresh_token",
"client_id",
"client_secret",
"dynamic_discount_secret",
"oauth_status",
)
def has_add_permission(self, request):
return not BazaarSettings.objects.exists()
def changelist_view(self, request, extra_context=None):
obj = BazaarSettings.get_solo()
return redirect("admin:billing_bazaarsettings_change", obj.pk)
def get_urls(self):
custom = [
path(
"oauth/connect/",
self.admin_site.admin_view(self.oauth_connect_view),
name="billing_bazaarsettings_oauth_connect",
),
path(
"oauth/callback/",
self.admin_site.admin_view(self.oauth_callback_view),
name="billing_bazaarsettings_oauth_callback",
),
]
return custom + super().get_urls()
def _callback_url(self, request) -> str:
return request.build_absolute_uri(reverse("admin:billing_bazaarsettings_oauth_callback"))
def oauth_connect_view(self, request):
obj = BazaarSettings.get_solo()
try:
authorize_url = build_authorize_url(self._callback_url(request))
except BazaarAuthError as exc:
messages.error(request, str(exc))
return redirect("admin:billing_bazaarsettings_change", obj.pk)
return redirect(authorize_url)
def oauth_callback_view(self, request):
obj = BazaarSettings.get_solo()
code = request.GET.get("code")
if not code:
messages.error(request, "پارامتر code در آدرس بازگشتی پیدا نشد.")
return redirect("admin:billing_bazaarsettings_change", obj.pk)
try:
exchange_code_for_tokens(code, self._callback_url(request))
except BazaarAuthError as exc:
messages.error(request, f"اتصال به کافه‌بازار ناموفق بود: {exc}")
else:
messages.success(request, "اتصال به کافه‌بازار موفق بود — access_token از این پس خودکار تمدید می‌شود.")
return redirect("admin:billing_bazaarsettings_change", obj.pk)
@admin.display(description="وضعیت اتصال کافه‌بازار")
def oauth_status(self, obj):
if not obj or not obj.pk:
return "ابتدا ذخیره کن."
callback_hint = reverse("admin:billing_bazaarsettings_oauth_callback")
connect_url = reverse("admin:billing_bazaarsettings_oauth_connect")
if obj.access_token_expires_at:
status_line = f"متصل (OAuth) — انقضای فعلی: {obj.access_token_expires_at:%Y-%m-%d %H:%M}"
elif obj.access_token:
status_line = "متصل با توکن ثابتِ روش جدید (بدون تمدید خودکار)."
else:
status_line = "هنوز متصل نیست."
return format_html(
"<p>{}</p>"
"<p>Redirect URI برای ثبت در پیشخان (اگر می‌خواهی OAuth را وصل کنی) — این مسیر را با "
"آدرس دامنه‌ی سرورت جلو ببر، مثلا https://api.example.com{} :</p>"
"<p><code>{}</code></p>"
'<p><a class="button" href="{}">شروع اتصال به کافه‌بازار (OAuth)</a></p>',
status_line,
callback_hint,
callback_hint,
connect_url,
)
class FeaturePlanTierInline(admin.TabularInline):
model = FeaturePlanTier
extra = 1
@admin.register(FeaturePlan)
class FeaturePlanAdmin(admin.ModelAdmin):
list_display = ("name", "key", "is_free", "is_purchasable")
list_editable = ("is_free",)
list_filter = ("is_free", "is_purchasable")
search_fields = ("name", "key")
inlines = [FeaturePlanTierInline]
@admin.register(GemPack)
class GemPackAdmin(admin.ModelAdmin):
list_display = ("display_name", "bazaar_sku", "gem_amount", "is_active", "sort_order")
list_editable = ("is_active", "sort_order")
search_fields = ("display_name", "bazaar_sku")
@admin.register(FeatureActivation)
class FeatureActivationAdmin(admin.ModelAdmin):
list_display = ("user", "feature", "expires_at", "updated_at")
list_filter = ("feature",)
search_fields = ("user__phone",)
autocomplete_fields = ["user"]
@admin.register(GemTransaction)
class GemTransactionAdmin(admin.ModelAdmin):
"""تاریخچه‌ی جم — فقط‌خواندنی، به‌علاوه‌ی ابزارِ «هدیه‌ی جم».
هدیه از دکمه‌ی بالای همین صفحه انجام می‌شود، نه با ساختنِ دستیِ ردیفِ
تراکنش: اگر ادمین ردیف را دستی بسازد، User.gem_balance به‌روز نمی‌شود و
موجودی با تاریخچه واگرا می‌شود. مسیرِ درست از GemWalletService رد
می‌شود که هر دو را با هم و داخلِ یک تراکنش تغییر می‌دهد.
"""
list_display = ("user", "amount", "reason", "created_at")
list_filter = ("reason",)
search_fields = ("user__phone", "note")
readonly_fields = ("user", "amount", "reason", "note", "created_at")
change_list_template = "admin/billing/gemtransaction/change_list.html"
def has_add_permission(self, request):
return False
def get_urls(self):
custom = [
path(
"gift/",
self.admin_site.admin_view(self.gift_view),
name="billing_gemtransaction_gift",
),
]
return custom + super().get_urls()
def gift_view(self, request):
if request.method == "POST":
form = GemGiftForm(request.POST)
if form.is_valid():
return self._apply_gift(request, form)
else:
form = GemGiftForm()
return render(
request,
"admin/billing/gem_gift.html",
{
**self.admin_site.each_context(request),
"title": "هدیه‌ی جم",
"form": form,
"opts": self.model._meta,
},
)
@db_transaction.atomic
def _apply_gift(self, request, form: GemGiftForm):
amount = form.cleaned_data["amount"]
note = form.cleaned_data["note"] or f"هدیه‌ی ادمین ({request.user})"
users, missing = form.existing_and_missing()
if missing and form.cleaned_data["create_missing"]:
created = [User.objects.create(phone=phone) for phone in missing]
users += created
missing = []
for user in users:
GemWalletService.add_gems(
user,
amount,
GemTransaction.Reason.ADMIN_ADJUSTMENT,
note=note,
)
if users:
messages.success(
request,
f"{amount} جم به {len(users)} شماره اضافه شد.",
)
if missing:
# هشدار و نه خطا: بقیه‌ی شماره‌ها شارژ شده‌اند و ادمین باید
# بداند دقیقا کدام‌ها جا مانده‌اند.
messages.warning(
request,
"این شماره‌ها کاربر ندارند و رد شدند: " + "، ".join(missing),
)
return redirect("admin:billing_gemtransaction_changelist")
@admin.register(BazaarPurchase)
class BazaarPurchaseAdmin(admin.ModelAdmin):
list_display = ("user", "gem_pack", "status", "gems_granted", "created_at", "verified_at")
list_filter = ("status",)
search_fields = ("user__phone", "purchase_token")
readonly_fields = ("purchase_token", "created_at", "verified_at")