This commit is contained in:
javad committed 2026-10-10 14:21:04 +03:30
1 parent 250cdbab7f
commit 5c6069c3ef
87 files changed
+29209 -3697

No files matched your search

@@ -0,0 +1,30 @@
"""پاک‌سازی یک‌بارهٔ فیلدهای تلفن مشتریان (phone1/phone2/phone3).
روی دیتابیس production (سرور اصلی) هم قابل اجرا است؛ idempotent است و اجرای
دوباره‌اش تغییری ایجاد نمی‌کند.
python manage.py fix_customer_phones --dry-run # فقط گزارش، بدون تغییر
python manage.py fix_customer_phones # اجرای واقعی
"""
from django.core.management.base import BaseCommand
from admin_panel.sms.phone_cleanup import clean_all
class Command(BaseCommand):
help = "استخراج و استانداردسازی شماره‌های قدیمی مشتریان؛ متن‌های حذف‌شده در فیلد توضیحات می‌مانند."
def add_arguments(self, parser):
parser.add_argument(
"--dry-run", action="store_true",
help="فقط گزارش کن که چه چیزی تغییر می‌کند؛ چیزی ذخیره نکن.",
)
def handle(self, *args, **options):
dry = options["dry_run"]
stats = clean_all(dry_run=dry)
prefix = "شبیه‌سازی (بدون تغییر): " if dry else "پاک‌سازی انجام شد: "
self.stdout.write(
f"{prefix}{stats['customers']} مشتری، {stats['fields']} فیلد اصلاح شد، "
f"{stats['noted']} یادداشت ثبت شد."
)
@@ -0,0 +1,118 @@
"""استعلام وضعیت تحقق پیامک‌های ارسال‌شده (رسیده/نرسیده به گوشی).
فقط برای provider وب‌سرویس 0098SMS معنا دارد؛ تنها آن provider با
``SendSMSWithID`` شناسهٔ پیامک (smsid) برمی‌گرداند و ``smsdeliveryState``
وضعیت تحویل را می‌دهد. برای سایر providerها دستور no-op است.
"""
import logging
import signal
import time
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.db.models import Q
from django.utils import timezone
from admin_panel.models import SMSProviderConfig, SMSQueue
from integrations.sms.factory import get_sms_provider
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "استعلام وضعیت تحویل پیامک‌های ارسال‌شده از وب‌سرویس 0098SMS."
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._stop = False
def add_arguments(self, parser):
parser.add_argument("--once", action="store_true", help="یک دور پردازش و خروج")
parser.add_argument("--sleep", type=float, default=30.0, help="فاصلهٔ بین دورها (ثانیه)")
parser.add_argument("--batch", type=int, default=100, help="تعداد ردیف در هر دور")
parser.add_argument(
"--min-age", type=int, default=60,
help="حداقل فاصله (ثانیه) از زمان ارسال پیش از نخستین استعلام",
)
def handle(self, *args, **options):
signal.signal(signal.SIGTERM, self._handle_signal)
signal.signal(signal.SIGINT, self._handle_signal)
once = options["once"]
sleep_seconds = options["sleep"]
self._emit("استعلام دلیوری پیامک آغاز شد.", style=self.style.SUCCESS)
while not self._stop:
try:
processed = self._drain_batch(
options["batch"], options["min_age"],
)
except Exception:
logger.exception("خطای غیرمنتظره در یک دور از استعلام دلیوری؛ دور بعد ادامه می‌دهد.")
processed = 0
if once:
break
if processed == 0:
self._sleep_interruptible(sleep_seconds)
self._emit("استعلام دلیوری پیامک متوقف شد.", style=self.style.SUCCESS)
# ------------------------------------------------------------------ helpers
def _handle_signal(self, signum, frame):
self._stop = True
def _emit(self, text, *, style=None, err=False):
"""چاپ با تحمل کدگذاری محدود کنسول (مثل worker پیامک)."""
stream = self.stderr if err else self.stdout
style = style or (lambda value: value)
for payload in (text, text.encode("ascii", "replace").decode("ascii")):
try:
stream.write(style(payload))
return
except (UnicodeEncodeError, ValueError, OSError):
continue
def _sleep_interruptible(self, seconds):
deadline = time.monotonic() + max(0, seconds)
while not self._stop and time.monotonic() < deadline:
time.sleep(min(0.5, deadline - time.monotonic()))
def _drain_batch(self, batch, min_age):
config = SMSProviderConfig.load()
if config.provider != "sms0098ws":
# smsid تنها متعلق به این provider است؛ استعلام با بقیه بی‌معناست.
logger.info("provider فعلی رهگیری دلیوری ندارد؛ استعلام رد شد.")
return 0
now = timezone.now()
cutoff = now - timedelta(seconds=max(0, min_age))
# پیامک‌های ارسال‌شده که شناسه دارند و وضعیت‌شان هنوز نهایی نشده است.
query = SMSQueue.objects.filter(
Q(status=SMSQueue.Status.SENT, provider_id__gt="", sent_at__lte=cutoff),
Q(delivery_checked_at__isnull=True)
| Q(delivery_status__in=["", SMSQueue.DeliveryStatus.PENDING,
SMSQueue.DeliveryStatus.UNKNOWN]),
)
try:
provider = get_sms_provider(config)
except Exception:
logger.exception("ساخت provider پیامک ناموفق بود؛ این دور رد شد.")
return 0
processed = 0
for row in query.order_by("sent_at")[:batch]:
if self._stop:
break
result = provider.get_delivery_status(row.provider_id)
if result is None:
# خطای ارتباط/تنظیمات: ردیف را رد کن تا دور بعد دوباره تلاش شود.
continue
row.delivery_status = result.status
row.delivery_checked_at = timezone.now()
row.save(update_fields=["delivery_status", "delivery_checked_at", "updated_at"])
processed += 1
self._emit(
f" دلیوری #{row.pk} ({row.provider_id}): {result.status} "
f"{'(نهایی)' if result.is_final else '(در انتظار)'}",
)
return processed
@@ -0,0 +1,50 @@
"""زمان‌بند/اسکنر: پیدا کردن موارد سررسیدشده و قرار دادن آن‌ها در صف پیامک."""
import logging
import signal
import time
from django.core.management.base import BaseCommand
from admin_panel.sms.scanner import run_all
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "اسکن یادآوری‌های پیامک و قرار دادن آن‌ها در صف."
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._stop = False
def add_arguments(self, parser):
parser.add_argument("--loop", action="store_true", help="اجرای پیوسته")
parser.add_argument("--interval", type=float, default=300.0, help="فاصلهٔ بین اجراها (ثانیه)")
def handle(self, *args, **options):
signal.signal(signal.SIGTERM, self._handle_signal)
signal.signal(signal.SIGINT, self._handle_signal)
loop = options["loop"]
interval = options["interval"]
while not self._stop:
results = run_all()
summary = ", ".join(f"{name}={count}" for name, count in results.items())
self._emit(f"اسکن پیامک: {summary}")
if not loop:
break
# خواب قابل‌قطع
slept = 0.0
while slept < interval and not self._stop:
time.sleep(min(1.0, interval - slept))
slept += 1.0
def _handle_signal(self, signum, frame):
self._stop = True
def _emit(self, text):
"""نوشتن خروجی با تحمل کدگذاری محدود کنسول (مثلاً cp1252 در ویندوز)."""
try:
self.stdout.write(text)
except UnicodeEncodeError:
self.stdout.write(text.encode("ascii", "replace").decode("ascii"))
@@ -0,0 +1,179 @@
"""worker تخلیه‌کنندهٔ صف پیامک.
در یک پردازش جدا اجرا می‌شود تا کندی/خطای سامانه پیامک به برنامه اصلی ضربه نزند.
"""
import logging
import signal
import time
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.db.models import Q
from django.utils import timezone
from admin_panel.models import SMSLog, SMSProviderConfig, SMSQueue
from integrations.sms.factory import get_sms_provider
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "تخلیهٔ صف پیامک و ارسال از طریق سرویس‌دهندهٔ تنظیم‌شده."
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._stop = False
def add_arguments(self, parser):
parser.add_argument("--once", action="store_true", help="یک دور پردازش و خروج")
parser.add_argument("--sleep", type=float, default=3.0, help="فاصلهٔ بین دورها (ثانیه)")
parser.add_argument(
"--batch", type=int, default=None,
help="تعداد پیامک در هر دور (پیش‌فرض: max_per_run از تنظیمات پنل)",
)
def handle(self, *args, **options):
signal.signal(signal.SIGTERM, self._handle_signal)
signal.signal(signal.SIGINT, self._handle_signal)
once = options["once"]
sleep_seconds = options["sleep"]
batch = options["batch"]
self._emit("worker پیامک آغاز شد.", style=self.style.SUCCESS)
while not self._stop:
# یک دور خراب (خطای دیتابیس، provider، …) نباید کل worker را از پا دربیاورد؛
# در غیر این‌صورت صف تا ری‌استارت دستی راکد می‌ماند.
try:
processed = self._drain_batch(batch)
except Exception:
logger.exception("خطای غیرمنتظره در یک دور از worker پیامک؛ دور بعد ادامه می‌دهد.")
processed = 0
if once:
break
if processed == 0:
self._sleep_interruptible(sleep_seconds)
self._emit("worker پیامک متوقف شد.", style=self.style.SUCCESS)
def _handle_signal(self, signum, frame):
self._stop = True
def _emit(self, text, *, style=None, err=False):
"""نوشتن خروجی با تحمل کدگذاری محدود کنسول (مثلاً cp1252 در ویندوز).
چاپ، عملیات جانبی است: اگر کنسول نتواند متن را بنویسد نباید ارسال‌ها را
متوقف یا «ناموفق» کند. اول متن کامل، بعد نسخهٔ ASCII-شده، و در نهایت سکوت.
"""
stream = self.stderr if err else self.stdout
style = style or (lambda value: value)
for payload in (text, text.encode("ascii", "replace").decode("ascii")):
try:
stream.write(style(payload))
return
except (UnicodeEncodeError, ValueError, OSError):
continue
def _drain_batch(self, batch):
config = SMSProviderConfig.load()
if not config.is_enabled:
logger.info("ارسال پیامک از تنظیمات غیرفعال است؛ صف دست‌نخورده می‌ماند.")
return 0
if not batch:
batch = config.max_per_run or 20
interval = config.send_interval_seconds or 0
now = timezone.now()
# تنها پیامک‌هایی که مدیر صراحتاً تأیید کرده ارسال می‌شوند؛ موارد «در
# انتظار تأیید» دست‌نخورده در صف بازبینی می‌مانند.
candidates = SMSQueue.objects.filter(
status=SMSQueue.Status.APPROVED,
).filter(
Q(next_retry_at__isnull=True) | Q(next_retry_at__lte=now),
).order_by("created_at")[:batch]
try:
provider = get_sms_provider(config)
except Exception:
logger.exception("ساخت provider پیامک ناموفق بود؛ این دور رد شد.")
return 0
processed = 0
for item in candidates:
if self._stop:
break
# claim اتمیک مثبت‌گرا (SQLite از select_for_update پشتیبانی نمی‌کند).
claimed = SMSQueue.objects.filter(
pk=item.pk, status=SMSQueue.Status.APPROVED,
).update(status=SMSQueue.Status.PROCESSING)
if not claimed:
continue
try:
row = SMSQueue.objects.get(pk=item.pk)
except SMSQueue.DoesNotExist:
# رکورد بین انتخاب و claim حذف شده (مثلاً پاک‌سازی دستی پنل).
continue
# فاصلهٔ بین پیامک‌ها: پیش از ارسال مورد بعدی صبر می‌کنیم (نه بعد از آخرین).
if processed and interval:
self._sleep_interruptible(interval)
if self._stop:
# مورد claim‌شده را به صف برگردان تا از دست نرود.
SMSQueue.objects.filter(
pk=row.pk, status=SMSQueue.Status.PROCESSING,
).update(status=SMSQueue.Status.APPROVED)
break
processed += 1
self._send(provider, row)
return processed
def _sleep_interruptible(self, seconds):
"""خواب قابل‌قطع تا سیگنال توقف سریع پاسخ داده شود."""
remaining = float(seconds)
while remaining > 0 and not self._stop:
step = min(0.5, remaining)
time.sleep(step)
remaining -= step
def _send(self, provider, row):
try:
result = provider.send_message(row.phone_number, row.message)
except Exception as exc: # provider نباید worker را از پا دربیاورد
logger.exception("استثنای غیرمنتظره در ارسال پیامک #%s", row.pk)
result = None
error = str(exc)
else:
error = getattr(result, "error", "") or ""
result = result if getattr(result, "success", False) else None
now = timezone.now()
row.attempts = (row.attempts or 0) + 1
if result is not None:
row.status = SMSQueue.Status.SENT
row.sent_at = now
row.provider_id = getattr(result, "provider_id", "") or ""
row.provider_code = getattr(result, "provider_code", "") or ""
# شناسه (smsid) داریم ⇒ اهل رهگیری دلیوری؛ دستور sms_delivery پر می‌کند.
row.delivery_status = (
SMSQueue.DeliveryStatus.PENDING if row.provider_id else ""
)
row.last_error = ""
row.save()
SMSLog.objects.create(
queue=row, attempt_no=row.attempts, success=True,
provider_id=row.provider_id, provider_code=row.provider_code,
)
self._emit(f" ارسال موفق #{row.pk} -> {row.phone_number}")
return
row.last_error = error[:2000]
if row.attempts >= (row.max_attempts or 5):
row.status = SMSQueue.Status.FAILED
else:
# همچنان تأییدشده می‌ماند تا تلاش خودکار بعدی ادامه یابد.
row.status = SMSQueue.Status.APPROVED
backoff = min(3600, 30 * (2 ** row.attempts))
row.next_retry_at = now + timedelta(seconds=backoff)
row.save()
SMSLog.objects.create(
queue=row, attempt_no=row.attempts, success=False, error=row.last_error,
)
self._emit(f" ارسال ناموفق #{row.pk} (تلاش {row.attempts}): {row.last_error[:120]}", err=True)