- Admin: refund/cancel/mark-resolved/Bazaar-link actions need change permission; question and curriculum import need add/delete; content browser needs view_question; guest device ids are shown to superusers only. - Accounts: registering a guest's phone revokes the guest's earlier sessions; self-deletion keeps Redemption, UserOffer and the phone's OTP rows; the public demo guest cannot be deleted (DEMO_GUEST_DEVICE_IDS); display-name contact filter covers any separator and social handles. - Payment: callback without Authority is 400; a second Bazaar purchase on a paid order is 409; orders snapshot credits/hearts/days at creation (payment.0011). - Assistant: a broken diagram no longer makes a streamed answer free; a late off-topic marker refuses the answer (not stored, not cached); a disconnect saves the partial answer; only KNOWN_COLLECTIONS are searched. - Infra: secret-key file is created atomically (workers no longer split keys); no default INTERNAL_SERVICE_KEY; Kavenegar key and Bazaar token kept out of error logs; Swagger/ReDoc pinned; schema throttled; docker compose bound to 127.0.0.1; non-ASCII digit ids are 400; ticket text capped at 5000. - docs/security-before-production.md: remaining open items 24-31 and the guard tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
106 lines
4.8 KiB
Python
106 lines
4.8 KiB
Python
from django.contrib import admin, messages
|
||
from django.utils.html import format_html
|
||
|
||
from . import bazaar
|
||
from .models import BazaarCredential, Order, Transaction
|
||
|
||
|
||
class TransactionInline(admin.TabularInline):
|
||
model = Transaction
|
||
extra = 0
|
||
|
||
|
||
@admin.register(Order)
|
||
class OrderAdmin(admin.ModelAdmin):
|
||
"""
|
||
لغو و بازپرداخت فقط با دو action (از ۲۰۲۶-۱۰-۰۲)، نه با عوض کردنِ دستیِ وضعیت: «paid» را با دست
|
||
«failed» کردن اعتبار یا اشتراکی را که سفارش داده بود پس نمیگرفت. برای همین وضعیت اینجا فقطخواندنی است.
|
||
منطق در payment.services (cancel_pending_order، refund_order) است و تستهایش در payment/tests_refund.py.
|
||
"""
|
||
|
||
list_display = ('id', 'user_id', 'plan_title', 'amount', 'currency', 'status', 'provider', 'created_at')
|
||
list_filter = ('status', 'provider', 'currency')
|
||
search_fields = ('=id', '=user_id')
|
||
readonly_fields = ('status',)
|
||
inlines = [TransactionInline]
|
||
actions = ['cancel_pending', 'refund']
|
||
|
||
@admin.action(description='لغوِ سفارشِ در انتظار', permissions=['change'])
|
||
def cancel_pending(self, request, queryset):
|
||
from .services import OrderStateError, cancel_pending_order
|
||
|
||
done = 0
|
||
for order in queryset:
|
||
try:
|
||
cancel_pending_order(order, staff=request.user.get_username())
|
||
done += 1
|
||
except OrderStateError as exc:
|
||
self.message_user(request, str(exc), messages.WARNING)
|
||
if done:
|
||
self.message_user(request, f'{done} سفارش لغو شد.', messages.SUCCESS)
|
||
|
||
@admin.action(description='بازپرداخت (پس گرفتنِ اعتبار/اشتراکِ سفارشِ پرداختشده)', permissions=['change'])
|
||
def refund(self, request, queryset):
|
||
from django.template.response import TemplateResponse
|
||
|
||
from .services import OrderStateError, refund_order
|
||
|
||
if request.POST.get('confirm') != 'yes':
|
||
return TemplateResponse(request, 'admin/payment/order/refund_confirm.html', {
|
||
**self.admin_site.each_context(request), 'orders': list(queryset), 'opts': self.model._meta,
|
||
'title': 'تأییدِ بازپرداخت',
|
||
})
|
||
for order in queryset:
|
||
try:
|
||
r = refund_order(order, staff=request.user.get_username())
|
||
except OrderStateError as exc:
|
||
self.message_user(request, str(exc), messages.WARNING)
|
||
continue
|
||
if r['subscription_days']:
|
||
what = f'{r["subscription_days"]} روز از اشتراک کم شد'
|
||
else:
|
||
what = f'{r["credits_taken"]} از {r["credits_granted"]} سوالِ بسته پس گرفته شد'
|
||
if r['credits_taken'] < r['credits_granted']:
|
||
what += f' (بقیه خرج شده بود)'
|
||
if r['hearts_days']:
|
||
what += f'، {r["hearts_days"]} روز قلبِ نامحدود کم شد'
|
||
self.message_user(request, f'سفارشِ {order.id}: {what}. پول را از پنلِ درگاه برگردانید.', messages.SUCCESS)
|
||
|
||
|
||
@admin.register(Transaction)
|
||
class TransactionAdmin(admin.ModelAdmin):
|
||
list_display = ('id', 'order', 'gateway', 'status', 'paid_at')
|
||
list_filter = ('gateway', 'status')
|
||
|
||
|
||
@admin.register(BazaarCredential)
|
||
class BazaarCredentialAdmin(admin.ModelAdmin):
|
||
"""
|
||
اتصالِ یکباره به API بازار، بدونِ SSH: action لینکِ «اجازه» را میسازد؛
|
||
صاحبِ حسابِ بازار بازش میکند و بازار به verify برمیگردد. خودِ توکن
|
||
هیچجا نمایش داده نمیشود.
|
||
"""
|
||
|
||
list_display = ('__str__', 'authorized_at', 'pending_since')
|
||
fields = ('connected', 'authorized_at', 'pending_since')
|
||
readonly_fields = fields
|
||
actions = ['make_authorize_link']
|
||
|
||
@admin.display(boolean=True, description='توکن دارد')
|
||
def connected(self, obj):
|
||
return bool(obj.refresh_token)
|
||
|
||
def changelist_view(self, request, extra_context=None):
|
||
BazaarCredential.objects.get_or_create(pk=1)
|
||
return super().changelist_view(request, extra_context)
|
||
|
||
def has_add_permission(self, request):
|
||
return False
|
||
|
||
@admin.action(description='ساختِ لینکِ اتصال به بازار', permissions=['change'])
|
||
def make_authorize_link(self, request, queryset):
|
||
url = bazaar.start_authorization()
|
||
self.message_user(request, format_html(
|
||
'این لینک را صاحبِ حسابِ بازار باز کند و «اجازه» بزند: <a href="{0}">{0}</a>', url,
|
||
), messages.SUCCESS)
|