Files
MathApp/payment/admin.py
Zareian a973c41db2 Security review fixes: admin permissions, guest sessions, payment edge cases, assistant leaks
- 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>
2026-10-05 04:52:25 +03:30

106 lines
4.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)