2026-08-31 15:11:52 +03:30
|
|
|
from django.contrib import admin, messages
|
2026-09-04 13:05:54 +03:30
|
|
|
from django.db import transaction as db_transaction
|
|
|
|
|
from django.shortcuts import redirect, render
|
2026-08-31 15:11:52 +03:30
|
|
|
from django.urls import path, reverse
|
|
|
|
|
from django.utils.html import format_html
|
2026-08-24 15:54:08 +03:30
|
|
|
|
2026-09-04 13:05:54 +03:30
|
|
|
from apps.account.models import User
|
2026-09-15 23:18:05 +03:30
|
|
|
from apps.account.send_sms import send_gift_gem_sms
|
2026-09-04 13:05:54 +03:30
|
|
|
|
|
|
|
|
from .api.services import GemWalletService
|
|
|
|
|
from .forms import GemGiftForm
|
2026-08-24 15:54:08 +03:30
|
|
|
from .models import BazaarPurchase, BazaarSettings, FeatureActivation, FeaturePlan, FeaturePlanTier, GemPack, GemTransaction
|
2026-08-31 15:11:52 +03:30
|
|
|
from .providers.bazaar import BazaarAuthError, build_authorize_url, exchange_code_for_tokens
|
2026-08-24 15:54:08 +03:30
|
|
|
|
|
|
|
|
|
|
|
|
|
@admin.register(BazaarSettings)
|
|
|
|
|
class BazaarSettingsAdmin(admin.ModelAdmin):
|
2026-08-31 15:11:52 +03:30
|
|
|
"""تکردیفی — لیست را رد میکنیم و مستقیم فرم ویرایش همان یک ردیف را نشان میدهیم.
|
|
|
|
|
|
|
|
|
|
برای اتصال با روش OAuth (تمدید خودکار access_token): این سه فیلد را پر
|
|
|
|
|
کن — Client ID، Client Secret، application id اپ — ذخیره کن، بعد از
|
|
|
|
|
«وضعیت اتصال کافهبازار» پایین فرم روی «شروع اتصال» بزن. اگر Redirect URI
|
|
|
|
|
که موقع ساخت Client در پیشخان ثبت کردهای با آدرسِ نشاندادهشده اینجا فرق
|
|
|
|
|
دارد، بعد از تایید در کافهبازار، مقدار code= را از آدرسِ برگشتی کپی و
|
|
|
|
|
خودت به همان آدرس callback اضافه کن (?code=...) — نیازی به فیلد جداگانه نیست.
|
|
|
|
|
|
|
|
|
|
یا سادهتر: اگر همین حالا یک توکن ثابتِ «روش جدید» پیشخان داری، مستقیم در
|
|
|
|
|
Access Token بچسبان و Access Token Expires At را خالی بگذار — کارش تمام
|
|
|
|
|
است، فقط اگر روزی باطل شد باید دستی توکن تازه بگیری."""
|
2026-08-24 15:54:08 +03:30
|
|
|
|
|
|
|
|
list_display = ("package_name", "updated_at")
|
2026-08-31 15:11:52 +03:30
|
|
|
readonly_fields = ("oauth_status",)
|
|
|
|
|
fields = (
|
|
|
|
|
"package_name",
|
|
|
|
|
"access_token",
|
|
|
|
|
"access_token_expires_at",
|
|
|
|
|
"refresh_token",
|
|
|
|
|
"client_id",
|
|
|
|
|
"client_secret",
|
|
|
|
|
"dynamic_discount_secret",
|
|
|
|
|
"oauth_status",
|
|
|
|
|
)
|
2026-08-24 15:54:08 +03:30
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-08-31 15:11:52 +03:30
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-24 15:54:08 +03:30
|
|
|
|
|
|
|
|
class FeaturePlanTierInline(admin.TabularInline):
|
|
|
|
|
model = FeaturePlanTier
|
|
|
|
|
extra = 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@admin.register(FeaturePlan)
|
|
|
|
|
class FeaturePlanAdmin(admin.ModelAdmin):
|
2026-09-04 13:05:54 +03:30
|
|
|
list_display = ("name", "key", "is_free", "is_purchasable")
|
|
|
|
|
list_editable = ("is_free",)
|
|
|
|
|
list_filter = ("is_free", "is_purchasable")
|
2026-08-24 15:54:08 +03:30
|
|
|
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):
|
2026-09-04 13:05:54 +03:30
|
|
|
"""تاریخچهی جم — فقطخواندنی، بهعلاوهی ابزارِ «هدیهی جم».
|
|
|
|
|
|
|
|
|
|
هدیه از دکمهی بالای همین صفحه انجام میشود، نه با ساختنِ دستیِ ردیفِ
|
|
|
|
|
تراکنش: اگر ادمین ردیف را دستی بسازد، User.gem_balance بهروز نمیشود و
|
|
|
|
|
موجودی با تاریخچه واگرا میشود. مسیرِ درست از GemWalletService رد
|
|
|
|
|
میشود که هر دو را با هم و داخلِ یک تراکنش تغییر میدهد.
|
|
|
|
|
"""
|
|
|
|
|
|
2026-08-24 15:54:08 +03:30
|
|
|
list_display = ("user", "amount", "reason", "created_at")
|
|
|
|
|
list_filter = ("reason",)
|
|
|
|
|
search_fields = ("user__phone", "note")
|
|
|
|
|
readonly_fields = ("user", "amount", "reason", "note", "created_at")
|
2026-09-04 13:05:54 +03:30
|
|
|
change_list_template = "admin/billing/gemtransaction/change_list.html"
|
2026-08-24 15:54:08 +03:30
|
|
|
|
|
|
|
|
def has_add_permission(self, request):
|
|
|
|
|
return False
|
|
|
|
|
|
2026-09-04 13:05:54 +03:30
|
|
|
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,
|
|
|
|
|
)
|
2026-09-15 23:18:05 +03:30
|
|
|
send_gift_gem_sms(user.phone, amount)
|
2026-09-04 13:05:54 +03:30
|
|
|
|
|
|
|
|
if users:
|
|
|
|
|
messages.success(
|
|
|
|
|
request,
|
|
|
|
|
f"{amount} جم به {len(users)} شماره اضافه شد.",
|
|
|
|
|
)
|
|
|
|
|
if missing:
|
|
|
|
|
# هشدار و نه خطا: بقیهی شمارهها شارژ شدهاند و ادمین باید
|
|
|
|
|
# بداند دقیقا کدامها جا ماندهاند.
|
|
|
|
|
messages.warning(
|
|
|
|
|
request,
|
|
|
|
|
"این شمارهها کاربر ندارند و رد شدند: " + "، ".join(missing),
|
|
|
|
|
)
|
|
|
|
|
return redirect("admin:billing_gemtransaction_changelist")
|
|
|
|
|
|
2026-08-24 15:54:08 +03:30
|
|
|
|
|
|
|
|
@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")
|