Audit fixes 2026-10-03: seed keeps admin edits, plan cache, refresh limit, payment and tutor guards

- seed_content no longer undoes admin work: a SeedRecord ledger (content/seed_sync.py)
  stops deleted/renamed/re-worded content from being recreated and keeps admin-edited
  lesson text; new rows in the seed files still arrive (migration content.0010)
- plan list cache is invalidated on Plan save (receiver used a stale 'v1' key)
- token refresh per-IP cap 30/hour -> 600/hour (TOKEN_REFRESH_IP_RATE)
- a failed gateway result only changes a pending order (forged NOK no longer
  overwrites refunded/canceled)
- tutor gets the correct answer only after the student has answered
- support reply notification moved to a TicketMessage post_save signal
- global daily cap on paid AI questions (AI_GLOBAL_DAILY_MAX, default 3000)
- Bazaar verify uses only the order's SKU; bazaar orders require a SKU
- /api/health warns on the committed signing key; deploy.sh saves local commits;
  400 for bad ?unit= / ?period=; account delete keeps the referrer's Conversion
- earlier today: no stub answers outside DEBUG, ?grade= removed, logged fallbacks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 16:05:27 +03:30
parent 7e027c167c
commit ab5e53730a
31 changed files with 623 additions and 49 deletions

View File

@@ -1,5 +1,4 @@
from django.contrib import admin
from django.db import transaction
from .models import BugReport, Ticket, TicketMessage
@@ -30,26 +29,5 @@ class TicketMessageAdmin(admin.ModelAdmin):
list_display = ('id', 'ticket', 'sender', 'created_at')
list_filter = ('sender',)
def save_model(self, request, obj, form, change):
"""
طبق سند معماری، ارتباط Support→Notifications فقط از طریق رویداد
TicketReplied‌ه، نه رفرنس مدل. چون پاسخ پشتیبانی (sender='support')
از پنل ادمین ثبت می‌شه، رویداد دقیقاً همین‌جا ساطع می‌شه — همون
الگوی notify() که در payment/views.py (PaymentWebhookView) هنگام
پرداخت موفق (status=paid) صدا زده می‌شه.
"""
is_new = obj.pk is None
# ⚛️ atomic: ذخیره‌ی پیام پشتیبانی + ساختِ Notification یک واحدند.
with transaction.atomic():
super().save_model(request, obj, form, change)
if is_new and obj.sender == 'support':
from notifications.services import notify
notify(
user_id=obj.ticket.user_id,
type='ticket_replied',
title='پاسخ جدید برای تیکت شما',
body=obj.text[:200],
related_id=obj.ticket_id,
)
# اعلانِ پاسخِ پشتیبانی در support/receivers.py است (سیگنال روی مدل)، نه این‌جا؛ از صفحه‌ی
# تیکت (inline) هم که ذخیره شود اعلان می‌رود.

View File

@@ -4,3 +4,6 @@ from django.apps import AppConfig
class SupportConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'support'
def ready(self):
from . import receivers # noqa: F401 (اعلانِ پاسخِ پشتیبانی)

27
support/receivers.py Normal file
View File

@@ -0,0 +1,27 @@
"""
اعلانِ «پاسخِ تازه برای تیکتت» با هر پیامِ پشتیبانی، از هر مسیری که ذخیره شود.
تا ۲۰۲۶-۱۰-۰۳ اعلان فقط در `TicketMessageAdmin.save_model` ساخته می‌شد. پشتیبان معمولاً از
صفحه‌ی خودِ تیکت (inline) جواب می‌دهد و آن مسیر از `TicketAdmin` رد می‌شود؛ پیام ثبت می‌شد و
کاربر هیچ اعلانی نمی‌گرفت (بازتولید شد). سیگنال روی خودِ مدل هر دو مسیر را می‌گیرد.
اعلان داخلِ همان تراکنشِ ذخیره‌ی پیام نوشته می‌شود؛ شکستش ذخیره‌ی پیام را هم برمی‌گرداند.
"""
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import TicketMessage
@receiver(post_save, sender=TicketMessage, dispatch_uid='support_notify_ticket_reply')
def _notify_ticket_reply(sender, instance, created, **kwargs):
if not created or instance.sender != 'support':
return
from notifications.services import notify
notify(
user_id=instance.ticket.user_id,
type='ticket_replied',
title='پاسخ جدید برای تیکت شما',
body=instance.text[:200],
related_id=instance.ticket_id,
)

View File

@@ -392,3 +392,33 @@ class TicketListTests(TestCase):
'/api/support/tickets', {'subject': 'س', 'category': 'other'}, format='json', **self.auth,
)
self.assertEqual(response.status_code, 200)
class TicketReplyNotificationTests(TestCase):
"""پاسخی که پشتیبان از صفحه‌ی خودِ تیکت (inline) می‌دهد هم اعلان می‌سازد (تا ۲۰۲۶-۱۰-۰۳ نمی‌ساخت)."""
def test_inline_reply_on_the_ticket_page_notifies_the_user(self):
from django.contrib.auth.models import User as Staff
from django.test import Client
from notifications.models import Notification
staff = Staff.objects.create_superuser('support-staff', 's@example.com', 'pw')
ticket = Ticket.objects.create(user_id=77, subject='s', category='c')
client = Client()
client.force_login(staff)
client.post(f'/admin/support/ticket/{ticket.id}/change/', {
'user_id': 77, 'subject': 's', 'category': 'c', 'status': 'open', 'priority': 'normal',
'messages-TOTAL_FORMS': '1', 'messages-INITIAL_FORMS': '0',
'messages-MIN_NUM_FORMS': '0', 'messages-MAX_NUM_FORMS': '1000',
'messages-0-sender': 'support', 'messages-0-text': 'پاسخ', 'messages-0-ticket': str(ticket.id),
})
self.assertEqual(ticket.messages.count(), 1)
self.assertEqual(Notification.objects.filter(user_id=77, type='ticket_replied').count(), 1)
def test_a_users_own_message_does_not_notify_them(self):
from notifications.models import Notification
ticket = Ticket.objects.create(user_id=78, subject='s', category='c')
TicketMessage.objects.create(ticket=ticket, sender='user', text='سلام')
self.assertFalse(Notification.objects.filter(user_id=78).exists())