Files
mobiniqow 831e656fa3 Fix OTP/SMS, ledger accounting, order tracking, and add reviews app
- Add MessageTemplate.runtime_field_labels so mobile's per-field
  execution-time labels are actually persisted (were silently dropped)
- Fix AdvanceOrderStageView reading the wrong extra-input key, and be
  honest about SMS send failures instead of always claiming success
- Add admin gift-gem SMS notification via Kavenegar
- Post real double-entry Transactions for plain (non-invoice) ledger
  entries so trial balance, the معین screen, and CSV/XLSX exports
  actually include everyday income/expense entries; backfill existing
  historical entries via data migration
- Rewrite CSV ledger export to produce an actual دفتر معین structure
  (per-account running balance) instead of a flat row dump
- Add invoice percent/promotional discounts, tax presets, ledger
  discount-percent handling, product SKU/category/expiry fields, and
  manual ledger-book archiving (replacing auto-archive on new book)
- Add reviews app (models, API, admin) for the comments/reviews feature
- Update tests for the above; fix two stale assertions left over from
  earlier work in this branch (معین sheet name, book auto-close)
2026-09-15 23:18:05 +03:30

234 lines
9.8 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 apps.account.send_sms import send_gift_gem_sms
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,
)
send_gift_gem_sms(user.phone, amount)
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")