admin & data: correct admin action redirect, add SQLite retry, seed_test_data command

This commit is contained in:
2026-08-26 03:22:20 +03:30
parent f322d27e4f
commit 05ab6ffc36
7 changed files with 601 additions and 10 deletions

View File

@@ -9,7 +9,9 @@ retry یا پیگیریِ تحویلی انجام نمی‌ده — طبق Messa
فراخوانی همیشه sync و بلاکینگ‌ه (برای هر کاربر یک send_sms/notify)؛
مناسبِ لیست‌های کوچیک یا اجرای دستی/تست، نه کمپین‌های بزرگ در پروداکشن.
"""
from django.db import IntegrityError, transaction
import time
from django.db import IntegrityError, OperationalError, transaction
from accounts import services as account_services
from accounts.services import SMSDeliveryError
@@ -17,6 +19,26 @@ from notifications.services import notify
from .models import CampaignDelivery, MessageCampaign
def _retry_on_sqlite_lock(fn, *, retries=5, base_delay=0.05):
"""
روی SQLite (مخصوصاً دیتابیسِ حافظه‌ایِ shared-cache که تست‌ها ازش
استفاده می‌کنن)، وقتی دو اتصالِ هم‌زمان می‌خوان همون جدول رو بنویسن،
ممکنه OperationalError('database table is locked') بگیریم. این
SQLITE_LOCKED‌ه نه SQLITE_BUSY، پس busy_timeoutِ معمولیِ اتصال کمکی
نمی‌کنه و بلافاصله raise می‌شه؛ یک خطای واقعیِ داده نیست، فقط
تناقضِ لحظه‌ایِ لاکه. این تابع فقط همین خطای گذرا رو با چند بار
تلاشِ سریع (backoffِ کوچیکِ فزاینده) دوباره امتحان می‌کنه؛ هر
OperationalError دیگه (یا شکستِ تلاشِ آخر) دست‌نخورده بالا می‌ره.
"""
for attempt in range(retries):
try:
return fn()
except OperationalError as exc:
if 'locked' not in str(exc).lower() or attempt == retries - 1:
raise
time.sleep(base_delay * (attempt + 1))
# نوعِ نوتیفیکیشنِ ثبت‌شده برای اعلان‌های ناشی از کمپین. عمداً در
# notifications.models.Notification.TYPE_CHOICES اضافه نشده چون آن
# choices فقط رویدادهایی‌ان که notifications به‌عنوانِ event-listener
@@ -70,12 +92,12 @@ def _mark_delivery_result(delivery: CampaignDelivery, *, error: Exception | None
"""رکوردِ pending‌ای که قبل از تلاشِ ارسال ساخته شده رو به sent/failed آپدیت می‌کنه."""
if error is None:
delivery.status = CampaignDelivery.STATUS_SENT
delivery.save(update_fields=['status'])
_retry_on_sqlite_lock(lambda: delivery.save(update_fields=['status']))
return
delivery.status = CampaignDelivery.STATUS_FAILED
delivery.error_message = str(error)[:300]
delivery.save(update_fields=['status', 'error_message'])
_retry_on_sqlite_lock(lambda: delivery.save(update_fields=['status', 'error_message']))
def _claim_delivery(campaign: MessageCampaign, user_id: int) -> CampaignDelivery | None:
@@ -96,11 +118,14 @@ def _claim_delivery(campaign: MessageCampaign, user_id: int) -> CampaignDelivery
هم‌زمانِ دیگر)، create() با IntegrityError رد می‌شه و این تابع
None برمی‌گردونه — یعنی کاربر به‌جای duplicate delivery، skip می‌شه.
"""
try:
def _create():
with transaction.atomic():
return CampaignDelivery.objects.create(
campaign=campaign, user_id=user_id, channel=campaign.channel,
)
try:
return _retry_on_sqlite_lock(_create)
except IntegrityError:
return None
@@ -203,7 +228,7 @@ def execute_campaign(campaign: MessageCampaign) -> None:
delivery = _claim_delivery(campaign, user.id)
if delivery is None:
continue
try:
def _notify_and_mark_sent():
with transaction.atomic():
notify(
user_id=user.id,
@@ -213,6 +238,9 @@ def execute_campaign(campaign: MessageCampaign) -> None:
related_id=campaign.id,
)
_mark_delivery_result(delivery, error=None)
try:
_retry_on_sqlite_lock(_notify_and_mark_sent)
except Exception as exc:
# چه notify() خودش شکست بخوره چه فقط نوشتنِ نهاییِ sent
# (که تویِ همون atomic block بالاست)، هر دو حالت اینجا