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

+101 -1
View File
@@ -74,4 +74,104 @@ class StoreRoomAdmin(admin.ModelAdmin):
def get_owners_count(self, obj):
"""نمایش تعداد مالکین در لیست اصلی انبارها"""
return obj.owners.count()
get_owners_count.short_description = "تعداد مالکین"
get_owners_count.short_description = "تعداد مالکین"
# ============================================================
# مدیریت پیامک (SMS)
# ============================================================
from django import forms
from django.contrib import messages
class SMSProviderConfigForm(forms.ModelForm):
class Meta:
model = SMSProviderConfig
fields = '__all__'
widgets = {
'password': forms.PasswordInput(render_value=True),
'api_token': forms.PasswordInput(render_value=True),
}
@admin.register(SMSProviderConfig)
class SMSProviderConfigAdmin(admin.ModelAdmin):
form = SMSProviderConfigForm
list_display = ('provider', 'is_enabled', 'sender', 'username',
'allow_backlog', 'backlog_max', 'max_per_run',
'send_interval_seconds', 'updated_at')
readonly_fields = ('updated_at',)
fieldsets = (
("اتصال سرویس‌دهنده", {
'fields': ('provider', 'is_enabled', 'api_url', 'username', 'password',
'sender', 'api_token', 'test_message', 'timeout_seconds'),
}),
("وب‌سرویس ۰۰۹۸ (فقط provider وب‌سرویس)", {
'fields': ('webservice_url', 'panel_no'),
}),
("کنترل جریان ارسال", {
'fields': ('scan_since', 'allow_backlog', 'backlog_max',
'max_per_run', 'send_interval_seconds', 'updated_at'),
}),
)
def has_add_permission(self, request):
# سینگلتون: فقط ویرایش رکورد موجود.
return not SMSProviderConfig.objects.exists()
def has_delete_permission(self, request, obj=None):
return False
@admin.register(SMSTemplate)
class SMSTemplateAdmin(admin.ModelAdmin):
list_display = ('title', 'sms_type', 'is_enabled', 'auto_send', 'offset_days',
'repeat_interval_days', 'repeat_interval_months')
list_editable = ('is_enabled', 'auto_send')
list_filter = ('sms_type', 'is_enabled', 'auto_send')
search_fields = ('title', 'text')
readonly_fields = ('updated_at',)
@admin.register(CustomerSMSSetting)
class CustomerSMSSettingAdmin(admin.ModelAdmin):
list_display = ('customer', 'sms_type', 'is_enabled')
list_filter = ('sms_type', 'is_enabled')
raw_id_fields = ('customer',)
search_fields = ('customer__name_family',)
@admin.register(SMSQueue)
class SMSQueueAdmin(admin.ModelAdmin):
list_display = ('id', 'customer', 'sms_type', 'phone_number', 'status',
'delivery_status', 'attempts', 'created_at', 'sent_at')
list_filter = ('status', 'sms_type', 'delivery_status')
raw_id_fields = ('customer', 'created_by')
search_fields = ('phone_number', 'message', 'dedup_key')
readonly_fields = ('created_at', 'updated_at', 'sent_at')
actions = ['approve_selected', 'retry_selected']
@admin.action(description="تأیید موارد انتخاب‌شده (ارسال توسط worker)")
def approve_selected(self, request, queryset):
updated = queryset.filter(status=SMSQueue.Status.PENDING).update(
status=SMSQueue.Status.APPROVED, next_retry_at=None,
)
self.message_user(request, f"{updated} پیامک تأیید شد.", messages.SUCCESS)
@admin.action(description="ارسال مجدد موارد انتخاب‌شده")
def retry_selected(self, request, queryset):
updated = queryset.update(status=SMSQueue.Status.APPROVED, next_retry_at=None, last_error='')
self.message_user(request, f"{updated} پیامک برای ارسال مجدد تأیید شد.", messages.SUCCESS)
@admin.register(SMSLog)
class SMSLogAdmin(admin.ModelAdmin):
list_display = ('id', 'queue', 'attempt_no', 'success', 'provider_code', 'created_at')
list_filter = ('success',)
raw_id_fields = ('queue',)
readonly_fields = ('queue', 'attempt_no', 'success', 'provider_id', 'provider_code', 'error', 'created_at')
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
+33
View File
@@ -1,6 +1,39 @@
import sys
from django.apps import AppConfig
def make_std_streams_unicode_safe():
"""استریم‌های استاندارد را در برابر متن غیر-ASCII مقاوم می‌کند.
کنسول ویندوز با کدپیج cp1252 کار می‌کند و نوشتن متن فارسی روی آن
``UnicodeEncodeError`` می‌دهد (پیام خطا: ``'charmap' codec can't encode ...``).
رخداد واقعی که این تابع برایش نوشته شد: ``print`` متن فارسی داخل provider کنسولِ
پیامک استثنا می‌داد، worker آن را به‌عنوان «ارسال ناموفق» ثبت می‌کرد و پیامک در صف
``failed`` می‌ماند — در حالی که ارسال هیچ مشکلی نداشت. یک استثنای صرفاً نمایشی
نباید هرگز نتیجهٔ یک عملیات کسب‌وکار را عوض کند.
پس اینجا یک‌بار برای همیشه استریم‌ها به UTF-8 با ``errors='replace'`` تنظیم می‌شوند:
هیچ چاپ فارسی‌ای پروسه را نمی‌شکند و فایل‌های لاگ هم فارسی را درست نگه می‌دارند.
"""
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is None:
continue
try:
reconfigure(encoding="utf-8", errors="replace")
except Exception:
# جریان‌های جایگزین (مثلاً test runner یا jupyter) ممکن است پشتیبانی نکنند.
pass
class AdminPanelConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'admin_panel'
def ready(self):
# پیش از هر کامند/سیگنال: چاپ امن متن فارسی روی کنسول ویندوز.
make_std_streams_unicode_safe()
# ثبت سیگنال‌ها (busy_timeout برای SQLite).
from . import signals # noqa: F401
+5 -1
View File
@@ -3,6 +3,7 @@ import os
import datetime
from .models import *
from .sms.notices import resolve as resolve_sms_notice
def dashBoxData(request):
@@ -52,5 +53,8 @@ def dashBoxData(request):
'entezarbazdid': entezarbazdid,
'bazdid': bazdid
}
# پیام نتیجهٔ عملیات پیامک (جایگزین flash message) از ?notice=<code>.
data.update(resolve_sms_notice(request))
return data
@@ -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)
+124
View File
@@ -0,0 +1,124 @@
# Generated by Django 3.2.25 on 2026-10-09 16:52
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('admin_panel', '0056_auto_20260731_1824'),
]
operations = [
migrations.CreateModel(
name='SMSProviderConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('provider', models.CharField(choices=[('sms0098', '0098 (SOAP/zeep)'), ('http', 'HTTP عمومی'), ('console', 'Console (تست/دیباگ)')], default='sms0098', max_length=20, verbose_name='سرویس\u200cدهنده پیامک')),
('is_enabled', models.BooleanField(default=True, help_text='در صورت غیرفعال بودن، worker هیچ پیامکی ارسال نمی\u200cکند.', verbose_name='فعال بودن ارسال پیامک')),
('api_url', models.CharField(blank=True, default='', max_length=500, verbose_name='آدرس سرویس (WSDL یا URL)')),
('username', models.CharField(blank=True, default='', max_length=100, verbose_name='نام کاربری')),
('password', models.CharField(blank=True, default='', max_length=200, verbose_name='رمز عبور')),
('sender', models.CharField(blank=True, default='', max_length=50, verbose_name='شماره خط فرستنده')),
('api_token', models.CharField(blank=True, default='', max_length=255, verbose_name='توکن API (HTTP)')),
('timeout_seconds', models.PositiveIntegerField(default=30, verbose_name='مهلت ارتباط (ثانیه)')),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'تنظیمات سامانه پیامک',
'verbose_name_plural': 'تنظیمات سامانه پیامک',
'permissions': [('ManageSMS', 'مدیریت پیامک')],
},
),
migrations.CreateModel(
name='SMSTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sms_type', models.CharField(choices=[('welcome', 'خوش\u200cآمدگویی (ایجاد مشتری)'), ('cheque_due', 'یادآوری سررسید چک'), ('nesiye_due', 'یادآوری سررسید نسیه'), ('cheque_registration', 'یادآوری تأمین موجودی چک'), ('debt_due', 'یادآوری بدهی (سررسید وصول مطالبات)'), ('visit', 'یادآوری بازدید خدمات'), ('survey', 'نظرسنجی پس از فاکتور'), ('custom', 'پیام سفارشی')], max_length=40, unique=True, verbose_name='نوع پیامک')),
('title', models.CharField(max_length=100, verbose_name='عنوان')),
('text', models.TextField(help_text='متغیرهای قابل استفاده: {name} نام مشتری، {amount} مبلغ، {amount_sep} مبلغ با جداکننده، {date} تاریخ جلالی، {days} تعداد روز، {check_row} شماره ردیف چک، {owner_name} نام مالک چک.', verbose_name='متن پیامک')),
('is_enabled', models.BooleanField(default=True, verbose_name='فعال')),
('offset_days', models.PositiveSmallIntegerField(default=2, verbose_name='چند روز قبل از سررسید ارسال شود')),
('repeat_interval_days', models.PositiveSmallIntegerField(blank=True, help_text='خالی بگذارید برای ارسال یک\u200cباره؛ مقدار بدهید برای ارسال تکرارشونده.', null=True, verbose_name='فاصله تکرار (روز)')),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'قالب پیامک',
'verbose_name_plural': 'قالب\u200cهای پیامک',
},
),
migrations.AddField(
model_name='phonenumber',
name='is_primary',
field=models.BooleanField(default=False, verbose_name='شماره اصلی (گیرنده پیامک)'),
),
migrations.CreateModel(
name='SMSQueue',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sms_type', models.CharField(blank=True, choices=[('welcome', 'خوش\u200cآمدگویی (ایجاد مشتری)'), ('cheque_due', 'یادآوری سررسید چک'), ('nesiye_due', 'یادآوری سررسید نسیه'), ('cheque_registration', 'یادآوری تأمین موجودی چک'), ('debt_due', 'یادآوری بدهی (سررسید وصول مطالبات)'), ('visit', 'یادآوری بازدید خدمات'), ('survey', 'نظرسنجی پس از فاکتور'), ('custom', 'پیام سفارشی')], default='', max_length=40, verbose_name='نوع')),
('phone_number', models.CharField(max_length=20, verbose_name='شماره گیرنده')),
('message', models.TextField(verbose_name='متن')),
('status', models.CharField(choices=[('pending', 'در انتظار'), ('processing', 'در حال پردازش'), ('sent', 'ارسال شده'), ('failed', 'ناموفق'), ('cancelled', 'لغو شده')], db_index=True, default='pending', max_length=12, verbose_name='وضعیت')),
('attempts', models.PositiveSmallIntegerField(default=0, verbose_name='تعداد تلاش')),
('max_attempts', models.PositiveSmallIntegerField(default=5, verbose_name='حداکثر تلاش')),
('next_retry_at', models.DateTimeField(blank=True, null=True, verbose_name='زمان تلاش بعدی')),
('last_error', models.TextField(blank=True, default='', verbose_name='آخرین خطا')),
('provider_id', models.CharField(blank=True, default='', max_length=100)),
('provider_code', models.CharField(blank=True, default='', max_length=50)),
('dedup_key', models.CharField(blank=True, help_text='کلید یکتاسازی برای جلوگیری از ارسال تکراری.', max_length=120, null=True, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('sent_at', models.DateTimeField(blank=True, null=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
('customer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sms_queue', to='admin_panel.customer', verbose_name='مشتری')),
],
options={
'verbose_name': 'صف پیامک',
'verbose_name_plural': 'صف پیامک\u200cها',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='SMSLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attempt_no', models.PositiveSmallIntegerField(default=1, verbose_name='شماره تلاش')),
('success', models.BooleanField(default=False, verbose_name='موفق')),
('provider_id', models.CharField(blank=True, default='', max_length=100)),
('provider_code', models.CharField(blank=True, default='', max_length=50)),
('error', models.TextField(blank=True, default='', verbose_name='خطا')),
('created_at', models.DateTimeField(auto_now_add=True)),
('queue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='admin_panel.smsqueue', verbose_name='صف')),
],
options={
'verbose_name': 'لاگ پیامک',
'verbose_name_plural': 'لاگ\u200cهای پیامک',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='CustomerSMSSetting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sms_type', models.CharField(choices=[('welcome', 'خوش\u200cآمدگویی (ایجاد مشتری)'), ('cheque_due', 'یادآوری سررسید چک'), ('nesiye_due', 'یادآوری سررسید نسیه'), ('cheque_registration', 'یادآوری تأمین موجودی چک'), ('debt_due', 'یادآوری بدهی (سررسید وصول مطالبات)'), ('visit', 'یادآوری بازدید خدمات'), ('survey', 'نظرسنجی پس از فاکتور'), ('custom', 'پیام سفارشی')], max_length=40, verbose_name='نوع پیامک')),
('is_enabled', models.BooleanField(default=True, verbose_name='فعال')),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sms_settings', to='admin_panel.customer')),
],
options={
'verbose_name': 'تنظیم پیامک مشتری',
'verbose_name_plural': 'تنظیمات پیامک مشتریان',
},
),
migrations.AddIndex(
model_name='smsqueue',
index=models.Index(fields=['status', 'next_retry_at'], name='admin_panel_status_c227cc_idx'),
),
migrations.AlterUniqueTogether(
name='customersmssetting',
unique_together={('customer', 'sms_type')},
),
]
@@ -0,0 +1,115 @@
"""دادهٔ اولیه: متن پیش‌فرض قالب‌های پیامک + علامت‌گذاری شمارهٔ اصلی از فیلدهای قدیمی.
این مایگریشن idempotent است (get_or_create / update_if) و اجرای دوبارهٔ آن بی‌خطر است.
"""
from django.db import migrations
DEFAULT_TEMPLATES = [
{
"sms_type": "welcome",
"title": "خوش‌آمدگویی",
"offset_days": 0,
"repeat_interval_days": None,
"text": "{name} عزیز، از خرید شما سپاسگزاریم. به‌امید دیدار دوباره.",
},
{
"sms_type": "cheque_due",
"title": "یادآوری سررسید چک",
"offset_days": 2,
"repeat_interval_days": None,
"text": "{name} عزیز، {days} روز تا سررسید چک شما به مبلغ {amount_sep} ریال باقی مانده است. لطفاً نسبت به تأمین موجودی اقدام فرمایید.",
},
{
"sms_type": "nesiye_due",
"title": "یادآوری سررسید نسیه",
"offset_days": 2,
"repeat_interval_days": None,
"text": "{name} عزیز، {days} روز تا سررسید بدهی شما به مبلغ {amount_sep} ریال باقی مانده است.",
},
{
"sms_type": "cheque_registration",
"title": "یادآوری تأمین موجودی چک",
"offset_days": 2,
"repeat_interval_days": 3,
"text": "{name} عزیز، سررسید چک شما ({check_row}) نزدیک است. لطفاً موجودی بانکی حساب خود را تأمین فرمایید.",
},
{
"sms_type": "debt_due",
"title": "یادآوری بدهی",
"offset_days": 0,
"repeat_interval_days": None,
"text": "{name} عزیز، مبلغ {amount_sep} ریال بدهی شما باقی مانده است. لطفاً نسبت به تسویه اقدام فرمایید.",
},
{
"sms_type": "visit",
"title": "یادآوری بازدید خدمات",
"offset_days": 0,
"repeat_interval_days": None,
"text": "{name} عزیز، زمان بازدید دوره‌ای دستگاه شما فرا رسیده است. برای تعیین وقت با ما تماس بگیرید.",
},
{
"sms_type": "survey",
"title": "نظرسنجی",
"offset_days": 2,
"repeat_interval_days": None,
"text": "{name} عزیز، نظر شما دربارهٔ خدمات ما برایمان ارزشمند است. لطفاً در نظرسنجی شرکت فرمایید.",
},
{
"sms_type": "custom",
"title": "پیام سفارشی",
"offset_days": 0,
"repeat_interval_days": None,
"text": "",
},
]
def seed_templates(apps, schema_editor):
SMSTemplate = apps.get_model("admin_panel", "SMSTemplate")
for item in DEFAULT_TEMPLATES:
SMSTemplate.objects.get_or_create(
sms_type=item["sms_type"],
defaults={
"title": item["title"],
"text": item["text"],
"is_enabled": True,
"offset_days": item["offset_days"],
"repeat_interval_days": item["repeat_interval_days"],
},
)
def unseed_templates(apps, schema_editor):
SMSTemplate = apps.get_model("admin_panel", "SMSTemplate")
SMSTemplate.objects.filter(
sms_type__in=[t["sms_type"] for t in DEFAULT_TEMPLATES]
).delete()
def backfill_primary_phones(apps, schema_editor):
"""اگر هیچ شمارهٔ اصلی‌ای ثبت نشده، شمارهٔ مطابق phone1 هر مشتری را اصلی می‌کند."""
Customer = apps.get_model("admin_panel", "Customer")
PhoneNumber = apps.get_model("admin_panel", "PhoneNumber")
for customer in Customer.objects.all():
if PhoneNumber.objects.filter(customer=customer, is_primary=True).exists():
continue
phone1 = (customer.phone1 or "").strip()
if not phone1:
continue
PhoneNumber.objects.filter(customer=customer, phone_number=phone1).update(is_primary=True)
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("admin_panel", "0057_sms"),
]
operations = [
migrations.RunPython(seed_templates, unseed_templates),
migrations.RunPython(backfill_primary_phones, noop),
]
@@ -0,0 +1,49 @@
# Generated by Django 3.2.25 on 2026-10-09 17:08
from django.db import migrations, models
def stamp_scan_since(apps, schema_editor):
"""روی نصب‌های موجود، خط شروع را روی «همین حالا» مهر می‌کند.
نتیجه: پس از به‌روزرسانی، موارد قدیمی موجود در دیتابیس یک‌باره در صف قرار نمی‌گیرند.
"""
from django.utils import timezone
SMSProviderConfig = apps.get_model('admin_panel', 'SMSProviderConfig')
SMSProviderConfig.objects.filter(scan_since__isnull=True).update(scan_since=timezone.now())
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0058_seed_sms_templates'),
]
operations = [
migrations.AddField(
model_name='smsproviderconfig',
name='allow_backlog',
field=models.BooleanField(default=False, help_text='اگر فعال باشد، مواردی که پیش از «خط شروع» واجد شرط شده\u200cاند هم (به\u200cاندازهٔ سقف زیر، در هر اجرا) ارسال می\u200cشوند. به\u200cصورت پیش\u200cفرض خاموش است.', verbose_name='اجازهٔ ارسال موارد قدیمی'),
),
migrations.AddField(
model_name='smsproviderconfig',
name='backlog_max',
field=models.PositiveIntegerField(default=0, help_text='فقط وقتی «اجازهٔ ارسال موارد قدیمی» روشن است معنا دارد. صفر = هیچ.', verbose_name='حداکثر موارد قدیمی در هر اجرا'),
),
migrations.AddField(
model_name='smsproviderconfig',
name='max_per_run',
field=models.PositiveIntegerField(default=200, help_text='سقف کلی صف\u200cکردن برای هر نوع پیامک در هر اجرای اسکنر.', verbose_name='حداکثر پیامک هر اسکنر در هر اجرا'),
),
migrations.AddField(
model_name='smsproviderconfig',
name='scan_since',
field=models.DateTimeField(blank=True, help_text='فقط مواردی که پنجرهٔ یادآوری\u200cشان از این لحظه به بعد باز می\u200cشود در صف قرار می\u200cگیرند؛ موارد قدیمی\u200cتر (قبل از این لحظه) نادیده گرفته می\u200cشوند. اگر خالی باشد، در نخستین اجرا به\u200cصورت خودکار روی «همین حالا» تنظیم می\u200cشود.', null=True, verbose_name='خط شروع ارسال'),
),
migrations.AddField(
model_name='smsproviderconfig',
name='send_interval_seconds',
field=models.PositiveIntegerField(default=3, help_text='worker پس از هر ارسال این مدت صبر می\u200cکند تا سرویس\u200cدهنده زیر بار نرود.', verbose_name='فاصلهٔ بین هر پیامک (ثانیه)'),
),
migrations.RunPython(stamp_scan_since, migrations.RunPython.noop),
]
@@ -0,0 +1,32 @@
# Generated by Django 3.2.25 on 2026-10-09 17:15
from django.db import migrations
def rename_manage_sms_permission(apps, schema_editor):
"""نام نمایشی پرمیشن را با قرارداد پروژه هم‌راستا می‌کند.
Django هنگام ``AlterModelOptions`` رکورد Permission موجود را تغییر نمی‌دهد؛
پس نام قبلی (فارسی) روی نصب‌های موجود باقی می‌ماند و باید صریحاً به‌روزرسانی شود.
"""
Permission = apps.get_model('auth', 'Permission')
Permission.objects.filter(
content_type__app_label='admin_panel',
content_type__model='smsproviderconfig',
codename='ManageSMS',
).update(name='ManageSMS')
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0059_sms_flow_control'),
]
operations = [
migrations.AlterModelOptions(
name='smsproviderconfig',
options={'permissions': [('ManageSMS', 'ManageSMS')], 'verbose_name': 'تنظیمات سامانه پیامک', 'verbose_name_plural': 'تنظیمات سامانه پیامک'},
),
migrations.RunPython(rename_manage_sms_permission, migrations.RunPython.noop),
]
@@ -0,0 +1,119 @@
"""یکسان‌سازی شماره‌های اصلی + زمان‌بندی اختصاصی هر نوع پیامک.
* «شمارهٔ اصلی» از رکورد ``PhoneNumber`` به خودِ ``Customer`` منتقل می‌شود و با مقدارِ
نرمال‌شدهٔ شماره ذخیره می‌گردد تا با افزودن/حذف/ویرایش مداوم مشتریان پایدار بماند.
* فیلد زمانی هر قالب با معنای واقعی همان نوع هم‌راستا می‌شود (مثلاً نظرسنجی = ۳ روز بعد
از صدور فاکتور، و یادآوری ثبت چک = هر ۳ روز تا ثبت اطلاعات چک).
"""
from django.db import migrations, models
def _normalize(raw):
"""نرمال‌سازی شماره (بدون وابستگی به کد اپ، برای اجرای امن روی دادهٔ قدیمی)."""
from integrations.sms.normalize import normalize_mobile
return normalize_mobile(raw)
def backfill_primary_phones(apps, schema_editor):
"""پرچم‌های ``is_primary`` موجود را به مقدار نرمال‌شده روی مشتری منتقل می‌کند."""
PhoneNumber = apps.get_model("admin_panel", "PhoneNumber")
for customer_id, phone_number in (
PhoneNumber.objects.filter(is_primary=True)
.values_list("customer_id", "phone_number")
):
normalized = _normalize((phone_number or "").strip())
if not normalized:
continue
customer = apps.get_model("admin_panel", "Customer").objects.filter(pk=customer_id).first()
if customer is None:
continue
current = [p for p in (customer.sms_primary_phones or "").split(",") if p]
if normalized in current:
continue
current.append(normalized)
customer.sms_primary_phones = ",".join(current)
customer.save(update_fields=["sms_primary_phones"])
def normalize_template_timing(apps, schema_editor):
"""مقادیر زمانیِ بی‌معنی را با معنای درست هر نوع پیامک هم‌راستا می‌کند.
فقط مقادیری که برای آن نوع معنا ندارند اصلاح می‌شوند؛ تنظیمات معنادارِ مدیر دست‌نخورده
می‌ماند.
"""
SMSTemplate = apps.get_model("admin_panel", "SMSTemplate")
# نظرسنجی: «۳ روز بعد از صدور فاکتور» (مقدار پیشین ۲ بود).
SMSTemplate.objects.filter(sms_type="survey", offset_days=2).update(offset_days=3)
# یادآوری ثبت چک: فاصله تکرار ۳ روز و بدون offset سررسید.
for template in SMSTemplate.objects.filter(sms_type="cheque_registration"):
template.offset_days = 0
if not template.repeat_interval_days:
template.repeat_interval_days = 3
template.save(update_fields=["offset_days", "repeat_interval_days"])
# یادآوری بازدید: دورهٔ پیش‌فرض ۹ ماه (قابل تغییر از پنل).
SMSTemplate.objects.filter(
sms_type="visit", repeat_interval_months__isnull=True,
).update(repeat_interval_months=9)
# متن «یادآوری ثبت چک» به معنای جدید (ثبت اطلاعات چک) هم‌راستا می‌شود.
SMSTemplate.objects.filter(sms_type="cheque_registration").update(
title="یادآوری ثبت اطلاعات چک",
text="{name} عزیز، برای چک با سررسید {date} هنوز اطلاعات ثبت چک "
"(شمارهٔ صیادی) در سامانه ثبت نشده است. لطفاً نسبت به ثبت آن اقدام فرمایید.",
)
# سایر انواع: هیچ فیلد زمانی نباید مقدار داشته باشد.
SMSTemplate.objects.filter(
sms_type__in=["welcome", "debt_due", "custom"],
).update(offset_days=0, repeat_interval_days=None, repeat_interval_months=None)
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("admin_panel", "0060_sms_permission_label"),
]
operations = [
migrations.AddField(
model_name="customer",
name="sms_primary_phones",
field=models.CharField(
blank=True, default="", max_length=100,
verbose_name="شماره‌های اصلی پیامک",
help_text="شماره‌های نرمال‌شده (+98…) جدا‌شده با کاما؛ خالی = شمارهٔ اول مشتری.",
),
),
# ترتیب مهم است: ابتدا مقادیر فعلی خوانده و منتقل می‌شوند، سپس ستون قدیمی حذف می‌گردد.
migrations.RunPython(backfill_primary_phones, noop),
migrations.RemoveField(model_name="phonenumber", name="is_primary"),
migrations.AddField(
model_name="smstemplate",
name="repeat_interval_months",
field=models.PositiveSmallIntegerField(
blank=True, null=True,
verbose_name="دورهٔ تکرار (ماه)",
help_text="برای «یادآوری بازدید»: چند ماه یک‌بار بازدید تکرار شود. "
"خالی = پیش‌فرض سامانه (۹ ماه).",
),
),
migrations.AlterField(
model_name="smstemplate",
name="offset_days",
field=models.PositiveSmallIntegerField(
default=0,
verbose_name="فاصلهٔ زمانی (روز)",
help_text="معنای این عدد برای هر نوع پیامک متفاوت است و در پنل توضیح داده "
"می‌شود؛ مثلاً «چند روز قبل از سررسید» یا «چند روز بعد از صدور فاکتور».",
),
),
migrations.RunPython(normalize_template_timing, noop),
]
@@ -0,0 +1,23 @@
# Generated by Django 3.2.25 on 2026-10-09 18:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0061_sms_timing_and_primary'),
]
operations = [
migrations.AlterField(
model_name='smsproviderconfig',
name='api_url',
field=models.CharField(blank=True, default='http://www.0098sms.com/sendsmslink.aspx', help_text='برای 0098SMS همان آدرس لینکی ارسال است؛ این سرویس WSDL/SOAP ندارد.', max_length=500, verbose_name='آدرس سرویس پیامک (URL)'),
),
migrations.AlterField(
model_name='smsproviderconfig',
name='provider',
field=models.CharField(choices=[('sms0098', '0098SMS (سرویس لینکی HTTP)'), ('http', 'HTTP عمومی'), ('console', 'Console (تست/دیباگ)')], default='sms0098', max_length=20, verbose_name='سرویس\u200cدهنده پیامک'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 3.2.25 on 2026-10-10 06:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0062_sms_link_provider'),
]
operations = [
migrations.AlterField(
model_name='smsqueue',
name='status',
field=models.CharField(choices=[('pending', 'در انتظار تأیید'), ('approved', 'تأییدشده'), ('processing', 'در حال پردازش'), ('sent', 'ارسال شده'), ('failed', 'ناموفق'), ('cancelled', 'لغو شده')], db_index=True, default='pending', max_length=12, verbose_name='وضعیت'),
),
]
@@ -0,0 +1,38 @@
# Generated by Django 3.2.25 on 2026-10-10 07:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0063_alter_smsqueue_status'),
]
operations = [
migrations.AddField(
model_name='smsproviderconfig',
name='panel_no',
field=models.CharField(blank=True, default='', help_text='شمارهٔ اختصاصی پنل در وب\u200cسرویس؛ اگر خالی بماند از «شماره خط فرستنده» استفاده می\u200cشود.', max_length=50, verbose_name='شماره پنل (PnlNo)'),
),
migrations.AddField(
model_name='smsproviderconfig',
name='webservice_url',
field=models.CharField(blank=True, default='https://webservice.0098sms.com/service.asmx', help_text='فقط برای provider «وب\u200cسرویس» لازم است؛ ارسال با شناسه (smsid) و استعلام دلیوری از همین آدرس انجام می\u200cشود.', max_length=500, verbose_name='آدرس وب\u200cسرویس (SOAP/WSDL)'),
),
migrations.AddField(
model_name='smsqueue',
name='delivery_checked_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='زمان آخرین استعلام دلیوری'),
),
migrations.AddField(
model_name='smsqueue',
name='delivery_status',
field=models.CharField(blank=True, choices=[('pending', 'در انتظار استعلام'), ('delivered', 'رسیده به گوشی'), ('not_delivered', 'نرسیده به گوشی'), ('expired', 'منقضی\u200cشده'), ('unknown', 'نامشخص')], db_index=True, default='', help_text='خالی = بدون پیگیری. مقدار از استعلام smsdeliveryState پر می\u200cشود.', max_length=16, verbose_name='وضعیت تحویل'),
),
migrations.AlterField(
model_name='smsproviderconfig',
name='provider',
field=models.CharField(choices=[('sms0098', '0098SMS (سرویس لینکی HTTP)'), ('sms0098ws', '0098SMS (وب\u200cسرویس + استعلام دلیوری)'), ('http', 'HTTP عمومی'), ('console', 'Console (تست/دیباگ)')], default='sms0098', max_length=20, verbose_name='سرویس\u200cدهنده پیامک'),
),
]
@@ -0,0 +1,35 @@
# Generated by Django 3.2.25 on 2026-10-10 07:48
from django.db import migrations, models
def scrub_brand(apps, schema_editor):
"""حذف نام برند از متن قالب‌هایی که پیش‌تر با «کتیبه» ذخیره شده‌اند."""
SMSTemplate = apps.get_model("admin_panel", "SMSTemplate")
for template in SMSTemplate.objects.filter(text__contains="کتیبه"):
cleaned = (
template.text.replace("به خانواده کتیبه خوش آمدید. ", "")
.replace(" نزد کتیبه", "")
)
template.text = cleaned
template.save(update_fields=["text"])
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0064_sms_webservice_delivery'),
]
operations = [
migrations.AddField(
model_name='smsproviderconfig',
name='test_message',
field=models.TextField(blank=True, default='پیامک آزمایشی؛ جهت بررسی صحت ارسال از پنل فرستاده شده است.', help_text='متنی که با دکمهٔ «ارسال پیامک آزمایشی» فرستاده می‌شود؛ قابل ویرایش است.', verbose_name='متن پیامک آزمایشی'),
),
migrations.RunPython(scrub_brand, noop),
]
+42
View File
@@ -0,0 +1,42 @@
# Generated by Django 3.2.25 on 2026-10-10 08:05
from django.db import migrations, models
def backfill_keys(apps, schema_editor):
"""کلیدهای صفِ موجود را به دفتر منتقل می‌کند تا حذف بعدی‌شان باعث بازگشت نشود."""
SMSQueue = apps.get_model("admin_panel", "SMSQueue")
SMSDedup = apps.get_model("admin_panel", "SMSDedup")
keys = SMSQueue.objects.exclude(dedup_key__isnull=True).exclude(
dedup_key=""
).values_list("dedup_key", flat=True)
SMSDedup.objects.bulk_create(
[SMSDedup(key=key) for key in keys], ignore_conflicts=True,
)
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0065_smsproviderconfig_test_message'),
]
operations = [
migrations.CreateModel(
name='SMSDedup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=120, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'کلید پردازش\u200cشدهٔ پیامک',
'verbose_name_plural': 'کلیدهای پردازش\u200cشدهٔ پیامک',
},
),
migrations.RunPython(backfill_keys, noop),
]
@@ -0,0 +1,30 @@
# Generated by Django 3.2.25 on 2026-10-10 08:43
from django.db import migrations, models
def scrub_legacy_phones(apps, schema_editor):
"""پاک‌سازی یک‌بارهٔ فیلدهای تلفن قدیمی (idempotent).
همان قاعدهٔ ``admin_panel.sms.phone_cleanup``؛ اینجا فقط مدل از رجیستری
تاریخی گرفته می‌شود. اجرای دوباره بی‌اثر است.
"""
from admin_panel.sms.phone_cleanup import clean_all
clean_all(apps=apps)
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0066_smsdedup'),
]
operations = [
migrations.AddField(
model_name='customer',
name='notes',
field=models.TextField(blank=True, default='', help_text='یادداشت آزاد دربارهٔ مشتری؛ متن‌های حذف‌شدهٔ فیلدهای تلفن هم اینجا ثبت می‌شوند.', verbose_name='توضیحات'),
),
migrations.RunPython(scrub_legacy_phones, migrations.RunPython.noop),
]
@@ -0,0 +1,23 @@
# Generated by Django 3.2.25 on 2026-10-10 09:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0067_customer_notes'),
]
operations = [
migrations.AlterField(
model_name='smsproviderconfig',
name='allow_backlog',
field=models.BooleanField(default=False, help_text='اگر فعال باشد، مواردی که پیش از «تاریخ شروع دوره» واجد شرط شده\u200cاند هم (به\u200cاندازهٔ سقف زیر، در هر اجرا) ارسال می\u200cشوند. به\u200cصورت پیش\u200cفرض خاموش است تا مشتریان قدیمی مشمول این فیلتر شوند.', verbose_name='اجازهٔ ارسال موارد قدیمی'),
),
migrations.AlterField(
model_name='smsproviderconfig',
name='scan_since',
field=models.DateTimeField(blank=True, help_text='فیلتر شروع دوره: فقط مواردی که «پنجرهٔ یادآوری»شان از این لحظه به بعد باز می\u200cشود در صف قرار می\u200cگیرند؛ موارد قدیمی\u200cتر (مثل مشتریان خیلی قدیمی که مدت\u200cهاست بازدید نشده\u200cاند) نادیده گرفته می\u200cشوند. از پنل به شکل تاریخ شمسی ویرایش می\u200cشود و روی «آغاز همان روز» تنظیم می\u200cگردد. اگر خالی باشد، در نخستین اجرا خودکار روی «همین حالا» تنظیم می\u200cشود.', null=True, verbose_name='تاریخ شروع دوره'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 3.2.25 on 2026-10-10 10:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0068_auto_20261010_1305'),
]
operations = [
migrations.AddField(
model_name='smstemplate',
name='auto_send',
field=models.BooleanField(default=False, help_text='اگر خاموش باشد (پیش\u200cفرض)، پیامک\u200cهای این نوع در وضعیت «در انتظار تأیید» در صف می\u200cمانند تا مدیر آن\u200cها را تأیید کند. با روشن\u200cکردن، پیامکِ ساخته\u200cشده توسط اسکنر مستقیماً تأیید و بلافاصله توسط worker ارسال می\u200cشود.', verbose_name='ارسال خودکار (بدون تأیید مدیر)'),
),
]
+1
View File
@@ -17,3 +17,4 @@ from .factors import * # noqa: F401,F403
from .finance import * # noqa: F401,F403
from .actions import * # noqa: F401,F403
from .system import * # noqa: F401,F403
from .sms import * # noqa: F401,F403
+18
View File
@@ -11,6 +11,12 @@ class Customer(models.Model):
]
name_family = models.CharField(max_length=200)
address = models.CharField(max_length=200)
# توضیحات آزاد مشتری؛ متن‌های غیرشماره‌ای که از فیلدهای تلفن پاک‌سازی می‌شوند
# اینجا نگه داشته می‌شوند تا هیچ داده‌ای از سیستم حذف نشود.
notes = models.TextField(
blank=True, default="", verbose_name="توضیحات",
help_text="یادداشت آزاد دربارهٔ مشتری؛ متن‌های حذف‌شدهٔ فیلدهای تلفن هم اینجا ثبت می‌شوند.",
)
phone1 = models.CharField(max_length=20, default='')
phone2 = models.CharField(max_length=20, default='', null=True, blank=True)
phone3 = models.CharField(max_length=20, default='', null=True, blank=True)
@@ -23,6 +29,16 @@ class Customer(models.Model):
# فیلد جدید برای حذف نرم (بدون تداخل با داده‌های قبلی)
is_active = models.BooleanField(default=True, null=True, blank=True, help_text="وضعیت فعالیت مشتری")
# شماره‌های «اصلی» گیرندهٔ پیامک، به‌صورت مقدارِ خودِ شماره (نرمال‌شده، جدا با کاما).
# چرا مقدار و نه کلیدِ رکورد؟ چون دادهٔ مشتریان دائماً افزوده/ویرایش/حذف می‌شود و
# کلید رکورد در بازسازی‌ها از بین می‌رود؛ ولی شماره پایدار است. اگر شماره‌ای حذف یا
# ویرایش شود، این انتخاب خودبه‌خود بی‌اثر می‌شود و سیستم به شمارهٔ اول برمی‌گردد.
sms_primary_phones = models.CharField(
max_length=100, blank=True, default="",
verbose_name="شماره‌های اصلی پیامک",
help_text="شماره‌های نرمال‌شده (+98…) جدا‌شده با کاما؛ خالی = شمارهٔ اول مشتری.",
)
def __str__(self):
return self.name_family
@@ -37,6 +53,8 @@ class PhoneNumber(models.Model):
customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name='phones')
title = models.CharField(max_length=50, default='تلفن', blank=True, null=True, help_text="عنوان شماره (مثل: موبایل پدر)")
phone_number = models.CharField(max_length=20)
# توجه: «شمارهٔ اصلی پیامک» روی خودِ Customer.sms_primary_phones نگه داشته می‌شود
# (کلیدش مقدار شماره است، نه رکورد) تا با افزودن/حذف/ویرایش مداوم مشتریان از بین نرود.
def __str__(self):
return f"{self.title}: {self.phone_number}"
+314
View File
@@ -0,0 +1,314 @@
"""مدل‌های زیرسیستم پیامک.
شامل:
- تنظیمات سامانه پیامک (singleton) با URL/نام‌کاربری/رمز قابل ویرایش از Django Admin.
- قالب متن هر نوع پیامک (قابل ویرایش از پنل).
- تنظیم فعال/غیرفعال هر نوع پیامک به‌ازای هر مشتری.
- صف ارسال (outbox) و لاگ تلاش‌های ارسال.
"""
from django.conf import settings
from django.db import models
class SMSType(models.TextChoices):
"""انواع پیامک‌های سیستم."""
WELCOME = "welcome", "خوش‌آمدگویی (ایجاد مشتری)"
CHEQUE_DUE = "cheque_due", "یادآوری سررسید چک"
NESIYE_DUE = "nesiye_due", "یادآوری سررسید نسیه"
CHEQUE_REGISTRATION = "cheque_registration", "یادآوری تأمین موجودی چک"
DEBT_DUE = "debt_due", "یادآوری بدهی (سررسید وصول مطالبات)"
VISIT = "visit", "یادآوری بازدید خدمات"
SURVEY = "survey", "نظرسنجی پس از فاکتور"
CUSTOM = "custom", "پیام سفارشی"
class SMSProviderConfig(models.Model):
"""تنظیمات سامانه پیامک؛ تک‌رکوردی (pk=1) و قابل ویرایش از Django Admin."""
class Meta:
verbose_name = "تنظیمات سامانه پیامک"
verbose_name_plural = "تنظیمات سامانه پیامک"
permissions = [
# نام نمایشی مطابق قرارداد سایر پرمیشن‌های پروژه (ViewStore/AddCustomer/...)
# تا در فهرست پرمیشن‌های Django Admin به‌سادگی پیدا شود.
("ManageSMS", "ManageSMS"),
]
ProviderChoices = [
("sms0098", "0098SMS (سرویس لینکی HTTP)"),
("sms0098ws", "0098SMS (وب‌سرویس + استعلام دلیوری)"),
("http", "HTTP عمومی"),
("console", "Console (تست/دیباگ)"),
]
provider = models.CharField(
max_length=20, choices=ProviderChoices, default="sms0098",
verbose_name="سرویس‌دهنده پیامک",
)
is_enabled = models.BooleanField(
default=True, verbose_name="فعال بودن ارسال پیامک",
help_text="در صورت غیرفعال بودن، worker هیچ پیامکی ارسال نمی‌کند.",
)
api_url = models.CharField(
max_length=500, blank=True,
default="http://www.0098sms.com/sendsmslink.aspx",
verbose_name="آدرس سرویس پیامک (URL)",
help_text="برای 0098SMS همان آدرس لینکی ارسال است؛ این سرویس WSDL/SOAP ندارد.",
)
username = models.CharField(max_length=100, blank=True, default="", verbose_name="نام کاربری")
password = models.CharField(max_length=200, blank=True, default="", verbose_name="رمز عبور")
sender = models.CharField(max_length=50, blank=True, default="", verbose_name="شماره خط فرستنده")
webservice_url = models.CharField(
max_length=500, blank=True,
default="https://webservice.0098sms.com/service.asmx",
verbose_name="آدرس وب‌سرویس (SOAP/WSDL)",
help_text="فقط برای provider «وب‌سرویس» لازم است؛ ارسال با شناسه (smsid) و استعلام "
"دلیوری از همین آدرس انجام می‌شود.",
)
panel_no = models.CharField(
max_length=50, blank=True, default="",
verbose_name="شماره پنل (PnlNo)",
help_text="شمارهٔ اختصاصی پنل در وب‌سرویس؛ اگر خالی بماند از «شماره خط فرستنده» "
"استفاده می‌شود.",
)
api_token = models.CharField(max_length=255, blank=True, default="", verbose_name="توکن API (HTTP)")
test_message = models.TextField(
blank=True,
default="پیامک آزمایشی؛ جهت بررسی صحت ارسال از پنل فرستاده شده است.",
verbose_name="متن پیامک آزمایشی",
help_text="متنی که با دکمهٔ «ارسال پیامک آزمایشی» فرستاده می‌شود؛ قابل ویرایش است.",
)
timeout_seconds = models.PositiveIntegerField(default=30, verbose_name="مهلت ارتباط (ثانیه)")
# --- کنترل جریان ارسال (جلوگیری از سیل پیامک) ---
scan_since = models.DateTimeField(
null=True, blank=True,
verbose_name="تاریخ شروع دوره",
help_text="فیلتر شروع دوره: فقط مواردی که «پنجرهٔ یادآوری»شان از این لحظه به بعد باز "
"می‌شود در صف قرار می‌گیرند؛ موارد قدیمی‌تر (مثل مشتریان خیلی قدیمی که "
"مدت‌هاست بازدید نشده‌اند) نادیده گرفته می‌شوند. از پنل به شکل تاریخ شمسی "
"ویرایش می‌شود و روی «آغاز همان روز» تنظیم می‌گردد. اگر خالی باشد، در "
"نخستین اجرا خودکار روی «همین حالا» تنظیم می‌شود.",
)
allow_backlog = models.BooleanField(
default=False,
verbose_name="اجازهٔ ارسال موارد قدیمی",
help_text="اگر فعال باشد، مواردی که پیش از «تاریخ شروع دوره» واجد شرط شده‌اند هم "
"(به‌اندازهٔ سقف زیر، در هر اجرا) ارسال می‌شوند. به‌صورت پیش‌فرض خاموش است "
"تا مشتریان قدیمی مشمول این فیلتر شوند.",
)
backlog_max = models.PositiveIntegerField(
default=0, verbose_name="حداکثر موارد قدیمی در هر اجرا",
help_text="فقط وقتی «اجازهٔ ارسال موارد قدیمی» روشن است معنا دارد. صفر = هیچ.",
)
max_per_run = models.PositiveIntegerField(
default=200, verbose_name="حداکثر پیامک هر اسکنر در هر اجرا",
help_text="سقف کلی صف‌کردن برای هر نوع پیامک در هر اجرای اسکنر.",
)
send_interval_seconds = models.PositiveIntegerField(
default=3, verbose_name="فاصلهٔ بین هر پیامک (ثانیه)",
help_text="worker پس از هر ارسال این مدت صبر می‌کند تا سرویس‌دهنده زیر بار نرود.",
)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"تنظیمات پیامک ({self.get_provider_display()})"
def save(self, *args, **kwargs):
# سینگلتون: همیشه فقط یک رکورد با pk=1.
self.pk = 1
# نخستین بارگذاری = لحظهٔ فعال‌سازی؛ خط شروع روی «همین حالا» مهر می‌شود تا
# موارد قدیمی موجود در دیتابیس یک‌باره در صف قرار نگیرند.
if self.scan_since is None:
from django.utils import timezone
self.scan_since = timezone.now()
super().save(*args, **kwargs)
@classmethod
def load(cls):
obj, _ = cls.objects.get_or_create(pk=1)
if obj.scan_since is None:
obj.save(update_fields=["scan_since"])
return obj
class SMSTemplate(models.Model):
"""قالب متن هر نوع پیامک. متن قابل ویرایش از پنل است."""
class Meta:
verbose_name = "قالب پیامک"
verbose_name_plural = "قالب‌های پیامک"
sms_type = models.CharField(
max_length=40, choices=SMSType.choices, unique=True, verbose_name="نوع پیامک",
)
title = models.CharField(max_length=100, verbose_name="عنوان")
text = models.TextField(
verbose_name="متن پیامک",
help_text="متغیرهای قابل استفاده: {name} نام مشتری، {amount} مبلغ، "
"{amount_sep} مبلغ با جداکننده، {date} تاریخ جلالی، {days} تعداد روز، "
"{check_row} شماره ردیف چک، {owner_name} نام مالک چک.",
)
is_enabled = models.BooleanField(default=True, verbose_name="فعال")
auto_send = models.BooleanField(
default=False,
verbose_name="ارسال خودکار (بدون تأیید مدیر)",
help_text="اگر خاموش باشد (پیش‌فرض)، پیامک‌های این نوع در وضعیت «در انتظار تأیید» "
"در صف می‌مانند تا مدیر آن‌ها را تأیید کند. با روشن‌کردن، پیامکِ ساخته‌شده "
"توسط اسکنر مستقیماً تأیید و بلافاصله توسط worker ارسال می‌شود.",
)
offset_days = models.PositiveSmallIntegerField(
default=0,
verbose_name="فاصلهٔ زمانی (روز)",
help_text="معنای این عدد برای هر نوع پیامک متفاوت است و در پنل توضیح داده می‌شود؛ "
"مثلاً «چند روز قبل از سررسید» یا «چند روز بعد از صدور فاکتور».",
)
repeat_interval_days = models.PositiveSmallIntegerField(
null=True, blank=True,
verbose_name="فاصله تکرار (روز)",
help_text="خالی بگذارید برای ارسال یک‌باره؛ مقدار بدهید برای ارسال تکرارشونده.",
)
repeat_interval_months = models.PositiveSmallIntegerField(
null=True, blank=True,
verbose_name="دورهٔ تکرار (ماه)",
help_text="برای «یادآوری بازدید»: چند ماه یک‌بار بازدید تکرار شود. خالی = پیش‌فرض سامانه (۹ ماه).",
)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.title
class CustomerSMSSetting(models.Model):
"""تنظیم فعال/غیرفعال هر نوع پیامک برای یک مشتری (مدل opt-out).
نبودِ رکورد برای یک (مشتری، نوع) یعنی فعال. تنها تغییرهای صریح ذخیره می‌شوند.
"""
class Meta:
verbose_name = "تنظیم پیامک مشتری"
verbose_name_plural = "تنظیمات پیامک مشتریان"
unique_together = ("customer", "sms_type")
customer = models.ForeignKey(
"Customer", on_delete=models.CASCADE, related_name="sms_settings",
)
sms_type = models.CharField(max_length=40, choices=SMSType.choices, verbose_name="نوع پیامک")
is_enabled = models.BooleanField(default=True, verbose_name="فعال")
def __str__(self):
state = "فعال" if self.is_enabled else "غیرفعال"
return f"{self.customer} - {self.get_sms_type_display()} ({state})"
class SMSQueue(models.Model):
"""صف دیتابیسی ارسال پیامک (outbox). توسط worker تخلیه می‌شود."""
class Status(models.TextChoices):
PENDING = "pending", "در انتظار تأیید" # در صف بازبینی مدیر
APPROVED = "approved", "تأییدشده" # تأیید مدیر؛ آمادهٔ ارسال توسط worker
PROCESSING = "processing", "در حال پردازش"
SENT = "sent", "ارسال شده"
FAILED = "failed", "ناموفق"
CANCELLED = "cancelled", "لغو شده"
class DeliveryStatus(models.TextChoices):
"""وضعیت تحویل واقعی به گوشی (از استعلام web service، فقط اگر smsid داشته باشیم)."""
PENDING = "pending", "در انتظار استعلام"
DELIVERED = "delivered", "رسیده به گوشی"
NOT_DELIVERED = "not_delivered", "نرسیده به گوشی"
EXPIRED = "expired", "منقضی‌شده"
UNKNOWN = "unknown", "نامشخص"
customer = models.ForeignKey(
"Customer", on_delete=models.SET_NULL, null=True, blank=True,
related_name="sms_queue", verbose_name="مشتری",
)
sms_type = models.CharField(
max_length=40, choices=SMSType.choices, blank=True, default="", verbose_name="نوع",
)
phone_number = models.CharField(max_length=20, verbose_name="شماره گیرنده")
message = models.TextField(verbose_name="متن")
status = models.CharField(
max_length=12, choices=Status.choices, default=Status.PENDING,
db_index=True, verbose_name="وضعیت",
)
attempts = models.PositiveSmallIntegerField(default=0, verbose_name="تعداد تلاش")
max_attempts = models.PositiveSmallIntegerField(default=5, verbose_name="حداکثر تلاش")
next_retry_at = models.DateTimeField(null=True, blank=True, verbose_name="زمان تلاش بعدی")
last_error = models.TextField(blank=True, default="", verbose_name="آخرین خطا")
provider_id = models.CharField(max_length=100, blank=True, default="")
provider_code = models.CharField(max_length=50, blank=True, default="")
dedup_key = models.CharField(
max_length=120, unique=True, null=True, blank=True,
help_text="کلید یکتاسازی برای جلوگیری از ارسال تکراری.",
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True,
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
sent_at = models.DateTimeField(null=True, blank=True)
# --- رهگیری تحویل (فقط برای providerهایی که شناسهٔ پیامک برمی‌گردانند) ---
delivery_status = models.CharField(
max_length=16, choices=DeliveryStatus.choices, blank=True, default="",
db_index=True, verbose_name="وضعیت تحویل",
help_text="خالی = بدون پیگیری. مقدار از استعلام smsdeliveryState پر می‌شود.",
)
delivery_checked_at = models.DateTimeField(
null=True, blank=True, verbose_name="زمان آخرین استعلام دلیوری",
)
class Meta:
verbose_name = "صف پیامک"
verbose_name_plural = "صف پیامک‌ها"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["status", "next_retry_at"]),
]
def __str__(self):
return f"{self.get_sms_type_display() or self.sms_type} → {self.phone_number} [{self.status}]"
class SMSDedup(models.Model):
"""دفتر کلیدهای یکتاسازیِ *همیشه‌ثبت‌شده*.
``SMSQueue.dedup_key`` تنها تا وقتی جلوی تکرار را می‌گیرد که رکورد صف باقی
باشد؛ با حذف رکورد از صف (رد کردن پیامک توسط مدیر) کلید هم پاک می‌شد و اسکنر
در اجرای بعدی همان پیامک را دوباره می‌ساخت. این دفتر مستقل از صف است و هرگز
حذف نمی‌شود، پس پیامکِ حذف‌شده دیگر بازنمی‌گردد.
"""
class Meta:
verbose_name = "کلید پردازش‌شدهٔ پیامک"
verbose_name_plural = "کلیدهای پردازش‌شدهٔ پیامک"
key = models.CharField(max_length=120, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.key
class SMSLog(models.Model):
"""لاگ سطح-تلاش برای هر بار ارسال توسط worker."""
class Meta:
verbose_name = "لاگ پیامک"
verbose_name_plural = "لاگ‌های پیامک"
ordering = ["-created_at"]
queue = models.ForeignKey(
SMSQueue, on_delete=models.CASCADE, related_name="logs", verbose_name="صف",
)
attempt_no = models.PositiveSmallIntegerField(default=1, verbose_name="شماره تلاش")
success = models.BooleanField(default=False, verbose_name="موفق")
provider_id = models.CharField(max_length=100, blank=True, default="")
provider_code = models.CharField(max_length=50, blank=True, default="")
error = models.TextField(blank=True, default="", verbose_name="خطا")
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
state = "موفق" if self.success else "ناموفق"
return f"#{self.queue_id} تلاش {self.attempt_no} ({state})"
+19
View File
@@ -0,0 +1,19 @@
"""سیگنال‌های اپ admin_panel."""
import logging
from django.db.backends.signals import connection_created
from django.dispatch import receiver
logger = logging.getLogger(__name__)
@receiver(connection_created)
def configure_sqlite_connection(sender, connection, **kwargs):
"""افزایش busy_timeout برای SQLite تا نوشتن هم‌زمان web/worker/scheduler خطا ندهد."""
if connection.vendor != "sqlite":
return
try:
with connection.cursor() as cursor:
cursor.execute("PRAGMA busy_timeout=30000;")
except Exception:
logger.warning("تنظیم busy_timeout برای SQLite ناموفق بود.", exc_info=True)
+1
View File
@@ -0,0 +1 @@
"""منطق دامنهٔ زیرسیستم پیامک (رندر قالب، گیرندگان، قواعد، صف و اسکنر)."""
+74
View File
@@ -0,0 +1,74 @@
"""قرار دادن پیامک در صف ارسال (insert-only، بی‌خطر برای مسیر request)."""
import logging
from uuid import uuid4
from django.db import IntegrityError
from ..models import SMSDedup, SMSQueue, SMSTemplate, SMSType
from .recipients import resolve_recipients
from .render import render_template
from .rules import is_sms_enabled
logger = logging.getLogger(__name__)
def enqueue_sms(*, customer=None, sms_type, context=None, dedup_key=None,
message=None, recipients=None, created_by=None, approved=False):
"""پیامک را در صف قرار می‌دهد و تعداد رکوردهای ساخته‌شده را برمی‌گرداند.
به‌صورت پیش‌فرض پیامک در وضعیت «در انتظار تأیید» ثبت می‌شود و تا وقتی مدیر
آن را از پنل تأیید نکند، worker آن را ارسال نمی‌کند. دو راه برای پرش از این
بازبینی هست: ``approved=True`` برای ارسال‌های دستیِ صریح مدیر (مثل پیامک سفارشی
یا آزمایشی)، و ``SMSTemplate.auto_send`` که مدیر در صفحهٔ قالب‌ها برای هر نوع
پیامک روشن می‌کند تا پیامک‌های خودکارِ اسکنر بدون صف تأیید ارسال شوند.
هیچ استثنایی به فراخوان پرتاب نمی‌شود؛ خطاها فقط لاگ می‌شوند تا مسیر اصلی
(مثلاً ایجاد مشتری یا اسکنر) هرگز به‌خاطر پیامک مختل نشود.
"""
try:
if message is None:
template = SMSTemplate.objects.filter(sms_type=sms_type, is_enabled=True).first()
if template is None:
return 0
message = render_template(template.text, context)
# «ارسال خودکار» هر نوع پیامک در صفحهٔ قالب‌ها تعیین می‌شود؛ خاموش = بازبینی مدیر.
approved = approved or template.auto_send
if not is_sms_enabled(customer, sms_type):
return 0
numbers = recipients if recipients is not None else resolve_recipients(customer)
if not numbers:
return 0
if dedup_key is None:
dedup_key = f"{sms_type}:{uuid4().hex}"
created = 0
for number in numbers:
key = dedup_key if len(numbers) == 1 else f"{dedup_key}:{number}"
try:
# دفتر کلیدها مستقل از صف است: کلیدِ یک‌بار خورده هرگز دوباره
# ایجاد نمی‌شود، حتی اگر رکورد صف را مدیر حذف کرده باشد.
if not SMSDedup.objects.get_or_create(key=key)[1]:
continue
_, made = SMSQueue.objects.get_or_create(
dedup_key=key,
defaults={
"customer": customer,
"sms_type": sms_type,
"phone_number": number,
"message": message,
"created_by": created_by,
"status": (SMSQueue.Status.APPROVED if approved
else SMSQueue.Status.PENDING),
},
)
created += 1 if made else 0
except IntegrityError:
# رقابت هم‌زمان روی dedup_key؛ نادیده گرفته می‌شود.
continue
return created
except Exception:
logger.exception("enqueue_sms برای نوع=%s شکست خورد", sms_type)
return 0
+59
View File
@@ -0,0 +1,59 @@
"""پیام‌های نتیجهٔ عملیات پنل پیامک.
جایگزین flash message جنگو است: پیام‌های جنگو در session ذخیره می‌شوند و اگر
قالب مقصد آن‌ها را رندر نکند، تا صفحهٔ بعدِ دارای بلوک پیام «سرگردان» می‌مانند و
بی‌ربط به عملیات ظاهر می‌شوند. این‌جا پیام با یک کد در querystring
(``?notice=<code>``) رد و بدل می‌شود؛ چون کد فقط روی همان URL وجود دارد، پیام هم
فقط همان‌جا دیده می‌شود و هیچ‌جا سرگردان نمی‌ماند.
مقدار ``{placeholder}`` در متن از پارامترهای querystring پر می‌شود
(نبودِ پارامتر ⇒ رشتهٔ خالی).
"""
from collections import defaultdict
# کد → (شدت، متن). شدت به کلاس Bulma نگاشت می‌شود.
NOTICES = {
# --- تنظیمات و ارسال آزمایشی ---
"test_sent": ("success", "پیامک آزمایشی در صف ارسال قرار گرفت."),
"settings_saved": ("success", "تنظیمات سامانه پیامک ذخیره شد."),
# --- قالب‌ها ---
"tpl_saved": ("success", "قالب «{title}» ذخیره شد."),
"tpl_bad_timing": ("error", "مقدار زمان‌بندی باید یک عدد صحیح باشد."),
# --- مشتری ---
"customer_sms_saved": ("success", "تنظیم پیامک مشتری به‌روزرسانی شد."),
"primary_updated": ("success", "شمارهٔ اصلی پیامک به‌روزرسانی شد."),
"primary_not_found": ("error", "شماره موردنظر برای این مشتری یافت نشد."),
"custom_queued": ("success", "پیامک سفارشی در صف ارسال قرار گرفت."),
"custom_empty": ("error", "متن پیامک خالی است."),
"custom_no_phone": ("error", "شماره اصلی معتبری برای این مشتری یافت نشد."),
# --- صف ---
"approved": ("success", "پیامک تأیید شد و برای ارسال در صف قرار گرفت."),
"not_pending": ("warning", "این پیامک در وضعیت «در انتظار تأیید» نیست."),
"deleted": ("success", "پیامک از صف حذف شد."),
"bulk_approved_all": ("success", "{count} پیامک تأیید شد و برای ارسال در صف قرار گرفت."),
"bulk_approved": ("success", "{count} پیامک تأیید شد."),
"bulk_deleted": ("success", "{count} پیامک از صف حذف شد."),
"invalid_action": ("error", "اقدام نامعتبر است."),
"queue_emptied": ("success", "کل صف خالی شد ({count} پیامک حذف شد)."),
"retried": ("success", "پیامک برای ارسال مجدد تأیید شد."),
}
_KIND_CLASSES = {
"success": "is-success",
"error": "is-danger",
"warning": "is-warning",
"info": "is-info",
}
def resolve(request):
"""کد ``?notice=`` را به متن/کلاس قابل رندر در قالب تبدیل می‌کند."""
get = getattr(request, "GET", None) or {}
entry = NOTICES.get((get.get("notice") or "").strip())
if not entry:
return {}
kind, text = entry
return {
"notice_text": text.format_map(defaultdict(str, get)),
"notice_kind": _KIND_CLASSES.get(kind, "is-info"),
}
+139
View File
@@ -0,0 +1,139 @@
"""پاک‌سازی یک‌بارهٔ شماره‌های قدیمی مشتریان (فیلدهای ``phone1``/``phone2``/``phone3``).
سال‌ها شماره‌ها در سه فیلد متنی ذخیره شده‌اند و به هر شکلی وارد شده‌اند:
بدون صفر ابتدایی (``9133722815``)، با ارقام فارسی، با فاصله/خط تیره، یا کلاهی
متن آزاد (نام، تاریخ نصب، «معرف …»). این ماژول قاعدهٔ تبدیل را یک‌جا تعریف می‌کند
تا هم مهاجرت (اجرای خودکار، یک‌بار) و هم دستور ``fix_customer_phones`` (اجرای
دستی روی دیتابیس production) از یک پیاده‌سازی استفاده کنند.
قاعده (طبق تصمیم کاربر):
* شمارهٔ موبایل — هرجای متن که باشد — استخراج و به شکل استاندارد ``09xxxxxxxxx``
تبدیل می‌شود.
* شمارهٔ ثابت ۸رقمی (مثل ``57387608``) **دست‌نخورده** می‌ماند.
* بقیه (متن آزاد، تاریخ، تکه‌های بی‌معنی) از فیلد شماره خالی می‌شود و متن اصلی‌اش
در فیلد ``Customer.notes`` می‌رود تا چیزی از سیستم گم نشود.
تابع ``clean_legacy_cell`` خودبسنده و idempotent است: بعد از یک‌بار اجرا، خروجی
همان مقدار فعلی است و دیگر توضیحی اضافه نمی‌شود؛ پس اجرای دوباره بی‌خطر است.
"""
import re
from persiantools import digits
from integrations.sms.normalize import normalize_mobile, to_local
_NOTE_HEADER = "— پاک‌سازی خودکار فیلدهای تلفن —"
def _numeric_tokens(raw):
"""تکه‌های رقمی متن، پس از تبدیل ارقام فارسی/عربی به انگلیسی."""
text = digits.fa_to_en(digits.ar_to_fa(str(raw)))
return re.findall(r"\d+", text)
def clean_legacy_cell(raw):
"""یک فیلد تلفن را پاک‌سازی می‌کند.
خروجی ``(value, note)`` است:
* ``value`` مقدار جدید فیلد، یا ``None`` یعنی «دست نزن».
* ``note`` متن اصلی برای افزودن به ``Customer.notes``، یا ``None``.
قاعده: هرچیزِ فیلد که در مقدار جدید نمی‌ماند، **بی‌کم‌وکاست** در
``note`` برمی‌گردد تا هیچ داده‌ای گم نشود.
"""
original = (raw or "").strip()
if not original:
return None, None
# ۱) کل فیلد یک موبایل است (شاید با فاصله/خط تیره/پیش‌شماره).
whole = normalize_mobile(original)
if whole:
local = to_local(whole)
if local == original:
return None, None
return local, original
tokens = _numeric_tokens(original)
# ۲) موبایلی داخل متن پنهان است (مثل «57322182/9130261671» یا «موبایل: 0912…»).
for token in tokens:
local = to_local(token)
if local:
return local, original
# ۳) شمارهٔ ثابت ۸رقمیِ تنها ⇒ دست‌نخورده.
if len(tokens) == 1 and len(tokens[0]) == 8:
return None, None
# ۴) بقیه (متن آزاد، تاریخ، تکهٔ بی‌معنی) ⇒ خالی؛ متن اصلی در یادداشت می‌ماند.
return "", original
def _append_note(notes, lines):
"""خطوط توضیح را به انتهای یادداشت مشتری اضافه می‌کند (با سرصفحهٔ یک‌بار)."""
current = (notes or "").strip()
block = "\n".join(lines)
if _NOTE_HEADER in current:
return f"{current}\n{block}"
if current:
return f"{current}\n\n{_NOTE_HEADER}\n{block}"
return f"{_NOTE_HEADER}\n{block}"
def clean_customer(customer, *, dry_run=False):
"""فیلدهای تلفن یک مشتری را اصلاح و متن‌های حذف‌شده را در ``notes`` می‌ریزد.
خروجی: تعداد فیلدهای تغییرکرده. اگر تغییری نباشد، چیزی ذخیره نمی‌شود.
"""
changed = 0
note_lines = []
updates = {}
for field in ("phone1", "phone2", "phone3"):
value, note = clean_legacy_cell(getattr(customer, field, ""))
if value is not None and value != (getattr(customer, field) or ""):
updates[field] = value
changed += 1
if note:
note_lines.append(f"«{field}»: {note}")
if not changed and not note_lines:
return 0
if dry_run:
return changed or (1 if note_lines else 0)
if note_lines:
customer.notes = _append_note(customer.notes, note_lines)
updates["notes"] = customer.notes
if updates:
for field, value in updates.items():
setattr(customer, field, value)
customer.save(update_fields=list(updates))
return changed
def clean_all(*, dry_run=False, batch_size=500, apps=None):
"""همهٔ مشتریان را پاک‌سازی می‌کند. خروجی: دیکشنری آمار.
``apps`` فقط برای مهاجرت‌ها است تا مدل را از رجیستری تاریخی بگیرد (نه import
زمان‌اجرا)؛ در غیر این صورت مدل زندهٔ ``Customer`` استفاده می‌شود.
"""
if apps is not None:
Customer = apps.get_model("admin_panel", "Customer")
else:
from ..models import Customer
stats = {"customers": 0, "fields": 0, "noted": 0}
qs = Customer.objects.all().only(
"id", "phone1", "phone2", "phone3", "notes"
).order_by("id")
for customer in qs.iterator(chunk_size=batch_size):
before_notes = (customer.notes or "").strip()
changed = clean_customer(customer, dry_run=dry_run)
if changed:
stats["customers"] += 1
stats["fields"] += changed
if (not dry_run) and (customer.notes or "").strip() != before_notes:
stats["noted"] += 1
return stats
+163
View File
@@ -0,0 +1,163 @@
"""یافتن شماره‌های یک مشتری از همهٔ روش‌های ذخیره‌سازی.
شماره‌ها در این پروژه به چند شکل ذخیره می‌شوند:
* رکوردهای مدل ``PhoneNumber`` (روش جدید؛ عنوان + شماره)
* فیلدهای قدیمی ``Customer.phone1`` / ``phone2`` / ``phone3``
* بدون صفر ابتدایی (``9133722815`` به‌جای ``09133722815``) و بعضاً مقدارهای
بی‌معنی مثل ``'0'`` یا شمارهٔ ثابت.
این ماژول تنها مرجع خواندن شماره‌هاست: هر جای دیگر (پنل، اسکنر، worker) باید از
همین توابع استفاده کند تا رفتار یکسان باشد.
«شمارهٔ اصلی» به‌صورت **مقدارِ خودِ شماره** (نرمال‌شده) در ``Customer.sms_primary_phones``
ذخیره می‌شود، نه با کلید رکورد. دلیل: دادهٔ مشتریان دائماً تغییر می‌کند (افزودن، ویرایش،
حذف) و کلید رکورد در بازسازی‌ها از بین می‌رود؛ ولی شماره یک مقدار پایدار است. اگر شماره‌ای
از مشتری حذف یا ویرایش شود، انتخاب اصلی خودبه‌خود بی‌اثر می‌شود و سیستم به شمارهٔ اول
برمی‌گردد.
"""
from integrations.sms.normalize import normalize_mobile
class PhoneEntry:
"""یک شمارهٔ مشتری، مستقل از اینکه کجا ذخیره شده است."""
__slots__ = ("raw", "display", "normalized", "title", "source", "is_primary")
def __init__(self, raw, title, source, is_primary=False):
self.raw = raw
self.title = title
self.source = source
self.normalized = normalize_mobile(raw)
self.display = to_display(raw)
self.is_primary = is_primary
@property
def is_mobile(self):
return bool(self.normalized)
def as_dict(self):
return {
"raw": self.raw,
"display": self.display,
"normalized": self.normalized,
"title": self.title,
"source": self.source,
"is_primary": self.is_primary,
"is_mobile": self.is_mobile,
}
def __repr__(self): # pragma: no cover - کمک‌کنندهٔ دیباگ
return f"<PhoneEntry {self.display!r} primary={self.is_primary} source={self.source}>"
def to_display(raw):
"""شماره را برای نمایش با صفر ابتدایی برمی‌گرداند (بدون دست‌بردن در مقدار ذخیره‌شده)."""
text = "" if raw is None else str(raw).strip()
if not text:
return ""
normalized = normalize_mobile(text)
if normalized:
return "0" + normalized[3:] # +989xxxxxxxxx → 09xxxxxxxxx
return text
def _iter_sources(customer):
"""(title, raw, source) از همهٔ منابع، بدون تکرار — ابتدا رکوردها، سپس فیلدهای قدیمی."""
from ..models import PhoneNumber
seen = set()
rows = PhoneNumber.objects.filter(customer=customer).order_by("id")
for row in rows:
raw = (row.phone_number or "").strip()
if not raw:
continue
key = normalize_mobile(raw) or raw
if key in seen:
continue
seen.add(key)
yield (row.title or "تلفن", raw, "phone_number")
legacy = (
("تلفن 1", getattr(customer, "phone1", "")),
("تلفن 2", getattr(customer, "phone2", "")),
("تلفن 3", getattr(customer, "phone3", "")),
)
for title, raw in legacy:
raw = (raw or "").strip()
if not raw:
continue
key = normalize_mobile(raw) or raw
if key in seen:
continue
seen.add(key)
yield (title, raw, "legacy")
def stored_primary_normalized(customer):
"""مجموعهٔ شماره‌های نرمال‌شده‌ای که مدیر به‌عنوان «اصلی» انتخاب کرده است."""
raw = (getattr(customer, "sms_primary_phones", "") or "").strip()
if not raw:
return set()
return {part.strip() for part in raw.split(",") if part.strip()}
def normalize_primary_values(values):
"""فهرست شماره‌های خام → رشتهٔ نرمال‌شدهٔ یکتا برای ذخیره در ``sms_primary_phones``.
شماره‌های نامعتبر (ثابت، ناقص، ``'0'``) نادیده گرفته می‌شوند؛ پس علامت‌زدن یک شمارهٔ
غیرموبایل به‌عنوان «اصلی» بی‌اثر می‌ماند و سیستم به شمارهٔ اولِ معتبر برمی‌گردد.
"""
unique = []
for raw in values:
number = normalize_mobile(raw)
if number and number not in unique:
unique.append(number)
return ",".join(unique)
def save_primary_normalized(customer, numbers, *, update_fields=("sms_primary_phones",)):
"""ذخیرهٔ فهرست شماره‌های اصلی (نرمال‌شده، بدون تکرار، به ترتیب ورودی)."""
customer.sms_primary_phones = normalize_primary_values(numbers)
customer.save(update_fields=list(update_fields))
return stored_primary_normalized(customer)
def customer_phone_entries(customer):
"""همهٔ شماره‌های مشتری از هر منبعی که ذخیره شده باشند (بدون تکرار)."""
if customer is None:
return []
primary = stored_primary_normalized(customer)
return [
PhoneEntry(raw, title, source, is_primary=(entry_norm in primary) if entry_norm else False)
for title, raw, source in _iter_sources(customer)
for entry_norm in [normalize_mobile(raw)]
]
def usable_entries(customer):
"""فقط شماره‌هایی که موبایل معتبرند و واقعاً قابل ارسال پیامک هستند."""
return [entry for entry in customer_phone_entries(customer) if entry.is_mobile]
def primary_phones(customer):
"""شماره‌های خام گیرندهٔ پیامک: انتخاب‌شده‌های مدیر، وگرنه شمارهٔ اولِ قابل استفاده."""
entries = usable_entries(customer)
if not entries:
return []
chosen = [entry for entry in entries if entry.is_primary]
if chosen:
return [entry.raw for entry in chosen]
return [entries[0].raw]
def resolved_recipients(customer):
"""گیرندگان نهاییِ نرمال‌شده (``+989xxxxxxxxx``) و یکتا."""
seen = []
for entry in usable_entries(customer):
if entry.is_primary and entry.normalized not in seen:
seen.append(entry.normalized)
if seen:
return seen
entries = usable_entries(customer)
return [entries[0].normalized] if entries else []
+22
View File
@@ -0,0 +1,22 @@
"""تعیین گیرندگان پیامک.
منطق واقعیِ یافتن شماره‌ها در ``admin_panel.sms.phones`` است (تنها مرجع خواندن
شماره‌ها). این ماژول فقط نام‌های قدیمی را برای سازگاری نگه می‌دارد و آن‌ها را به
همان توابع واگذار می‌کند تا رفتار در همهٔ مسیرها یکسان بماند.
"""
import logging
from .phones import primary_phones as _primary_phones
from .phones import resolved_recipients
logger = logging.getLogger(__name__)
def primary_phones(customer):
"""شماره‌های خام گیرندهٔ پیامک (اصلی‌های انتخاب‌شده، وگرنه شمارهٔ اولِ معتبر)."""
return _primary_phones(customer)
def resolve_recipients(customer):
"""فهرست شماره‌های نرمال‌شدهٔ یکتای گیرنده."""
return resolved_recipients(customer)
+41
View File
@@ -0,0 +1,41 @@
"""رندر قالب متن پیامک با placeholderهای امن."""
from persiantools.jdatetime import JalaliDate, JalaliDateTime
class _SafeDict(dict):
"""دیکشنری که برای کلیدهای ناشناخته رشته خالی برمی‌گرداند (بدون خطا)."""
def __missing__(self, key):
return ""
def format_amount(value):
try:
return f"{int(value):,}"
except (TypeError, ValueError):
return str(value or "")
def format_jalali(value):
"""تبدیل datetime/date به تاریخ جلالی ``YYYY/MM/DD``."""
if not value:
return ""
try:
if hasattr(value, "date") and callable(getattr(value, "date")):
value = value.date()
return JalaliDate(value).strftime("%Y/%m/%d")
except Exception:
try:
return JalaliDateTime(value).strftime("%Y/%m/%d")
except Exception:
return str(value)
def render_template(text, context=None):
ctx = dict(context or {})
if "amount" in ctx:
ctx.setdefault("amount_sep", format_amount(ctx["amount"]))
if "date" in ctx:
ctx.setdefault("date_raw", ctx["date"])
ctx["date"] = format_jalali(ctx["date"])
return (text or "").format_map(_SafeDict(ctx))
+25
View File
@@ -0,0 +1,25 @@
"""قواعد فعال/غیرفعال بودن پیامک برای مشتری (مدل opt-out)."""
from ..models import CustomerSMSSetting, SMSType
def is_sms_enabled(customer, sms_type):
"""اگر رکوردی با is_enabled=False وجود داشته باشد غیرفعال است؛ در غیر این‌صورت فعال."""
if customer is None or sms_type == SMSType.CUSTOM:
return True
setting = CustomerSMSSetting.objects.filter(customer=customer, sms_type=sms_type).first()
if setting is None:
return True
return setting.is_enabled
def customer_toggle_map(customer):
"""فهرست نوع‌های پیامک با وضعیت مؤثر (برای نمایش در پنل مشتری)."""
overrides = {
s.sms_type: s.is_enabled
for s in CustomerSMSSetting.objects.filter(customer=customer)
}
return [
{"code": code, "label": label, "enabled": overrides.get(code, True)}
for code, label in SMSType.choices
if code != SMSType.CUSTOM
]
+336
View File
@@ -0,0 +1,336 @@
"""اسکنر دوره‌ای: پیدا کردن موارد سررسیدشده و قرار دادن پیامک در صف.
قواعد کنترل جریان (همه از پنل مدیریت قابل تنظیم):
* **خط شروع (``scan_since``)**: فقط مواردی صف می‌شوند که «لحظهٔ باز شدن پنجرهٔ یادآوری»
آن‌ها از این لحظه به بعد باشد. بنابراین در نخستین اجرا هیچ‌کدام از موارد قدیمی موجود
در دیتابیس یک‌باره ارسال نمی‌شوند.
* **اجازهٔ موارد قدیمی (``allow_backlog`` + ``backlog_max``)**: اگر مدیر صریحاً روشن کند،
در هر اجرا حداکثر ``backlog_max`` مورد قدیمی هم صف می‌شود.
* **سقف هر اجرا (``max_per_run``)**: سقف کل صف‌کردن هر اسکنر در یک اجرا.
هر اسکنر مستقل و مقاوم به خطاست؛ یک خطا بقیه را متوقف نمی‌کند. ``dedup_key`` از
ارسال تکراری جلوگیری می‌کند و تضمین می‌کند اجرای دوبارهٔ اسکنر رکورد جدیدی نسازد.
یادآوری‌های **دوره‌ای** (بازدید و ثبت چک) کلیدشان شامل شمارهٔ دوره است. یعنی اگر
مدیر یک مورد را از صف حذف کند، فقط همان دوره کنسل می‌شود و در دورهٔ بعد دوباره
ساخته می‌شود؛ برای همیشه خاموش نمی‌ماند.
"""
import logging
import os
from datetime import datetime, time, timedelta
from django.db.models import Q, Sum
from django.utils import timezone
from ..models import (Article, Customer, Factor, Sarresid, SMSProviderConfig,
SMSTemplate, SMSType)
from .enqueue import enqueue_sms
from .timing import DEFAULT_VISIT_MONTHS
logger = logging.getLogger(__name__)
# پیش‌فرض فاصلهٔ تکرار «یادآوری ثبت چک» (روز) وقتی قالبی تنظیم نشده باشد.
CHEQUE_REG_INTERVAL_DEFAULT = 3
def _template(sms_type):
return SMSTemplate.objects.filter(sms_type=sms_type, is_enabled=True).first()
def _aware(value):
"""تبدیل تاریخ/ساعت naive به aware (کد قدیمی پروژه جاهایی مقدار naive ذخیره می‌کند)."""
if value is None:
return None
if isinstance(value, datetime):
if timezone.is_naive(value):
return timezone.make_aware(value, timezone.get_current_timezone())
return value
# date → آغاز همان روز
return timezone.make_aware(
datetime.combine(value, time.min), timezone.get_current_timezone(),
)
class _Budget:
"""ظرفیت صف‌کردن یک اسکنر در یک اجرا (خط شروع + سقف قدیمی + سقف کل)."""
def __init__(self, config):
self.since = config.scan_since
self.allow_backlog = bool(config.allow_backlog)
self.backlog_max = config.backlog_max or 0
self.max_per_run = config.max_per_run or 0
self.new_count = 0
self.backlog_count = 0
def is_backlog(self, trigger):
trigger = _aware(trigger)
return self.since is not None and trigger < self.since
def can_send(self, trigger):
if self.max_per_run and (self.new_count + self.backlog_count) >= self.max_per_run:
return False
if self.is_backlog(trigger):
return self.allow_backlog and self.backlog_count < self.backlog_max
return True
def record(self, trigger):
if self.is_backlog(trigger):
self.backlog_count += 1
else:
self.new_count += 1
def scan_cheque_due(today):
template = _template(SMSType.CHEQUE_DUE)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
offset = template.offset_days or 0
qs = Sarresid.objects.filter(
type="check", payStatus="false",
sarresidDT__date__gte=today,
sarresidDT__date__lte=today + timedelta(days=offset),
).select_related("customer").order_by("sarresidDT")
count = 0
for s in qs:
if not s.customer or not s.sarresidDT:
continue
# پنجرهٔ یادآوری از «سررسید منهای offset» باز می‌شود.
trigger = _aware(s.sarresidDT) - timedelta(days=offset)
if not budget.can_send(trigger):
continue
days = (s.sarresidDT.date() - today).days
made = enqueue_sms(
customer=s.customer, sms_type=SMSType.CHEQUE_DUE,
context={
"name": s.customer.name_family, "amount": s.price,
"date": s.sarresidDT, "days": days, "check_row": s.check_row_number,
},
dedup_key=f"cheque_due:{s.id}",
)
if made:
budget.record(trigger)
count += made
return count
def scan_nesiye_due(today):
template = _template(SMSType.NESIYE_DUE)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
offset = template.offset_days or 0
qs = Sarresid.objects.filter(
type="nesiye", payStatus="false",
sarresidDT__date__gte=today,
sarresidDT__date__lte=today + timedelta(days=offset),
).select_related("customer").order_by("sarresidDT")
count = 0
for s in qs:
if not s.customer or not s.sarresidDT:
continue
trigger = _aware(s.sarresidDT) - timedelta(days=offset)
if not budget.can_send(trigger):
continue
days = (s.sarresidDT.date() - today).days
made = enqueue_sms(
customer=s.customer, sms_type=SMSType.NESIYE_DUE,
context={
"name": s.customer.name_family, "amount": s.price,
"date": s.sarresidDT, "days": days,
},
dedup_key=f"nesiye_due:{s.id}",
)
if made:
budget.record(trigger)
count += made
return count
def scan_cheque_registration(today):
"""یادآوری «ثبت اطلاعات چک» برای چک‌های پرداخت‌نشدهٔ ثبت‌نشده.
هر ``interval`` روز یک‌بار تکرار می‌شود و به‌محض اینکه شمارهٔ صیادی چک ثبت شود
(یا چک پرداخت شود) متوقف می‌گردد. دورهٔ تکرار از پنل قابل تنظیم است.
"""
template = _template(SMSType.CHEQUE_REGISTRATION)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
interval = template.repeat_interval_days or CHEQUE_REG_INTERVAL_DEFAULT
qs = Sarresid.objects.filter(
type="check", payStatus="false", sarresidDT__date__gte=today,
).filter(
Q(check_sayyad_id="") | Q(check_sayyad_id__isnull=True),
).select_related("customer")
count = 0
for s in qs:
if not s.customer:
continue
anchor = s.createdDT or s.sarresidDT
if not anchor:
continue
days = (today - anchor.date()).days
if days <= 0 or days % interval != 0:
continue
cycle = days // interval
# لحظهٔ باز شدن همین دورهٔ تکرار (نه خودِ چک) ملاک خط شروع است.
trigger = _aware(anchor) + timedelta(days=days)
if not budget.can_send(trigger):
continue
made = enqueue_sms(
customer=s.customer, sms_type=SMSType.CHEQUE_REGISTRATION,
context={
"name": s.customer.name_family, "amount": s.price,
"date": s.sarresidDT, "days": (s.sarresidDT.date() - today).days
if s.sarresidDT else "",
"check_row": s.check_row_number,
},
dedup_key=f"cheque_reg:{s.id}:{cycle}",
)
if made:
budget.record(trigger)
count += made
return count
def scan_debt_due(today):
"""یادآوری بدهی برای مانده‌دارها روی سررسید وصول مطالبات."""
template = _template(SMSType.DEBT_DUE)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
offset = template.offset_days or 0
target = today - timedelta(days=offset)
qs = Sarresid.objects.filter(
payStatus="false", sarresidDT__date=target,
).select_related("customer").order_by("sarresidDT")
count = 0
seen = set()
for s in qs:
if not s.customer or s.customer_id in seen or not s.sarresidDT:
continue
trigger = _aware(s.sarresidDT)
if not budget.can_send(trigger):
continue
totals = Article.objects.filter(customer=s.customer).aggregate(
d=Sum("debit"), c=Sum("credit"),
)
net = (totals["d"] or 0) - (totals["c"] or 0)
if net <= 0:
continue
made = enqueue_sms(
customer=s.customer, sms_type=SMSType.DEBT_DUE,
context={"name": s.customer.name_family, "amount": net},
dedup_key=f"debt_due:{s.id}",
)
if made:
seen.add(s.customer_id)
budget.record(trigger)
count += made
return count
def scan_survey(today):
"""نظرسنجی چند روز پس از ایجاد فاکتور."""
template = _template(SMSType.SURVEY)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
offset = template.offset_days or 3
target = today - timedelta(days=offset)
qs = Factor.objects.filter(
is_active=True, create_date__date=target,
).exclude(pay_status="cancell").select_related("customer").order_by("create_date")
count = 0
for f in qs:
if not f.customer or not f.create_date:
continue
trigger = _aware(f.create_date) + timedelta(days=offset)
if not budget.can_send(trigger):
continue
made = enqueue_sms(
customer=f.customer, sms_type=SMSType.SURVEY,
context={"name": f.customer.name_family, "amount": f.amount},
dedup_key=f"survey:{f.id}",
)
if made:
budget.record(trigger)
count += made
return count
def scan_visit(today):
"""یادآوری بازدید برای مشتریان عقب‌افتاده از بازدید (منطق صفحهٔ «انتظار»).
لحظهٔ واجد‌شرط‌شدن هر مشتری = «آخرین بازدید + دورهٔ مجاز» (یا تاریخ نصب برای
مشتریانی که هرگز بازدید نشده‌اند). اگر این لحظه پیش از خط شروع باشد، مورد قدیمی
تلقی می‌شود و فقط با اجازهٔ صریح مدیر (و در سقف تعیین‌شده) ارسال می‌گردد.
"""
template = _template(SMSType.VISIT)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
# دورهٔ بازدید: اول از قالب (قابل تنظیم از پنل)، وگرنه متغیر محیطی legacy، وگرنه پیش‌فرض.
months = template.repeat_interval_months
if not months:
try:
months = int(os.environ.get("bazdid", DEFAULT_VISIT_MONTHS))
except (TypeError, ValueError):
months = DEFAULT_VISIT_MONTHS
period = timedelta(weeks=int(months * 4.3))
period_days = max(period.days, 1)
now = timezone.now()
qs = Customer.objects.filter(
laghv_bazdid=False,
is_active=True,
installDate__lte=(now - period).date(),
).order_by("id")
count = 0
for customer in qs:
anchor = _aware(customer.last_bazdid_dt) or _aware(customer.installDate)
if anchor is None:
continue
trigger = anchor + period
if trigger > now:
continue # هنوز عقب‌افتاده نیست
if not budget.can_send(trigger):
continue
# چرخه = شمارهٔ دورهٔ کاملِ سپری‌شده از لنگر. کلید به چرخه گره می‌خورد تا
# حذفِ یادآوری توسط مدیر فقط همان «دوره» را کنسل کند و در دوره‌های بعدی
# دوباره ساخته شود (نه اینکه برای همیشه خاموش بماند).
cycle = max((today - anchor.date()).days // period_days, 0)
made = enqueue_sms(
customer=customer, sms_type=SMSType.VISIT,
context={"name": customer.name_family},
dedup_key=f"visit:{customer.id}:{cycle}",
)
if made:
budget.record(trigger)
count += made
return count
SCANNERS = [
("cheque_due", scan_cheque_due),
("nesiye_due", scan_nesiye_due),
("cheque_registration", scan_cheque_registration),
("debt_due", scan_debt_due),
("survey", scan_survey),
("visit", scan_visit),
]
def run_all(today=None):
"""همهٔ اسکنرها را اجرا و نگاشت «نام → تعداد درج‌شده» را برمی‌گرداند."""
today = today or timezone.localdate()
results = {}
for name, func in SCANNERS:
try:
results[name] = func(today)
except Exception:
logger.exception("اسکن پیامک شکست خورد: %s", name)
results[name] = -1
return results
+112
View File
@@ -0,0 +1,112 @@
"""معنای «زمان‌بندی» برای هر نوع پیامک.
هر نوع پیامک زمان‌بندی متفاوتی دارد و نمی‌شود همه را زیر یک برچسب «روز قبل از سررسید»
جمع کرد. این ماژول تنها مرجع این معناست: هم پنل قالب‌ها بر اساسش فیلد درست را نشان
می‌دهد، هم اسکنر بر اساسش تصمیم می‌گیرد. هر نوع دقیقاً به یک فیلد زمانی نگاشت می‌شود
(``field``) تا داده هرگز ناهمخوان نشود؛ بقیهٔ فیلدهای زمانی صفر/خالی می‌مانند.
"""
from ..models import SMSType
NONE = "none"
BEFORE_DUE = "before_due"
AFTER_CREATION = "after_creation"
RECURRING = "recurring"
PERIOD_MONTHS = "period_months"
# پیش‌فرض دورهٔ بازدید (ماه) وقتی مدیر مقداری در قالب نگذاشته باشد.
DEFAULT_VISIT_MONTHS = 9
TIMING = {
SMSType.WELCOME: {
"kind": NONE,
"field": None,
"note": "به‌محض ثبت مشتری و بدون تأخیر ارسال می‌شود؛ تنظیم زمانی ندارد.",
},
SMSType.CHEQUE_DUE: {
"kind": BEFORE_DUE,
"field": "offset_days",
"label": "چند روز قبل از سررسید چک",
"default": 2,
"note": "پنجرهٔ یادآوری از «سررسید − این تعداد روز» باز می‌شود و تا سررسید ادامه دارد.",
},
SMSType.NESIYE_DUE: {
"kind": BEFORE_DUE,
"field": "offset_days",
"label": "چند روز قبل از سررسید نسیه",
"default": 2,
"note": "پنجرهٔ یادآوری از «سررسید − این تعداد روز» باز می‌شود و تا سررسید ادامه دارد.",
},
SMSType.CHEQUE_REGISTRATION: {
"kind": RECURRING,
"field": "repeat_interval_days",
"label": "هر چند روز یک‌بار",
"default": 3,
"note": "تا زمانی که اطلاعات ثبت چک (شمارهٔ صیادی) وارد نشده و چک پرداخت نشده، "
"با این فاصله تکرار می‌شود.",
},
SMSType.SURVEY: {
"kind": AFTER_CREATION,
"field": "offset_days",
"label": "چند روز بعد از صدور فاکتور",
"default": 3,
"note": "این تعداد روز پس از ثبت فاکتور ارسال می‌شود.",
},
SMSType.DEBT_DUE: {
"kind": NONE,
"field": None,
"note": "روی سررسیدِ وصول مطالبات و فقط برای مشتریانی که مانده بدهکارند ارسال می‌شود.",
},
SMSType.VISIT: {
"kind": PERIOD_MONTHS,
"field": "repeat_interval_months",
"label": "هر چند ماه یک‌بار",
"default": DEFAULT_VISIT_MONTHS,
"note": "برای مشتریانی که از این مدت بازدید نشده‌اند ارسال می‌شود.",
},
SMSType.CUSTOM: {
"kind": NONE,
"field": None,
"note": "فقط به‌صورت دستی از صفحهٔ مشتری ارسال می‌شود؛ تنظیم زمانی ندارد.",
},
}
def timing_for(sms_type):
"""اطلاعات زمان‌بندی یک نوع پیامک (با پیش‌فرض بی‌خطر برای انواع ناشناخته)."""
return TIMING.get(sms_type, {"kind": NONE, "field": None, "note": ""})
def apply_timing(template, value):
"""فیلد زمانیِ متناسب با نوع قالب را می‌نشاند و بقیه را صفر/خالی می‌کند.
``value`` عدد صحیحِ واردشده از فرم است (یا ``None``). این کار تضمین می‌کند صرف‌نظر از
اینکه فرم چه بفرستد، فقط یک فیلد زمانی برای هر قالب معنا داشته باشد.
"""
info = timing_for(template.sms_type)
field = info["field"]
template.offset_days = 0
template.repeat_interval_days = None
template.repeat_interval_months = None
if field == "offset_days":
template.offset_days = value if value is not None else info["default"]
elif field == "repeat_interval_days":
template.repeat_interval_days = value if value else info["default"]
elif field == "repeat_interval_months":
template.repeat_interval_months = value if value else None
return template
def current_timing_value(template):
"""مقدار فعلی فیلد زمانیِ متناسب (برای نمایش در فرم).
اگر مقدار خالی باشد، پیش‌فرض همان نوع برگردانده می‌شود تا فیلدِ تنظیم‌نشده هم در
فرم دیده شود و مدیر بتواند مقدارش را تغییر دهد.
"""
info = timing_for(template.sms_type)
field = info["field"]
if field is None:
return None
value = getattr(template, field)
if value is None:
value = info.get("default")
return value
@@ -0,0 +1,70 @@
{% if perms.admin_panel.ManageSMS %}
<div class="box" dir="rtl">
<h2 class="title is-5">پیامک</h2>
<div class="columns is-multiline">
<div class="column is-6-desktop is-12-mobile">
<h3 class="subtitle is-6">شماره‌های اصلی (گیرنده پیامک)</h3>
{# همهٔ شماره‌ها از هر منبعی که ذخیره شده‌اند نمایش داده می‌شوند (جدول شماره‌ها یا فیلدهای قدیمی) #}
{% for entry in sms_phone_entries %}
<form method="post" action="{% url 'admin_panel:sms_primary_toggle' customer.id %}" class="field has-addons">
{% csrf_token %}
<input type="hidden" name="phone" value="{{ entry.raw }}">
<input type="hidden" name="is_primary" value="{% if entry.is_primary %}false{% else %}true{% endif %}">
<div class="control is-expanded">
<input class="input" type="text"
value="{{ entry.title }} — {{ entry.display }}{% if not entry.is_mobile %} (غیرقابل ارسال پیامک){% endif %}"
disabled>
</div>
<div class="control">
{% if entry.is_mobile %}
<button class="button {% if entry.is_primary %}is-success{% else %}is-light{% endif %}" type="submit">
{% if entry.is_primary %}اصلی ✓{% else %}تعیین به‌عنوان اصلی{% endif %}
</button>
{% else %}
<button class="button is-light" type="button" disabled>غیرموبایل</button>
{% endif %}
</div>
</form>
{% empty %}
<p class="help">شماره‌ای برای این مشتری ثبت نشده است.</p>
{% endfor %}
<p class="help">اگر شمارهٔ اصلی تعیین نشده باشد، شمارهٔ اولِ معتبر به‌صورت خودکار استفاده می‌شود.</p>
<p class="help">گیرندگان فعلی: {% for r in sms_recipients %}<span dir="ltr">{{ r }}</span>{% if not forloop.last %}، {% endif %}{% empty %}—{% endfor %}</p>
</div>
<div class="column is-6-desktop is-12-mobile">
<h3 class="subtitle is-6">فعال/غیرفعال پیامک‌ها برای این مشتری</h3>
{% for item in sms_toggles %}
<form method="post" action="{% url 'admin_panel:sms_customer_toggle' customer.id %}" class="field has-addons">
{% csrf_token %}
<input type="hidden" name="sms_type" value="{{ item.code }}">
<input type="hidden" name="is_enabled" value="{% if item.enabled %}false{% else %}true{% endif %}">
<div class="control is-expanded">
<input class="input" type="text" value="{{ item.label }}" disabled>
</div>
<div class="control">
<button class="button {% if item.enabled %}is-success{% else %}is-danger{% endif %}" type="submit">
{% if item.enabled %}فعال{% else %}غیرفعال{% endif %}
</button>
</div>
</form>
{% endfor %}
</div>
<div class="column is-12">
<h3 class="subtitle is-6">ارسال پیامک سفارشی</h3>
<form method="post" action="{% url 'admin_panel:sms_send_custom' customer.id %}">
{% csrf_token %}
<div class="field">
<div class="control">
<textarea class="textarea" name="text" rows="2" placeholder="متن پیامک... (خالی بگذارید تا متن قالب «پیام سفارشی» استفاده شود)"></textarea>
</div>
</div>
<p class="help">می‌توانید از متغیرهایی مثل {name} (نام مشتری) استفاده کنید.</p>
<button class="button is-info" type="submit">ارسال به شماره‌های اصلی</button>
</form>
</div>
</div>
</div>
{% endif %}
+30 -1
View File
@@ -19,6 +19,7 @@
<script type="text/javascript" src="{% static 'admin_panel/persian-datepicker.min.js' %}"></script>
<link rel="stylesheet" type="text/css" href="{% static 'admin_panel/style.css' %}">
{% block cssEnd %}{% endblock %}
<style>
</style>
</head>
@@ -119,9 +120,26 @@
{% if perms.admin_panel.ManageSMS %}
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">پیامک</a>
<div class="navbar-dropdown">
<a href="{% url 'admin_panel:sms_settings' %}" style="padding-right: 10px;" class="navbar-item">
تنظیمات سامانه
</a>
<a href="{% url 'admin_panel:sms_templates' %}" style="padding-right: 10px;" class="navbar-item">
قالب‌ها
</a>
<a href="{% url 'admin_panel:sms_queue' %}" style="padding-right: 10px;" class="navbar-item">
صف ارسال
</a>
</div>
</div>
{% endif %}
<a class="navbar-item">
کاربران
</a>
</a>
</div>
<div class="navbar-end ml-0">
@@ -201,6 +219,17 @@
{% endif %}
</div>
{% if notice_text %}
<div class="columns m-0">
<div class="column is-12 px-2 pt-3 pb-0">
<div class="notification {{ notice_kind }} is-light mb-2">
<button class="delete close-notification" type="button"></button>
<strong>{{ notice_text }}</strong>
</div>
</div>
</div>
{% endif %}
{% block bodyPage %}
{% endblock %}
@@ -45,86 +45,29 @@
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">شماره‌های تماس</label>
<div id="dynamic-phones-edit">
{% if customer.phones.all %}
{% for phone_obj in customer.phones.all %}
<div class="field has-addons mt-2 ss02">
<div class="control" style="width: 30%;">
<input disabled value="{{ phone_obj.title }}" name="phone_titles" class="input" type="text" placeholder="عنوان">
</div>
<div class="control is-expanded">
<input disabled value="0{{ phone_obj.phone_number }}" name="phones" class="input an_phone" type="text" placeholder="تلفن">
</div>
<div class="control">
<a href="tel:0{{ phone_obj.phone_number }}" class="button is-success" title="تماس با {{ phone_obj.phone_number }}">
📞
</a>
</div>
{% if perms.admin_panel.ViewStore %}
<div class="control">
<button type="button" disabled class="button is-danger remove-phone-btn">-</button>
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
{% if customer.phone1 %}
<div class="field has-addons ss02 mt-2">
<div class="control" style="width: 30%;">
<input disabled value="تلفن 1" name="phone_titles" class="input" type="text" placeholder="عنوان">
</div>
<div class="control is-expanded">
<input disabled value="0{{ customer.phone1 }}" name="phones" class="input an_phone" type="text" placeholder="تلفن">
</div>
<div class="control">
<a href="tel:0{{ customer.phone1 }}" class="button is-success" title="تماس">
📞
</a>
</div>
{% if perms.admin_panel.ViewStore %}
<div class="control"><button type="button" disabled class="button is-danger remove-phone-btn">-</button></div>
{% endif %}
</div>
{% endif %}
{% if customer.phone2 %}
<div class="field has-addons ss02 mt-2">
<div class="control" style="width: 30%;">
<input disabled value="تلفن 2" name="phone_titles" class="input" type="text">
</div>
<div class="control is-expanded">
<input disabled value="0{{ customer.phone2 }}" name="phones" class="input an_phone" type="text">
</div>
<div class="control">
<a href="tel:0{{ customer.phone2 }}" class="button is-success" title="تماس">
📞
</a>
</div>
{% if perms.admin_panel.ViewStore %}
<div class="control"><button type="button" disabled class="button is-danger remove-phone-btn">-</button></div>
{% endif %}
</div>
{% endif %}
{% if customer.phone3 %}
<div class="field has-addons ss02 mt-2">
<div class="control" style="width: 30%;">
<input disabled value="تلفن 3" name="phone_titles" class="input" type="text">
</div>
<div class="control is-expanded">
<input disabled value="0{{ customer.phone3 }}" name="phones" class="input an_phone" type="text">
</div>
<div class="control">
<a href="tel:0{{ customer.phone3 }}" class="button is-success" title="تماس">
📞
</a>
</div>
{% if perms.admin_panel.ViewStore %}
<div class="control"><button type="button" disabled class="button is-danger remove-phone-btn">-</button></div>
{% endif %}
</div>
{% endif %}
{% endif %}
{# همهٔ شماره‌ها از هر منبعی که ذخیره شده‌اند (جدول شماره‌ها + فیلدهای قدیمی)، بدون تکرار #}
{% for entry in sms_phone_entries %}
<div class="field has-addons mt-2 ss02">
<div class="control" style="width: 30%;">
<input disabled value="{{ entry.title }}" name="phone_titles" class="input" type="text" placeholder="عنوان">
</div>
<div class="control is-expanded">
<input disabled value="{{ entry.display }}" name="phones" class="input an_phone" type="text" placeholder="تلفن">
</div>
<div class="control">
<a href="tel:{{ entry.display }}" class="button is-success" title="تماس با {{ entry.display }}">
📞
</a>
</div>
{% if perms.admin_panel.ViewStore %}
<div class="control">
<button type="button" disabled class="button is-danger remove-phone-btn">-</button>
</div>
{% endif %}
</div>
{% empty %}
<p class="help mt-2">شماره‌ای برای این مشتری ثبت نشده است.</p>
{% endfor %}
</div>
{% if perms.admin_panel.ViewStore %}
<button type="button" disabled id="add-more-phone-edit" class="button is-small is-info mt-2 w-100 add-phone-btn">
@@ -148,6 +91,13 @@
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">توضیحات</label>
<div class="control">
<textarea disabled name="notes" class="textarea ss02 an_notes" rows="4" placeholder="یادداشت آزاد">{{ customer.notes }}</textarea>
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">لغو بازدید</label>
<div class="control">
@@ -686,7 +636,7 @@ $(document).ready(function() {
var test = 0;
$("#enEdit").click(function(){
$("#formcsEdit").find("input").prop('disabled', false);
$("#formcsEdit").find("input, textarea").prop('disabled', false);
// فعال کردن دکمه‌های کنترل تلفن
$("#formcsEdit").find("button.remove-phone-btn, button.add-phone-btn").prop('disabled', false);
@@ -180,9 +180,11 @@
</form>
</div>
{% include "./_customer_sms.html" %}
</div>
{% endblock %}
{% block BodyCustomer2 %}
<div id="addNewIteam" class="modal">
+212 -103
View File
@@ -136,63 +136,87 @@
<button class="modal-close is-large closeSearch" aria-label="close"></button>
</div>
<div class="modal addNewUser">
<div class="modal addNewUser" role="dialog" aria-modal="true" aria-labelledby="addUserTitle">
<div class="modal-background closeAddUser"></div>
<div class="modal-content">
<form action="{% url 'admin_panel:customersAdd' %}" class="columns is-mobile is-multiline" method="post">
{% csrf_token %}
<div class="field column is-4-desktop is-12-mobile m-0">
<label class="label">نام ونام خانوادگی</label>
<div class="control">
<input required name="an_name" class="input an_name" type="text" placeholder="نام نام خانوادگی">
</div>
</div>
<div class="field column is-8-desktop is-12-mobile m-0">
<label class="label">آدرس</label>
<div class="control">
<input name="an_address" class="input an_address" type="text" placeholder="آدرس کامل">
</div>
</div>
<div class="field column is-6-desktop is-12-mobile m-0">
<label class="label">شماره‌های تماس</label>
<div id="dynamic-phones-add">
<div class="field has-addons">
<div class="control" style="width: 35%;">
<input name="phone_titles" class="input" type="text" placeholder="عنوان (مثلاً موبایل)">
<div class="modal-content add-modal">
<div class="add-modal-card">
<header class="add-modal-head">
<h2 class="title is-5 mb-0" id="addUserTitle">ایجاد مشتری جدید</h2>
<button type="button" class="delete closeAddUser" aria-label="بستن"></button>
</header>
<form action="{% url 'admin_panel:customersAdd' %}" method="post" id="addCustomerForm">
{% csrf_token %}
<div class="add-modal-body">
<div class="columns is-mobile is-multiline m-0">
<div class="field column is-8-desktop is-12-mobile py-2 px-2">
<label class="label" for="addName">نام و نام خانوادگی <span class="has-text-danger">*</span></label>
<div class="control">
<input required name="an_name" id="addName" class="input an_name" type="text"
autocomplete="name" placeholder="نام و نام خانوادگی">
</div>
</div>
<div class="control is-expanded">
<input name="phones" class="input an_phone" type="text" placeholder="شماره تماس">
<div class="field column is-4-desktop is-12-mobile py-2 px-2">
<label class="label" for="addIdnCode">کدشناسایی</label>
<div class="control">
<input name="idnCode" id="addIdnCode" class="input idnCode" type="text"
placeholder="کد شناسایی مشتری">
</div>
</div>
<div class="control">
<button type="button" class="button is-info add-phone-btn">+</button>
<div class="field column is-4-desktop is-12-mobile py-2 px-2">
<label class="label" for="persianDateP">تاریخ نصب</label>
<div class="control">
<input name="installDate" id="persianDateP" class="input installDate" type="text"
autocomplete="off" placeholder="مثل: 1400/06/01">
</div>
<p class="help">مبنای محاسبهٔ یادآوری بازدید دوره‌ای.</p>
</div>
<div class="field column is-8-desktop is-12-mobile py-2 px-2">
<label class="label" for="addAddress">آدرس</label>
<div class="control">
<input name="an_address" id="addAddress" class="input an_address" type="text"
autocomplete="street-address" placeholder="آدرس کامل">
</div>
</div>
<div class="field column is-12 py-2 px-2">
<label class="label">شماره‌های تماس</label>
<div id="dynamic-phones-add">
<div class="phone-row">
<div class="phone-row-fields">
<input name="phone_titles" class="input phone-title" type="text"
aria-label="عنوان شماره" placeholder="عنوان (مثلاً موبایل)">
<input name="phones" class="input phone-number an_phone" type="tel"
inputmode="tel" autocomplete="tel" aria-label="شماره تماس"
placeholder="شماره تماس">
</div>
<div class="phone-row-actions">
<label class="checkbox phone-primary" title="گیرندهٔ پیامک‌های این مشتری">
<input type="checkbox" name="phone_primary" value="0" checked> اصلی
</label>
<button type="button" class="button is-small is-danger is-light remove-phone-btn"
aria-label="حذف این شماره">حذف</button>
</div>
</div>
</div>
<button type="button" class="button is-info is-light is-small add-phone-btn"
data-target="#dynamic-phones-add">+ افزودن شماره</button>
<p class="help">تیک «اصلی» یعنی گیرندهٔ پیامک‌های این مشتری (می‌تواند چند شماره باشد).</p>
</div>
</div>
</div>
</div>
<div class="field column is-4-desktop is-12-mobile m-0">
<label class="label">تاریخ نصب</label>
<div class="control">
<input name="installDate" id="persianDateP" class="input installDate" type="text" placeholder="مثل: 1400/06/01">
</div>
</div>
<div class="field column is-4-desktop is-12-mobile m-0">
<label class="label">کدشناسایی</label>
<div class="control">
<input name="idnCode" class="input idnCode" type="text" placeholder="">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<input class="button is-success" id="DcreateCS" value="ایجاد مشتری" type="submit">
</div>
</form>
<footer class="add-modal-foot">
<button class="button is-success" id="DcreateCS" type="submit">ایجاد مشتری</button>
<button class="button is-light closeAddUser" type="button">انصراف</button>
</footer>
</form>
</div>
</div>
<button class="modal-close is-large closeAddUser" aria-label="close"></button>
</div>
@@ -252,83 +276,168 @@
</div>
{% endblock %}
{% block cssEnd %}
<style>
/* --- مودال ایجاد مشتری: کارت با سربرگ/پابرگ چسبان و بدنهٔ اسکرول‌شو --- */
/* .add-modal همان .modal-content بولماست؛ سقف ارتفاع و max-width پیش‌فرضش را خنثی
می‌کنیم تا کارت خودش اندازه را تعیین کند (وگرنه پابرگ بریده می‌شود). */
.add-modal {
overflow: visible; max-height: none; max-width: 760px;
width: 100%; margin: 0 auto; padding: 0 .5rem;
}
.add-modal-card {
display: flex; flex-direction: column;
background: #fff; border-radius: 10px; overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, .25);
width: 100%; max-width: 720px; margin: 0 auto;
max-height: calc(100vh - 120px);
}
/* فرم باید خودش ستون فلکس باشد تا بدنه بتواند اسکرول شود و پابرگ پایین بماند. */
.add-modal-card > form {
display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto;
}
.add-modal-head {
display: flex; align-items: center; justify-content: space-between;
gap: .5rem; padding: .75rem 1rem;
border-bottom: 1px solid #eee; background: #fff; flex: 0 0 auto;
}
.add-modal-body {
overflow-y: auto; -webkit-overflow-scrolling: touch;
padding: .25rem .5rem; flex: 1 1 auto; min-height: 0;
}
.add-modal-foot {
display: flex; gap: .5rem; padding: .75rem 1rem;
border-top: 1px solid #eee; background: #fafafa; flex: 0 0 auto;
}
.add-modal-foot .button { min-height: 2.6em; }
/* --- ردیف شمارهٔ تماس: دسکتاپ یک‌خطی، موبایل عمودی --- */
.phone-row {
display: flex; flex-wrap: wrap; align-items: center; gap: .5rem;
padding: .5rem; margin-bottom: .5rem;
border: 1px solid #e6e6e6; border-radius: 8px; background: #fafafa;
}
.phone-row-fields { display: flex; flex-wrap: wrap; gap: .5rem; flex: 1 1 320px; min-width: 0; }
.phone-row-fields .phone-title { flex: 0 0 140px; min-width: 0; }
.phone-row-fields .phone-number { flex: 1 1 180px; min-width: 0; }
.phone-row-actions { display: flex; align-items: center; gap: .75rem; white-space: nowrap; }
.phone-primary { display: flex; align-items: center; gap: .35rem; font-size: .9rem; }
@media (max-width: 768px) {
.add-modal-card { max-height: calc(100vh - 32px); border-radius: 8px; }
/* dvh روی مرورگرهای موبایل نوار آدرس را هم حساب می‌کند. */
.add-modal-card { max-height: calc(100dvh - 32px); }
.phone-row-fields { flex: 1 1 100%; }
.phone-row-fields .phone-title,
.phone-row-fields .phone-number { flex: 1 1 100%; }
.phone-row-actions { flex: 1 1 100%; justify-content: space-between; }
.add-modal-foot { flex-direction: column-reverse; }
.add-modal-foot .button { width: 100%; }
}
</style>
{% endblock %}
{% block jsEnd %}
<script>
$(document).ready(function() {
kamaDatepicker('persianDateP', { buttonsColor: "red", forceFarsiDigits: true });
$(document).on('click', '.add-phone-btn', function() {
var phoneRow = `
<div class="field has-addons mt-2">
<div class="control" style="width: 35%;">
<input name="phone_titles" class="input" type="text" placeholder="عنوان">
var PHONE_ROW = `
<div class="phone-row">
<div class="phone-row-fields">
<input name="phone_titles" class="input phone-title" type="text"
aria-label="عنوان شماره" placeholder="عنوان (مثلاً موبایل)">
<input name="phones" class="input phone-number an_phone" type="tel"
inputmode="tel" autocomplete="tel" aria-label="شماره تماس" placeholder="شماره تماس">
</div>
<div class="control is-expanded">
<input name="phones" class="input an_phone" type="text" placeholder="شماره تماس">
</div>
<div class="control">
<button type="button" class="button is-danger remove-phone-btn">-</button>
<div class="phone-row-actions">
<label class="checkbox phone-primary" title="گیرندهٔ پیامک‌های این مشتری">
<input type="checkbox" name="phone_primary" value="0"> اصلی
</label>
<button type="button" class="button is-small is-danger is-light remove-phone-btn"
aria-label="حذف این شماره">حذف</button>
</div>
</div>`;
$(this).closest('div[id^="dynamic-phones"]').append(phoneRow);
function reindexPhones() {
$('#dynamic-phones-add .phone-row').each(function(i) {
$(this).find('input[name="phone_primary"]').val(i);
});
}
// همیشه دست‌کم یک شماره «اصلی» بماند (شمارهٔ اول پیش‌فرضِ گیرندهٔ پیامک است).
function ensurePrimary() {
var $rows = $('#dynamic-phones-add .phone-row');
if ($rows.length && $rows.find('input[name="phone_primary"]:checked').length === 0) {
$rows.first().find('input[name="phone_primary"]').prop('checked', true);
}
}
reindexPhones();
$(document).on('click', '.add-phone-btn', function() {
// data-target صریح (وقتی دکمه بیرونِ ظرف است)؛ وگرنه نزدیک‌ترین ظرف شماره‌ها.
var $target = $(this).data('target')
? $($(this).data('target'))
: $(this).closest('div[id^="dynamic-phones"]');
$target.append(PHONE_ROW);
reindexPhones();
$target.find('.phone-row').last().find('.phone-number').focus();
});
$(document).on('click', '.remove-phone-btn', function() {
$(this).closest('.field.has-addons').remove();
$(this).closest('.phone-row').remove();
reindexPhones();
ensurePrimary();
});
$("#createCS").click(function(){
var u_name = $(".addNewUser").find('.an_name').val();
var u_address = $(".addNewUser").find('.an_address').val();
var u_phone1 = $(".addNewUser").find('#phone-1').val();
var u_phone2 = $(".addNewUser").find('#phone-2').val();
var u_phone3 = $(".addNewUser").find('#phone-3').val();
var u_installDate = $(".addNewUser").find('.installDate').val();
alert("Ok send To server");
function resetAddForm() {
$('#addCustomerForm').find('input.an_name, input.an_address, input.installDate, input.idnCode').val('');
$('#dynamic-phones-add').empty().append(PHONE_ROW);
reindexPhones();
ensurePrimary();
}
$(".closeAddUser").click(function(){
$(".addNewUser").removeClass("is-active");
$(".addNewUser").find('.an_name').val('');
$(".addNewUser").find('.an_address').val('');
$(".addNewUser").find('#phone-1').val('');
$(".addNewUser").find('#phone-2').val('');
$(".addNewUser").find('#phone-3').val('');
$(".addNewUser").find('.installDate').val('');
//Refresh page
});
$(".modal-close, .modal-background").click(function(){
$(".mEdit1").removeClass("is-active")
});
$(".closeSearch").click(function(){
resetAddForm();
});
$(".modal-close, .modal-background").click(function(){
$(".mEdit1").removeClass("is-active")
});
$(".closeSearch").click(function(){
$(".SearchBox").removeClass("is-active")
});
$(".closeAddUser").click(function(){
$(".addNewUser").removeClass("is-active");
$(".addNewUser").find('.an_name').val('');
$(".addNewUser").find('.an_address').val('');
$(".addNewUser").find('#phone-1').val('');
$(".addNewUser").find('#phone-2').val('');
$(".addNewUser").find('#phone-3').val('');
$(".addNewUser").find('.installDate').val('');
});
});
$("#addNewUser").click(function(){
$(".addNewUser").addClass("is-active")
});
$("#openSearchBox").click(function(){
$("#addCustomerForm").on('submit', function(){
// جلوگیری از ارسال دوباره؛ خودِ فرم همین حالا داده‌ها را بسته است.
$(this).find('#DcreateCS').prop('disabled', true).text('در حال ثبت…');
});
$("#addNewUser").click(function(){
resetAddForm();
$(".addNewUser").addClass("is-active");
$('#addName').trigger('focus');
});
$(document).on('keydown', function(e){
if (e.key === 'Escape' && $(".addNewUser").hasClass("is-active")) {
$(".addNewUser").removeClass("is-active");
resetAddForm();
}
});
$("#openSearchBox").click(function(){
$(".SearchBox").addClass("is-active")
});
$("#bedehiCN01").click(function(){
});
$("#bedehiCN01").click(function(){
var factorId = $(this).parent().parent().find(".fid").val();
if ($("#bedehiStatus").val() == "tasfiye"){
alert("send To server to tasfiye");
$("tbody").find('.fidv-'+factorId).parent().parent().remove();
$(".mEdit1").removeClass("is-active");
}
});
});
} );
</script>
{% endblock %}
@@ -0,0 +1,149 @@
{% extends "./base.html" %}
{% load custom_tags %}
{% block bodyPage %}
<div class="container mt-5" dir="rtl">
<h1 class="title is-4">صف پیامک‌ها</h1>
<div class="notification is-warning is-light">
پیامک‌ها تا زمانی که مدیر آن‌ها را تأیید نکند ارسال نمی‌شوند. صف را بازبینی
کنید، موارد نامناسب را حذف کنید و سپس تأیید بزنید.
<br>
حذف یک مورد، فقط همان نوبت را کنسل می‌کند. یادآوری‌های دوره‌ای (بازدید و ثبت
اطلاعات چک) در <strong>دورهٔ بعدی</strong> دوباره ساخته می‌شوند؛ پس با حذف یک
مورد، پیامک برای آن مشتری برای همیشه قطع نمی‌شود.
</div>
<div class="columns is-multiline mb-2" dir="rtl">
<div class="column is-narrow">
<div class="box py-3 px-4 has-text-centered">
<p class="heading mb-0">کل صف</p>
<p class="title is-5 mb-0">{{ total_count }}</p>
</div>
</div>
{% for code, label, count in status_stats %}
<div class="column is-narrow">
<a href="?status={{ code }}" class="box py-3 px-4 has-text-centered"
{% if status == code %}style="border:2px solid #3273dc"{% endif %}>
<p class="heading mb-0">{{ label }}</p>
<p class="title is-5 mb-0">{{ count }}</p>
</a>
</div>
{% endfor %}
</div>
{% if status or delivery %}
<p class="mb-3">نتایج فیلترشده: <strong>{{ filtered_count }}</strong> مورد — <a href="{% url 'admin_panel:sms_queue' %}">حذف فیلتر</a></p>
{% endif %}
<form method="get" class="field has-addons mb-3">
<div class="control">
<div class="select">
<select name="status">
<option value="">همهٔ وضعیت‌ها</option>
{% for value, label in status_choices %}
<option value="{{ value }}" {% if status == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control">
<div class="select">
<select name="delivery">
<option value="">همهٔ دلیوری‌ها</option>
{% for value, label in delivery_choices %}
<option value="{{ value }}" {% if delivery == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control"><button class="button" type="submit">فیلتر</button></div>
</form>
<form method="post" action="{% url 'admin_panel:sms_queue_bulk' %}" id="bulk-form" class="mb-3">
{% csrf_token %}
<button class="button is-success" name="action" value="approve_all"
onclick="return confirm('همهٔ پیامک‌های «در انتظار تأیید» تأیید و ارسال شوند؟');">
تأیید همهٔ موارد در انتظار ({{ pending_count }})
</button>
<button class="button is-info" name="action" value="approve_selected">تأیید انتخاب‌شده‌ها</button>
<button class="button is-danger" name="action" value="delete_selected"
onclick="return confirm('موارد انتخاب‌شده از صف حذف شوند؟');">حذف انتخاب‌شده‌ها</button>
</form>
<div class="notification is-danger is-light is-flex is-align-items-center is-justify-content-space-between mb-3">
<span>خالی کردن کل صف: همهٔ پیامک‌ها (در هر وضعیتی) حذف می‌شوند و دیگر بازنمی‌گردند.</span>
<form method="post" action="{% url 'admin_panel:sms_queue_empty' %}"
onsubmit="return confirm('کل صف پیامک خالی شود؟ این پیامک‌ها دیگر هرگز ارسال یا بازسازی نمی‌شوند.');">
{% csrf_token %}
<button class="button is-danger" type="submit">خالی کردن کل صف</button>
</form>
</div>
<div class="table-container">
<table class="table is-fullwidth is-narrow is-hoverable is-striped">
<thead>
<tr>
<th><input type="checkbox" onclick="document.querySelectorAll('.row-check').forEach(c => c.checked = this.checked)"></th>
<th>شناسه</th><th>مشتری</th><th>نوع</th><th>شماره</th>
<th>وضعیت</th><th>دلیوری</th><th>تلاش</th><th>ایجاد</th><th>ارسال</th><th>خطا</th><th></th>
</tr>
</thead>
<tbody>
{% for item in queue %}
<tr>
<td><input class="row-check" type="checkbox" form="bulk-form" name="ids" value="{{ item.id }}"></td>
<td>{{ item.id }}</td>
<td>
{% if item.customer_id %}
<a href="{% url 'admin_panel:customer' item.customer_id %}">{{ item.customer.name_family }}</a>
{% else %}—{% endif %}
</td>
<td>{{ item.get_sms_type_display }}</td>
<td dir="ltr">{{ item.phone_number }}</td>
<td>{{ item.get_status_display }}</td>
<td>{{ item.get_delivery_status_display|default:'—' }}</td>
<td>{{ item.attempts }}</td>
<td>{{ item.created_at|toJalaliDT }}</td>
<td>{{ item.sent_at|toJalaliDT|default:'—' }}</td>
<td class="is-size-7">{{ item.last_error|truncatechars:60 }}</td>
<td>
<div class="buttons are-small">
{% if item.status == 'pending' %}
<form method="post" action="{% url 'admin_panel:sms_queue_approve' item.id %}">
{% csrf_token %}
<button class="button is-small is-success" type="submit">تأیید</button>
</form>
{% elif item.status == 'failed' %}
<form method="post" action="{% url 'admin_panel:sms_queue_retry' item.id %}">
{% csrf_token %}
<button class="button is-small is-warning" type="submit">ارسال مجدد</button>
</form>
{% endif %}
<form method="post" action="{% url 'admin_panel:sms_queue_delete' item.id %}"
onclick="return confirm('این پیامک از صف حذف شود؟');">
{% csrf_token %}
<button class="button is-small is-danger" type="submit">حذف</button>
</form>
</div>
</td>
</tr>
{% empty %}
<tr><td colspan="12" class="has-text-centered">موردی یافت نشد.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if queue.has_other_pages %}
<nav class="pagination" role="navigation" aria-label="pagination">
{% if queue.has_previous %}
<a class="pagination-previous" href="?page={{ queue.previous_page_number }}{% if status %}&status={{ status }}{% endif %}{% if delivery %}&delivery={{ delivery }}{% endif %}">قبلی</a>
{% endif %}
{% if queue.has_next %}
<a class="pagination-next" href="?page={{ queue.next_page_number }}{% if status %}&status={{ status }}{% endif %}{% if delivery %}&delivery={{ delivery }}{% endif %}">بعدی</a>
{% endif %}
<ul class="pagination-list"><li><span class="pagination-link">صفحه {{ queue.number }} از {{ queue.paginator.num_pages }}</span></li></ul>
</nav>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,188 @@
{% extends "./base.html" %}
{% block bodyPage %}
<div class="container mt-5" dir="rtl">
<h1 class="title is-4">تنظیمات سامانه پیامک</h1>
<div class="box">
<form method="post" action="{% url 'admin_panel:sms_settings' %}">
{% csrf_token %}
<div class="columns is-multiline">
<div class="field column is-4-desktop is-12-mobile">
<label class="label">سرویس‌دهنده</label>
<div class="control">
<div class="select is-fullwidth">
<select name="provider">
{% for value, label in provider_choices %}
<option value="{{ value }}" {% if config.provider == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">وضعیت ارسال</label>
<div class="control">
<label class="checkbox">
<input type="checkbox" name="is_enabled" {% if config.is_enabled %}checked{% endif %}>
فعال باشد
</label>
</div>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">مهلت ارتباط (ثانیه)</label>
<div class="control">
<input class="input" type="number" name="timeout_seconds" value="{{ config.timeout_seconds }}">
</div>
</div>
<div class="field column is-12-mobile">
<label class="label">آدرس سرویس پیامک (URL)</label>
<div class="control">
<input class="input" type="text" name="api_url" value="{{ config.api_url }}" dir="ltr">
</div>
<p class="help">
برای 0098SMS همین آدرس لینکی ارسال است
(<span dir="ltr">http://www.0098sms.com/sendsmslink.aspx</span>)؛ این سرویس
WSDL/SOAP ندارد و آدرس WSDL باعث خطای «Invalid XML content» می‌شود.
</p>
</div>
<div class="field column is-12-mobile">
<label class="label">آدرس وب‌سرویس (SOAP/WSDL) — فقط provider وب‌سرویس</label>
<div class="control">
<input class="input" type="text" name="webservice_url" value="{{ config.webservice_url }}" dir="ltr">
</div>
<p class="help">
ارسال با شناسهٔ پیامک (<span dir="ltr">smsid</span>) و استعلام تحویل به گوشی
از همین آدرس انجام می‌شود. پیش‌فرض:
<span dir="ltr">https://webservice.0098sms.com/service.asmx</span>
</p>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">شمارهٔ پنل (PnlNo)</label>
<div class="control">
<input class="input" type="text" name="panel_no" value="{{ config.panel_no }}" dir="ltr">
</div>
<p class="help">اگر خالی بماند از «شماره خط فرستنده» استفاده می‌شود.</p>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">نام کاربری</label>
<div class="control">
<input class="input" type="text" name="username" value="{{ config.username }}" dir="ltr">
</div>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">رمز عبور</label>
<div class="control">
<input class="input" type="password" name="password" placeholder="{% if config.password %}(بدون تغییر)‌{% endif %}" dir="ltr">
</div>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">شماره خط فرستنده</label>
<div class="control">
<input class="input" type="text" name="sender" value="{{ config.sender }}" dir="ltr">
</div>
</div>
<div class="field column is-12-mobile">
<label class="label">توکن API (فقط برای provider HTTP)</label>
<div class="control">
<input class="input" type="password" name="api_token" placeholder="{% if config.api_token %}(بدون تغییر)‌{% endif %}" dir="ltr">
</div>
</div>
</div>
<hr>
<h2 class="title is-5">کنترل جریان ارسال</h2>
<p class="help mb-3">
برای جلوگیری از سیل پیامک: تنها مواردی ارسال می‌شوند که پنجرهٔ یادآوری‌شان از
«خط شروع» به بعد باز شود. موارد قدیمی‌تر تنها با روشن‌کردن «اجازهٔ موارد قدیمی»
و در سقف تعیین‌شده ارسال می‌شوند. بین هر پیامک هم فاصلهٔ زمانی رعایت می‌شود.
</p>
<div class="columns is-multiline">
<div class="field column is-6-desktop is-12-mobile">
<label class="label">تاریخ شروع دوره</label>
<div class="control">
<input class="input" type="text" name="scan_since"
value="{{ scan_since_jalali }}"
placeholder="مثلاً 1403/07/18 — خالی = همین حالا" dir="ltr">
</div>
<p class="help">
فقط یادآوری‌هایی صف می‌شوند که «پنجرهٔ» آن‌ها از این تاریخ به بعد باز شده باشد.
مشتریان قدیمی‌تری که پنجره‌شان پیش از این تاریخ باز شده، وارد صف نمی‌شوند.
قالب: <span dir="ltr">YYYY/MM/DD</span> (شمسی) — خالی = همین حالا.
</p>
</div>
<div class="field column is-3-desktop is-6-mobile">
<label class="label">حداکثر پیامک هر اسکنر در هر اجرا</label>
<div class="control">
<input class="input" type="number" min="0" name="max_per_run" value="{{ config.max_per_run }}">
</div>
</div>
<div class="field column is-3-desktop is-6-mobile">
<label class="label">فاصلهٔ بین هر پیامک (ثانیه)</label>
<div class="control">
<input class="input" type="number" min="0" name="send_interval_seconds" value="{{ config.send_interval_seconds }}">
</div>
</div>
<div class="field column is-3-desktop is-6-mobile">
<label class="label">اجازهٔ ارسال موارد قدیمی</label>
<div class="control">
<label class="checkbox">
<input type="checkbox" name="allow_backlog" {% if config.allow_backlog %}checked{% endif %}>
فعال باشد
</label>
</div>
</div>
<div class="field column is-3-desktop is-6-mobile">
<label class="label">حداکثر موارد قدیمی در هر اجرا</label>
<div class="control">
<input class="input" type="number" min="0" name="backlog_max" value="{{ config.backlog_max }}">
</div>
<p class="help">صفر = هیچ مورد قدیمی ارسال نمی‌شود.</p>
</div>
</div>
<div class="buttons">
<button class="button is-success" type="submit">ذخیره تنظیمات</button>
</div>
</form>
</div>
<div class="box">
<h2 class="title is-5">ارسال پیامک آزمایشی</h2>
<form method="post" action="{% url 'admin_panel:sms_settings' %}">
{% csrf_token %}
<input type="hidden" name="action" value="test">
<div class="field">
<label class="label">متن پیامک آزمایشی</label>
<div class="control">
<textarea class="textarea" name="test_message" rows="3">{{ config.test_message }}</textarea>
</div>
<p class="help">این متن ذخیره می‌شود و در ارسال‌های بعدی هم استفاده خواهد شد.</p>
</div>
<div class="field has-addons">
<div class="control is-expanded">
<input class="input" type="text" name="test_phone" placeholder="مثلاً 09120000000" dir="ltr">
</div>
<div class="control">
<button class="button is-info" type="submit">ارسال آزمایشی</button>
</div>
</div>
</form>
<p class="help">پیامک آزمایشی در صف قرار می‌گیرد و توسط worker ارسال می‌شود.</p>
</div>
</div>
{% endblock %}
@@ -0,0 +1,455 @@
{% extends "./base.html" %}
{% block bodyPage %}
<style>
{% verbatim %}
/* --- صفحهٔ قالب‌های پیامک: استایل‌ها با پیشوند smsx و محدود به همین صفحه --- */
/* هیچ عنصری از این صفحه نباید از کادر خودش بیرون بزند؛ ریشهٔ به‌هم‌ریختگی‌ها
معمولاً همین box-sizing است (width:100% + padding). */
.smsx, .smsx *, .smsx *::before, .smsx *::after { box-sizing: border-box; }
.smsx { max-width: 60rem; margin-inline: auto; }
.smsx img, .smsx textarea, .smsx input, .smsx button { max-width: 100%; }
.smsx-head { display: flex; flex-wrap: wrap; gap: .75rem; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
.smsx-head__actions { display: flex; flex-wrap: wrap; gap: .5rem; }
/* ---------- کارت هر نوع پیامک ---------- */
.smsx-card { background: #fff; border: 1px solid #e8e8e8; border-radius: 12px; box-shadow: 0 1px 2px rgba(0,0,0,.04); margin-bottom: .9rem; overflow: hidden; }
.smsx-card[open] { box-shadow: 0 6px 18px rgba(0,0,0,.07); border-color: #d9d9d9; }
.smsx-card.is-off .smsx-summary__title { color: #9a9a9a; }
.smsx-card.is-dirty { border-color: #ffdd57; }
.smsx-summary { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; gap: .75rem; align-items: center; padding: .9rem 1rem; cursor: pointer; list-style: none; }
.smsx-summary::-webkit-details-marker { display: none; }
.smsx-summary:hover { background: #fafafa; }
.smsx-summary:focus-visible { outline: 2px solid #3273dc; outline-offset: -2px; }
.smsx-summary__icon { font-size: 1.35rem; line-height: 1; }
.smsx-summary__main { min-width: 0; }
.smsx-summary__title { display: block; font-weight: 600; color: #363636; overflow-wrap: anywhere; }
.smsx-summary__sub { display: block; font-size: .78rem; color: #7a7a7a; line-height: 1.7; overflow-wrap: anywhere; }
.smsx-summary__meta { display: flex; gap: .35rem; align-items: center; flex-wrap: wrap; }
.smsx-summary__chev { flex: 0 0 auto; width: .55rem; height: .55rem; border-right: 2px solid #b5b5b5; border-bottom: 2px solid #b5b5b5; transform: rotate(45deg); transition: transform .18s; }
.smsx-card[open] .smsx-summary__chev { transform: rotate(-135deg); }
/* ---------- بدنهٔ فرم ---------- */
.smsx-body { padding: 1rem; border-top: 1px solid #f0f0f0; }
/* گرید سه‌ستونی مقاوم: ستون عنوان کشسان، دو ستون دیگر به‌قدر محتوا */
.smsx-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 10rem) max-content; gap: .9rem 1rem; align-items: start; }
.smsx-cell { display: flex; flex-direction: column; gap: .4rem; min-width: 0; }
.smsx-label { display: block; margin: 0; font-size: .8rem; font-weight: 600; color: #4a4a4a; line-height: 1.6; overflow-wrap: anywhere; }
.smsx-cell--switch .smsx-label { white-space: nowrap; }
.smsx-note { margin-top: .75rem; margin-bottom: 0; line-height: 1.9; }
/* کلید فعال/غیرفعال */
.smsx-switch { display: inline-flex; align-items: center; gap: .5rem; cursor: pointer; user-select: none; min-height: 2.5rem; }
.smsx-switch input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.smsx-switch__track { position: relative; flex: 0 0 auto; width: 46px; height: 26px; border-radius: 13px; background: #dbdbdb; transition: background .18s; }
.smsx-switch__track::after { content: ""; position: absolute; top: 3px; inset-inline-start: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3); transition: transform .18s; }
.smsx-switch input:checked + .smsx-switch__track { background: #48c774; }
.smsx-switch input:checked + .smsx-switch__track::after { transform: translateX(-20px); }
.smsx-switch input:focus-visible + .smsx-switch__track { outline: 2px solid #3273dc; outline-offset: 2px; }
.smsx-switch__text { font-size: .85rem; color: #4a4a4a; white-space: nowrap; }
/* ---------- کادر نوشتن متن ---------- */
.smsx-editor { margin-top: 1rem; border: 1px solid #e2e2e2; border-radius: 10px; background: #fff; overflow: hidden; transition: border-color .15s, box-shadow .15s; }
.smsx-editor:focus-within { border-color: #3273dc; box-shadow: 0 0 0 3px rgba(50,115,220,.12); }
/* padding افقی سر/پا هم‌اندازهٔ padding خود textarea (1rem) تا متن و برچسب هم‌تراز بمانند */
.smsx-editor__head { display: flex; flex-wrap: wrap; gap: .5rem .75rem; align-items: center; justify-content: space-between; padding: .65rem 1rem; background: #fbfbfb; border-bottom: 1px solid #eee; }
.smsx-chips { display: flex; flex-wrap: wrap; gap: .35rem; }
.smsx-chip { display: inline-block; font-size: .75rem; line-height: 1.6; padding: .35rem .65rem; border-radius: 999px; border: 1px solid #dbdbdb; background: #fff; color: #4a4a4a; cursor: pointer; direction: ltr; font-family: ui-monospace, Menlo, Consolas, monospace; white-space: nowrap; }
.smsx-chip:hover { border-color: #3273dc; color: #3273dc; background: #f5f9ff; }
.smsx-chip:active { transform: translateY(1px); }
/* textarea دقیقاً به اندازهٔ کادر؛ بدون border/padding سرکش */
.smsx-editor textarea.smsx-textarea {
display: block; width: 100%; max-width: 100%; margin: 0;
border: 0; border-radius: 0; box-shadow: none; background: transparent;
padding: .85rem 1rem; font-size: 1rem; line-height: 1.9; font-family: inherit;
resize: vertical; overflow-y: auto;
unicode-bidi: plaintext; text-align: start; overflow-wrap: anywhere;
}
.smsx-editor textarea.smsx-textarea:focus { border: 0; box-shadow: none; outline: none; }
.smsx-editor__foot { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem .6rem; padding: .5rem 1rem; border-top: 1px solid #f0f0f0; background: #fbfbfb; font-size: .78rem; color: #7a7a7a; }
.smsx-dot { color: #dbdbdb; }
.smsx-metric { font-variant-numeric: tabular-nums; }
.smsx-metric.is-ok { color: #257942; }
.smsx-metric.is-warn { color: #b8860b; }
.smsx-metric.is-bad { color: #cc0f35; font-weight: 600; }
.smsx-warn { color: #cc0f35; overflow-wrap: anywhere; }
/* ---------- پیش‌نمایش ---------- */
.smsx-preview { margin-top: 1rem; }
.smsx-preview__label { display: block; font-size: .8rem; font-weight: 600; color: #4a4a4a; margin-bottom: .4rem; }
.smsx-bubble { background: #ecf7ee; border: 1px solid #d5ecda; border-radius: 14px 14px 4px 14px; padding: .75rem .95rem; line-height: 1.9; font-size: .9rem; white-space: pre-wrap; overflow-wrap: anywhere; min-height: 2.6rem; }
.smsx-preview__hint { margin: .4rem 0 0; font-size: .75rem; line-height: 1.8; }
.smsx-actions { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; margin-top: 1rem; }
.smsx-dirty { font-size: .8rem; color: #b8860b; }
.smsx-help { margin-bottom: 1rem; background: #f5f9ff; border: 1px solid #dbe9ff; border-radius: 10px; padding: .7rem .95rem; }
.smsx-help > summary { cursor: pointer; font-size: .85rem; color: #3273dc; font-weight: 600; }
.smsx-help .smsx-chips { margin-top: .6rem; }
.smsx-help .smsx-chip { cursor: default; white-space: normal; }
/* ---------- موبایل ---------- */
@media (max-width: 768px) {
.smsx-body { padding: .85rem; }
.smsx-summary { grid-template-columns: auto minmax(0, 1fr) auto; padding: .8rem .85rem; row-gap: .5rem; }
.smsx-summary__meta { grid-column: 1 / -1; }
/* همهٔ فیلدها زیر هم؛ دیگر هیچ چیز به هم نمی‌چسبد */
.smsx-grid { grid-template-columns: minmax(0, 1fr); }
.smsx-cell--switch .smsx-label { white-space: normal; }
.smsx-editor textarea.smsx-textarea { font-size: 16px; } /* جلوگیری از زوم خودکار iOS */
.smsx-actions .button { width: 100%; }
}
{% endverbatim %}
</style>
<div class="container mt-5" dir="rtl">
<div class="smsx">
<div class="smsx-head">
<div>
<h1 class="title is-4 mb-1">قالب‌های پیامک</h1>
<p class="subtitle is-6 has-text-grey mb-0">
متن هر نوع پیامک و زمان ارسال آن را اینجا تعیین کنید. برای ویرایش، روی هر کارت بزنید.
</p>
</div>
<div class="smsx-head__actions">
<button type="button" class="button is-small is-light" data-smsx-expand-all>باز کردن همه</button>
<button type="button" class="button is-small is-light" data-smsx-collapse-all>بستن همه</button>
</div>
</div>
<details class="smsx-help">
<summary>متغیرهای قابل استفاده در متن پیامک</summary>
<p class="help mb-0">کافی است روی هر متغیر بزنید تا در کادر متن همان پیامک درج شود.</p>
<div class="smsx-chips">
{% for token, desc in variables %}
<span class="smsx-chip" title="{{ desc }}">{{ token }} <span class="has-text-grey">{{ desc }}</span></span>
{% endfor %}
</div>
</details>
{{ smsx_config|json_script:"smsx-config" }}
{% for row in rows %}
<details class="smsx-card" id="type-{{ row.code }}" {% if forloop.first %}open{% endif %} data-smsx-card>
<summary class="smsx-summary">
<span class="smsx-summary__icon" aria-hidden="true">{{ row.icon }}</span>
<span class="smsx-summary__main">
<span class="smsx-summary__title">{{ row.label }}</span>
<span class="smsx-summary__sub">
{{ row.when }}{% if row.timing_value is not None %} · {{ row.timing_label }}: {{ row.timing_value }}{% endif %}
</span>
</span>
<span class="smsx-summary__meta">
<span class="tag is-light" data-smsx-badge>—</span>
{% if not row.template.is_enabled %}
<span class="tag is-warning is-light">غیرفعال</span>
{% endif %}
{% if row.template.auto_send %}
<span class="tag is-success is-light">خودکار</span>
{% endif %}
</span>
<span class="smsx-summary__chev" aria-hidden="true"></span>
</summary>
<form class="smsx-body" method="post" action="{% url 'admin_panel:sms_template_edit' row.template.id %}">
{% csrf_token %}
<div class="smsx-grid">
<div class="smsx-cell">
<label class="smsx-label" for="title-{{ row.code }}">عنوان</label>
<input id="title-{{ row.code }}" class="input" type="text" name="title" value="{{ row.template.title }}">
</div>
{% if row.timing_value is not None %}
<div class="smsx-cell">
<label class="smsx-label" for="timing-{{ row.code }}">{{ row.timing_label }}</label>
<input id="timing-{{ row.code }}" class="input" type="number" inputmode="numeric" min="0"
name="timing_value" value="{{ row.timing_value|default_if_none:'' }}">
</div>
{% endif %}
<div class="smsx-cell smsx-cell--switch">
<span class="smsx-label">وضعیت ارسال</span>
<label class="smsx-switch">
<input type="checkbox" name="is_enabled" {% if row.template.is_enabled %}checked{% endif %}>
<span class="smsx-switch__track"></span>
<span class="smsx-switch__text">{% if row.template.is_enabled %}فعال{% else %}غیرفعال{% endif %}</span>
</label>
</div>
<div class="smsx-cell smsx-cell--switch">
<span class="smsx-label">ارسال</span>
<label class="smsx-switch" title="روشن = ارسال خودکار بدون صف تأیید مدیر / خاموش = نیازمند تأیید مدیر">
<input type="checkbox" name="auto_send" {% if row.template.auto_send %}checked{% endif %}>
<span class="smsx-switch__track"></span>
<span class="smsx-switch__text">{% if row.template.auto_send %}خودکار{% else %}با تأیید مدیر{% endif %}</span>
</label>
</div>
</div>
{% if row.timing.note %}
<p class="help smsx-note">{{ row.timing.note }}</p>
{% endif %}
<div class="smsx-editor">
<div class="smsx-editor__head">
<label class="smsx-label" for="text-{{ row.code }}">متن پیامک</label>
<div class="smsx-chips" role="group" aria-label="درج متغیر در متن">
{% for token, desc in variables %}
<button type="button" class="smsx-chip" data-smsx-insert="{{ token }}"
title="{{ desc }}" aria-label="درج {{ token }} — {{ desc }}">{{ token }}</button>
{% endfor %}
</div>
</div>
<textarea id="text-{{ row.code }}" class="textarea smsx-textarea" name="text" rows="6"
data-smsx-text aria-describedby="meta-{{ row.code }}"
placeholder="متن پیامک را اینجا بنویسید…">{{ row.template.text }}</textarea>
<div class="smsx-editor__foot" id="meta-{{ row.code }}">
<span class="smsx-metric" data-smsx-counter>—</span>
<span class="smsx-dot" aria-hidden="true">•</span>
<span class="smsx-metric" data-smsx-segments>—</span>
<span class="smsx-warn" data-smsx-warn hidden></span>
</div>
</div>
<div class="smsx-preview">
<span class="smsx-preview__label">پیش‌نمایش (با دادهٔ نمونه)</span>
<div class="smsx-bubble" data-smsx-preview>{{ row.preview }}</div>
<p class="help smsx-preview__hint">
مقادیر نمونه فقط برای نمایش است؛ هنگام ارسال، اطلاعات واقعی همان مشتری جای متغیرها می‌نشیند.
</p>
</div>
<div class="smsx-actions">
<button class="button is-success" type="submit">ذخیره</button>
<span class="smsx-dirty" data-smsx-dirty hidden>● تغییرات ذخیره‌نشده</span>
</div>
</form>
</details>
{% empty %}
<div class="notification is-warning">هیچ قالبی تعریف نشده است.</div>
{% endfor %}
</div>
</div>
{% endblock %}
{% block jsEnd %}
<script>
{% verbatim %}
(function () {
"use strict";
var configEl = document.getElementById("smsx-config");
if (!configEl) return;
var CFG = JSON.parse(configEl.textContent);
var TOKENS = CFG.tokens || [];
var SAMPLE = CFG.sample || {};
var TOKEN_RE = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
// GSM-7 پایه (یک نویسه) و نویسه‌های افزودنی (دو نویسه)
var GSM_BASIC = {};
" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\n\r".split("").forEach(function (c) { GSM_BASIC[c] = true; });
var GSM_EXT = {};
["^", "{", "}", "[", "]", "~", "|", "\\", "€"].forEach(function (c) { GSM_EXT[c] = true; });
function toFa(value) {
return String(value).replace(/\d/g, function (d) { return "۰۱۲۳۴۵۶۷۸۹"[d]; });
}
// شمارش نویسه و بخش‌های پیامک: GSM-7 ⇒ ۱۶۰/۱۵۳، یونیکد ⇒ ۷۰/۶۷
function measure(text) {
var chars = Array.from(text);
var gsm = true, len = 0;
for (var i = 0; i < chars.length; i++) {
var c = chars[i];
if (GSM_BASIC[c]) { len += 1; }
else if (GSM_EXT[c]) { len += 2; }
else { gsm = false; break; }
}
if (!gsm) len = chars.length;
var single = gsm ? 160 : 70, multi = gsm ? 153 : 67;
var parts = len === 0 ? 0 : (len <= single ? 1 : Math.ceil(len / multi));
return { len: len, parts: parts, gsm: gsm };
}
function fillSample(text) {
return text.replace(TOKEN_RE, function (match, name) {
return Object.prototype.hasOwnProperty.call(SAMPLE, name) ? SAMPLE[name] : "";
});
}
function unknownTokens(text) {
var found, seen = {}, out = [];
while ((found = TOKEN_RE.exec(text)) !== null) {
var name = "{" + found[1] + "}";
if (TOKENS.indexOf(name) === -1 && !seen[name]) { seen[name] = true; out.push(name); }
}
TOKEN_RE.lastIndex = 0;
return out;
}
function cardOf(el) { return el ? el.closest("[data-smsx-card]") : null; }
function refresh(card) {
var ta = card.querySelector("[data-smsx-text]");
if (!ta) return;
var text = ta.value;
var shown = fillSample(text);
var info = measure(shown);
var counter = card.querySelector("[data-smsx-counter]");
var segments = card.querySelector("[data-smsx-segments]");
var warn = card.querySelector("[data-smsx-warn]");
var preview = card.querySelector("[data-smsx-preview]");
var badge = card.querySelector("[data-smsx-badge]");
counter.textContent = toFa(info.len) + " نویسه";
if (info.parts === 0) {
segments.textContent = "متن خالی است";
segments.className = "smsx-metric";
} else {
segments.textContent = toFa(info.parts) + (info.parts === 1 ? " پیامک" : " پیامک (چندبخشی)");
segments.className = "smsx-metric " + (info.parts <= 1 ? "is-ok" : (info.parts <= 3 ? "is-warn" : "is-bad"));
}
if (badge) badge.textContent = info.parts === 0 ? "—" : toFa(info.parts) + " پیامک";
var unknown = unknownTokens(text);
if (unknown.length) {
warn.hidden = false;
warn.textContent = "متغیر ناشناخته: " + unknown.join("، ") + " — خالی رندر می‌شود.";
} else {
warn.hidden = true;
warn.textContent = "";
}
if (preview) preview.textContent = shown;
}
var DIRTY = false;
function markDirty(card) {
if (!card || card.classList.contains("is-dirty")) return;
card.classList.add("is-dirty");
var flag = card.querySelector("[data-smsx-dirty]");
if (flag) flag.hidden = false;
DIRTY = true;
}
function insertAtCursor(ta, token) {
ta.focus();
var value = ta.value;
var start = ta.selectionStart == null ? value.length : ta.selectionStart;
var end = ta.selectionEnd == null ? value.length : ta.selectionEnd;
var before = value.slice(0, start);
var prefix = (before && !/\s$/.test(before)) ? " " : "";
ta.value = before + prefix + token + value.slice(end);
var pos = start + prefix.length + token.length;
ta.setSelectionRange(pos, pos);
}
document.addEventListener("click", function (event) {
// بستن اعلان‌ها (Bulma JS بارگذاری نشده است).
var dismiss = event.target.closest(".notification .delete");
if (dismiss) {
var box = dismiss.closest(".notification");
if (box) box.remove();
return;
}
var insert = event.target.closest("[data-smsx-insert]");
if (insert) {
event.preventDefault();
var card = cardOf(insert);
var ta = card && card.querySelector("[data-smsx-text]");
if (!ta) return;
insertAtCursor(ta, insert.getAttribute("data-smsx-insert"));
refresh(card);
markDirty(card);
return;
}
if (event.target.closest("[data-smsx-expand-all]")) {
document.querySelectorAll("[data-smsx-card]").forEach(function (d) { d.open = true; refresh(d); });
return;
}
if (event.target.closest("[data-smsx-collapse-all]")) {
document.querySelectorAll("[data-smsx-card]").forEach(function (d) { d.open = false; });
}
});
document.addEventListener("input", function (event) {
var card = cardOf(event.target);
if (!card) return;
if (event.target.matches("[data-smsx-text]")) {
refresh(card);
markDirty(card);
} else if (event.target.matches('input[name="title"], input[name="timing_value"]')) {
markDirty(card);
}
});
document.addEventListener("change", function (event) {
var input = event.target;
if (!input.matches('input[name="is_enabled"], input[name="auto_send"]')) return;
var card = cardOf(input);
if (!card) return;
markDirty(card);
if (input.name === "is_enabled") card.classList.toggle("is-off", !input.checked);
// متن سوئیچِ همان کلید به‌روز می‌شود (نه اولین سوئیچ کارت).
var sw = input.closest(".smsx-switch");
var label = sw && sw.querySelector(".smsx-switch__text");
if (label) {
label.textContent = input.name === "is_enabled"
? (input.checked ? "فعال" : "غیرفعال")
: (input.checked ? "خودکار" : "با تأیید مدیر");
}
});
// «toggle» رویداد حباب‌شو نیست؛ با capture گرفته می‌شود.
document.addEventListener("toggle", function (event) {
if (event.target.matches && event.target.matches("[data-smsx-card]") && event.target.open) {
refresh(event.target);
}
}, true);
document.addEventListener("submit", function () { DIRTY = false; });
window.addEventListener("beforeunload", function (event) {
if (!DIRTY) return;
event.preventDefault();
event.returnValue = "";
});
function init() {
document.querySelectorAll("[data-smsx-card]").forEach(function (card) {
var toggle = card.querySelector('input[name="is_enabled"]');
if (toggle) card.classList.toggle("is-off", !toggle.checked);
refresh(card);
});
// پس از ذخیره، همان کارت باز و در نمای دید باقی می‌ماند (لنگر #type-…).
var hash = window.location.hash;
if (hash.indexOf("#type-") === 0) {
var target = document.getElementById(hash.slice(1));
if (target) {
document.querySelectorAll("[data-smsx-card][open]").forEach(function (d) {
if (d !== target) d.open = false;
});
target.open = true;
refresh(target);
target.scrollIntoView({ block: "start" });
}
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
{% endverbatim %}
</script>
{% endblock %}
+14 -4
View File
@@ -1,5 +1,6 @@
from django import template
from persiantools.jdatetime import JalaliDate
from django.utils import timezone
from persiantools.jdatetime import JalaliDate, JalaliDateTime
import datetime
register = template.Library()
@@ -8,8 +9,17 @@ def toJalali(value):
if(value):
return JalaliDate(value).strftime("%Y/%m/%d")
def toJalaliDT(value):
"""تاریخ و ساعت به شمسی، در تایم‌زون محلی پروژه."""
if not value:
return ""
if timezone.is_aware(value):
value = timezone.localtime(value)
return JalaliDateTime(value).strftime("%Y/%m/%d %H:%M")
def has_group(user, group_name):
return user.groups.filter(name=group_name).exists()
register.filter('has_group', has_group)
register.filter('toJalali', toJalali)
register.filter('has_group', has_group)
register.filter('toJalali', toJalali)
register.filter('toJalaliDT', toJalaliDT)
+15 -1
View File
@@ -79,5 +79,19 @@ urlpatterns = [
path('addSarresid/<int:cs_id>', views.addSarresid, name='addSarresid'),
path('pardakhtNesiye/<int:cs_id>', views.pardakhtNesiye, name='pardakhtNesiye'),
# === مدیریت پیامک ===
path('sms/settings', views.sms_settings, name='sms_settings'),
path('sms/templates', views.sms_templates, name='sms_templates'),
path('sms/template/<int:t_id>/edit', views.sms_template_edit, name='sms_template_edit'),
path('sms/customer/<int:c_id>/toggle', views.sms_customer_toggle, name='sms_customer_toggle'),
path('sms/customer/<int:c_id>/primary', views.sms_primary_toggle, name='sms_primary_toggle'),
path('sms/customer/<int:c_id>/send', views.sms_send_custom, name='sms_send_custom'),
path('sms/queue', views.sms_queue, name='sms_queue'),
path('sms/queue/bulk', views.sms_queue_bulk, name='sms_queue_bulk'),
path('sms/queue/empty', views.sms_queue_empty, name='sms_queue_empty'),
path('sms/queue/<int:q_id>/approve', views.sms_queue_approve, name='sms_queue_approve'),
path('sms/queue/<int:q_id>/delete', views.sms_queue_delete, name='sms_queue_delete'),
path('sms/queue/<int:q_id>/retry', views.sms_queue_retry, name='sms_queue_retry'),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
+1
View File
@@ -21,3 +21,4 @@ from .store import * # noqa: F401,F403
from .finance import * # noqa: F401,F403
from .actions import * # noqa: F401,F403
from .cheque_inquiry import * # noqa: F401,F403
from .sms import * # noqa: F401,F403
+18
View File
@@ -38,6 +38,24 @@ def _parse_jalali_date(date_str):
return None
def _parse_phone_rows(request):
"""آرایه‌های شمارهٔ فرم مشتری را به ردیف‌های معتبر تبدیل می‌کند.
خروجی یک سه‌گانه است: ردیف‌های آمادهٔ ذخیره، آرایهٔ خام شماره‌ها، و ایندکس ردیف‌های
«اصلی». ایندکس‌ها عمداً نسبت به *آرایهٔ خام* برگردانده می‌شوند نه ردیف‌های فیلترشده؛
وگرنه حذف ردیف‌های خالی، ایندکس‌ها را جابه‌جا می‌کرد و تیک «اصلی» به شمارهٔ اشتباه می‌خورد.
"""
titles = request.POST.getlist('phone_titles')
phones = request.POST.getlist('phones')
primary = {int(i) for i in request.POST.getlist('phone_primary') if str(i).isdigit()}
rows = [
{'title': title.strip() or 'تلفن', 'phone': phone.strip()}
for title, phone in zip(titles, phones)
if phone.strip()
]
return rows, phones, primary
def _parse_timestamp(ts_value):
"""تبدیل یونیکس تایم‌استمپ‌های ارسالی از جاوااسکریپت"""
if not ts_value:
+80 -46
View File
@@ -11,8 +11,13 @@ from django.contrib.auth.decorators import login_required, permission_required
from persiantools.jdatetime import JalaliDate
from ..models import (Customer, PhoneNumber, ProductCategory, Product, Factor,
FactorIteam, Article, Sarresid, Action, CacheTable)
from ._helpers import logger, _parse_jalali_date
FactorIteam, Article, Sarresid, Action, CacheTable, SMSType)
from ..sms.enqueue import enqueue_sms
from ..sms.phones import (customer_phone_entries, normalize_primary_values,
save_primary_normalized)
from ..sms.recipients import resolve_recipients
from ..sms.rules import customer_toggle_map
from ._helpers import logger, _parse_jalali_date, _parse_phone_rows
@csrf_exempt
@@ -23,19 +28,21 @@ def customers(request):
searchStatus = True
full_path = request.get_full_path()
# مشتریان غیرفعال هرگز نمایش داده نمی‌شوند — نه در فهرست کامل و نه در جستجو.
all_customers = Customer.objects.filter(is_active=True)
if searchOn == 'code':
all_customers = Customer.objects.filter(idCode__contains=searchVal).order_by('-create_date')
all_customers = all_customers.filter(idCode__contains=searchVal)
elif searchOn == 'phone':
all_customers = Customer.objects.filter(Q(phone1__contains=searchVal) | Q(phone2__contains=searchVal) | Q(phone3__contains=searchVal) ).order_by('-create_date')
all_customers = all_customers.filter(Q(phone1__contains=searchVal) | Q(phone2__contains=searchVal) | Q(phone3__contains=searchVal))
elif searchOn == 'name':
all_customers = Customer.objects.filter(name_family__contains=searchVal).order_by('-create_date')
all_customers = all_customers.filter(name_family__contains=searchVal)
elif searchOn == 'address':
all_customers = Customer.objects.filter(address__contains=searchVal).order_by('-create_date')
all_customers = all_customers.filter(address__contains=searchVal)
elif searchOn == 'laghv':
all_customers = Customer.objects.filter(laghv_bazdid=True).order_by('-create_date')
all_customers = all_customers.filter(laghv_bazdid=True)
else:
searchStatus = False
all_customers = Customer.objects.filter(is_active=True).order_by('-create_date')
all_customers = all_customers.order_by('-create_date')
paginator = Paginator(all_customers, 25) # Show 25 contacts per page.
page_number = request.GET.get('page', None)
@@ -50,17 +57,19 @@ def customers(request):
def customersSearch(request, searchVal,searchOn):
searchStatus = True
# همان قاعدهٔ فهرست اصلی: مشتری غیرفعال در هیچ نتیجه‌ای نمایش داده نمی‌شود.
all_customers = Customer.objects.filter(is_active=True)
if searchOn == 'code':
all_customers = Customer.objects.filter(idCode__contains=searchVal).order_by('-create_date')
all_customers = all_customers.filter(idCode__contains=searchVal)
elif searchOn == 'phone':
all_customers = Customer.objects.filter(phone1__contains=searchVal).order_by('-create_date')
all_customers = all_customers.filter(phone1__contains=searchVal)
elif searchOn == 'name':
all_customers = Customer.objects.filter(name_family__contains=searchVal).order_by('-create_date')
all_customers = all_customers.filter(name_family__contains=searchVal)
elif searchOn == 'address':
all_customers = Customer.objects.filter(address__contains=searchVal).order_by('-create_date')
all_customers = all_customers.filter(address__contains=searchVal)
else:
searchStatus = False
all_customers = Customer.objects.all().order_by('-create_date')
all_customers = all_customers.order_by('-create_date')
paginator = Paginator(all_customers, 25) # Show 25 contacts per page.
page_number = request.GET.get('page', None)
@@ -112,7 +121,17 @@ def customerNew2(request ,c_id):
#all_prd = serializers.serialize("json", all_prda)
#return JsonResponse(all_prd,safe = False)
dateNow = datetime.datetime.now().date
data = {'customer': customer, 'categorys': categorys, 'dateNow': dateNow, 'factorha': factorha, 'mandehesabCs':mandehesabCs}
sms_toggles = customer_toggle_map(customer)
# همهٔ شماره‌ها از هر منبعی که ذخیره شده‌اند (PhoneNumber و phone1/2/3 قدیمی).
sms_phone_entries = customer_phone_entries(customer)
sms_effective = {n for n in resolve_recipients(customer)}
data = {
'customer': customer, 'categorys': categorys, 'dateNow': dateNow,
'factorha': factorha, 'mandehesabCs': mandehesabCs,
'sms_toggles': sms_toggles, 'sms_phone_entries': sms_phone_entries,
'sms_effective': sms_effective,
'sms_recipients': sorted(sms_effective),
}
return render(request, 'admin_panel/customerNew2.html', data)
@@ -223,18 +242,8 @@ def customersAdd(request):
installDate = request.POST.get('installDate', '')
idnCode = request.POST.get('idnCode', '')
# دریافت آرایه‌های عنوان و شماره تلفن
titles_list = request.POST.getlist('phone_titles')
phones_list = request.POST.getlist('phones')
# ترکیب عنوان‌ها و شماره‌ها و حذف ردیف‌های خالی
valid_phones = []
for title, phone in zip(titles_list, phones_list):
if phone.strip():
valid_phones.append({
'title': title.strip() if title.strip() else 'تلفن',
'phone': phone.strip()
})
# آرایه‌های عنوان/شمارهٔ فرم + ایندکس ردیف‌های علامت‌خورده به‌عنوان «اصلی»
valid_phones, phones_list, primary_indices = _parse_phone_rows(request)
year, month, day = map(int, installDate.split('/'))
installDate_gregorian = JalaliDate(year, month, day).to_gregorian()
@@ -249,34 +258,43 @@ def customersAdd(request):
newCs.phone1 = valid_phones[0]['phone'] if len(valid_phones) > 0 else ''
newCs.phone2 = valid_phones[1]['phone'] if len(valid_phones) > 1 else ''
newCs.phone3 = valid_phones[2]['phone'] if len(valid_phones) > 2 else ''
# شماره‌های «اصلی» پیامک، بر مبنای مقدارِ نرمال‌شدهٔ خودِ شماره‌ها (پایدار در بازسازی‌ها).
newCs.sms_primary_phones = normalize_primary_values(
phones_list[i] for i in primary_indices if i < len(phones_list)
)
newCs.save()
# ذخیره شماره‌ها به صورت نامحدود در جدول PhoneNumber
for item in valid_phones:
PhoneNumber.objects.create(customer=newCs, title=item['title'], phone_number=item['phone'])
PhoneNumber.objects.create(
customer=newCs, title=item['title'], phone_number=item['phone'],
)
# پیامک خوش‌آمدگویی (در صورت فعال بودن قالب و تنظیم مشتری)
enqueue_sms(
customer=newCs, sms_type=SMSType.WELCOME,
context={'name': newCs.name_family},
dedup_key=f'welcome:{newCs.id}',
created_by=request.user,
)
return redirect('/panel/customers/')
@login_required(login_url='/panel/p_login/')
def customerEdit(request, c_id):
# این مسیر فقط endpoint فرم ویرایش است؛ دسترسی مستقیم با GET نباید ۵۰۰ بدهد.
if request.method != 'POST':
return redirect('/panel/customer/' + str(c_id))
an_name = request.POST.get('an_name', '')
an_address = request.POST.get('an_address', '')
installDate = request.POST.get('installDate', '')
idnCode = request.POST.get('idnCode', '')
laghv_bazdid = request.POST.get('laghv_bazdid', False)
# دریافت آرایه‌های عنوان و شماره تلفن
titles_list = request.POST.getlist('phone_titles')
phones_list = request.POST.getlist('phones')
valid_phones = []
for title, phone in zip(titles_list, phones_list):
if phone.strip():
valid_phones.append({
'title': title.strip() if title.strip() else 'تلفن',
'phone': phone.strip()
})
# آرایه‌های عنوان/شمارهٔ فرم + ایندکس ردیف‌های علامت‌خورده به‌عنوان «اصلی»
valid_phones, phones_list, primary_indices = _parse_phone_rows(request)
year, month, day = map(int, installDate.split('/'))
installDate_gregorian = JalaliDate(year, month, day).to_gregorian()
@@ -287,17 +305,33 @@ def customerEdit(request, c_id):
newCs.installDate = installDate_gregorian
newCs.idCode = idnCode
newCs.laghv_bazdid = True if laghv_bazdid == 'true' else False
# فقط وقتی فرم فیلد توضیحات را فرستاده باشد مقدارش عوض می‌شود؛ فرم‌های قدیمی
# که این فیلد را ندارند نباید یادداشت‌های پاک‌سازی را پاک کنند.
if 'notes' in request.POST:
newCs.notes = request.POST.get('notes', '')
# آپدیت فیلدهای قدیمی جهت حفظ سازگاری
newCs.phone1 = valid_phones[0]['phone'] if len(valid_phones) > 0 else ''
newCs.phone2 = valid_phones[1]['phone'] if len(valid_phones) > 1 else ''
newCs.phone3 = valid_phones[2]['phone'] if len(valid_phones) > 2 else ''
# آپدیت فیلدهای قدیمی جهت حفظ سازگاری (تنها وقتی شماره‌ای ارسال شده باشد)
if valid_phones:
newCs.phone1 = valid_phones[0]['phone'] if len(valid_phones) > 0 else ''
newCs.phone2 = valid_phones[1]['phone'] if len(valid_phones) > 1 else ''
newCs.phone3 = valid_phones[2]['phone'] if len(valid_phones) > 2 else ''
newCs.save()
# بروزرسانی جدول بی‌نهایت (حذف قبلی‌ها و درج مجدد)
PhoneNumber.objects.filter(customer=newCs).delete()
for item in valid_phones:
PhoneNumber.objects.create(customer=newCs, title=item['title'], phone_number=item['phone'])
# بروزرسانی جدول شماره‌ها فقط در صورت ارسال شماره‌های جدید.
# اگر فرم شماره‌ای نفرستاده باشد (فیلدهای غیرفعال)، شماره‌های فعلی دست‌نخورده می‌مانند.
if valid_phones:
PhoneNumber.objects.filter(customer=newCs).delete()
for item in valid_phones:
PhoneNumber.objects.create(
customer=newCs, title=item['title'], phone_number=item['phone'],
)
# اگر فرم ویرایش ردیف‌های «اصلی» را مشخص کرده باشد، همان‌ها ذخیره می‌شوند
# (مبتنی بر خودِ شماره، نه ایندکس رکورد).
if primary_indices:
save_primary_normalized(
newCs, [phones_list[i] for i in primary_indices if i < len(phones_list)],
)
return redirect('/panel/customer/'+str(c_id))
+472
View File
@@ -0,0 +1,472 @@
"""ویوهای مدیریت پیامک در پنل (نیازمند پرمیشن ``admin_panel.ManageSMS``)."""
import re
from datetime import datetime, time
from urllib.parse import urlencode
from django.contrib.auth.decorators import login_required, permission_required
from django.core.paginator import Paginator
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from ..models import (Customer, SMSDedup, SMSProviderConfig, SMSQueue,
SMSTemplate, SMSType)
from ..sms.enqueue import enqueue_sms
from ..sms.phones import (customer_phone_entries, save_primary_normalized,
stored_primary_normalized)
from ..sms.render import format_jalali, render_template
from ..sms.timing import apply_timing, current_timing_value, timing_for
_manage_required = permission_required("admin_panel.ManageSMS", raise_exception=True)
def _notice(url, code, **extra):
"""بازگشت به ``url`` همراه با کد پیام نتیجه (جایگزین flash message).
کد در querystring می‌رود و فقط در همان صفحه رندر می‌شود؛ بر خلاف پیام جنگو
که در session می‌ماند و می‌تواند در صفحه‌ای بی‌ربط ظاهر شود.
"""
query = urlencode({"notice": code, **extra})
return redirect(f"{url}{'&' if '?' in url else '?'}{query}")
def _customer_url(c_id):
return reverse("admin_panel:customer", kwargs={"c_id": c_id})
# متغیرهای قابل استفاده در متن قالب‌ها (برای راهنمای پنل).
TEMPLATE_VARIABLES = [
("{name}", "نام مشتری"),
("{amount}", "مبلغ"),
("{amount_sep}", "مبلغ با جداکنندهٔ هزارگان"),
("{date}", "تاریخ جلالی"),
("{days}", "تعداد روز"),
("{check_row}", "شمارهٔ ردیف چک"),
("{owner_name}", "نام مالک چک"),
]
# فقط برای نمایش: آیکن و توضیح کوتاه «چه زمانی ارسال می‌شود» هر نوع (لایهٔ UI، نه دامنه).
SMS_TYPE_UI = {
SMSType.WELCOME: {"icon": "👋", "when": "بیدرنگ پس از ثبت مشتری"},
SMSType.CHEQUE_DUE: {"icon": "🧾", "when": "پیش از سررسید چک"},
SMSType.NESIYE_DUE: {"icon": "💳", "when": "پیش از سررسید نسیه"},
SMSType.CHEQUE_REGISTRATION: {"icon": "⏳", "when": "تا ثبت‌شدن اطلاعات چک"},
SMSType.DEBT_DUE: {"icon": "💰", "when": "روز سررسید وصول مطالبات"},
SMSType.SURVEY: {"icon": "⭐", "when": "پس از صدور فاکتور"},
SMSType.VISIT: {"icon": "🔧", "when": "پس از گذشت دورهٔ بازدید"},
SMSType.CUSTOM: {"icon": "✍️", "when": "ارسال دستی از صفحهٔ مشتری"},
}
# نمونهٔ داده برای «پیش‌نمایش زندهٔ» متن در پنل (هیچ پیامکی با این مقادیر ارسال نمی‌شود).
SAMPLE_CONTEXT = {
"name": "مشتری نمونه",
"amount": 250000,
"amount_sep": "250,000",
"date": None, # در زمان ساخت ویو با تاریخ امروز پر می‌شود
"days": 2,
"check_row": "1234",
"owner_name": "نام مالک",
}
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_settings(request):
config = SMSProviderConfig.load()
if request.method == "POST":
if request.POST.get("action") == "test":
phone = request.POST.get("test_phone", "").strip()
# متن آزمایشی از فرم همان کارت خوانده و برای دفعهٔ بعد ذخیره می‌شود.
test_text = (request.POST.get("test_message") or "").strip()
if test_text:
config.test_message = test_text
config.save(update_fields=["test_message"])
enqueue_sms(
customer=None, sms_type=SMSType.CUSTOM,
message=test_text or config.test_message,
recipients=resolve_recipients_test(phone), created_by=request.user,
approved=True, # ارسال دستیِ مدیر؛ نیاز به بازبینی صف ندارد.
)
return _notice(reverse("admin_panel:sms_settings"), "test_sent")
config.provider = request.POST.get("provider", config.provider)
config.is_enabled = request.POST.get("is_enabled") == "on"
config.api_url = request.POST.get("api_url", "").strip()
config.webservice_url = request.POST.get("webservice_url", "").strip()
config.panel_no = request.POST.get("panel_no", "").strip()
config.username = request.POST.get("username", "").strip()
config.sender = request.POST.get("sender", "").strip()
config.api_token = request.POST.get("api_token", "").strip()
try:
config.timeout_seconds = int(request.POST.get("timeout_seconds") or 30)
except (TypeError, ValueError):
config.timeout_seconds = 30
new_password = request.POST.get("password", "")
if new_password:
config.password = new_password
config.allow_backlog = request.POST.get("allow_backlog") == "on"
for field in ("backlog_max", "max_per_run", "send_interval_seconds"):
raw = request.POST.get(field)
if raw is not None and str(raw).strip() != "":
try:
setattr(config, field, max(0, int(raw)))
except (TypeError, ValueError):
pass
scan_since_raw = (request.POST.get("scan_since") or "").strip()
if scan_since_raw:
# اول ISO/گرگوری، بعد تاریخ شمسی (YYYY/MM/DD). نامعتبر = مقدار قبلی حفظ می‌شود.
parsed = parse_scan_since(scan_since_raw, None)
if parsed is None:
parsed = parse_jalali_date(scan_since_raw)
if parsed is not None:
config.scan_since = parsed
elif config.scan_since is None:
config.scan_since = timezone.now()
config.save()
return _notice(reverse("admin_panel:sms_settings"), "settings_saved")
data = {
"config": config,
"provider_choices": SMSProviderConfig.ProviderChoices,
"scan_since_jalali": format_jalali(config.scan_since),
}
return render(request, "admin_panel/sms_settings.html", data)
def resolve_recipients_test(phone):
from integrations.sms.normalize import normalize_mobile
normalized = normalize_mobile(phone)
return [normalized] if normalized else []
def parse_scan_since(raw, fallback):
"""تجزیهٔ «خط شروع» با قالب‌های رایج؛ در صورت نامعتبر بودن، مقدار قبلی حفظ می‌شود."""
from django.utils.dateparse import parse_datetime
value = parse_datetime(raw)
if value is None:
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
try:
value = datetime.strptime(raw, fmt)
break
except (TypeError, ValueError):
continue
if value is None:
return fallback
if timezone.is_naive(value):
value = timezone.make_aware(value, timezone.get_current_timezone())
return value
def parse_jalali_date(raw):
"""«YYYY/MM/DD» شمسی → datetime آگاه در آغاز همان روز (تایم‌زون پروژه).
جداکنندهٔ اسلش مخصوص شمسی است تا با قالب گرگوری `YYYY-MM-DD` اشتباه نشود.
خروجی «آغاز روز» است چون «تاریخ شروع دوره» در سطح روز معنا دارد.
"""
from persiantools.jdatetime import JalaliDate
match = re.fullmatch(r"(\d{4})/(\d{1,2})/(\d{1,2})", (raw or "").strip())
if not match:
return None
try:
gregorian = JalaliDate(*(int(part) for part in match.groups())).to_gregorian()
except (TypeError, ValueError):
return None
naive = datetime.combine(gregorian, time.min)
return timezone.make_aware(naive, timezone.get_current_timezone())
def _sample_context():
"""نمونهٔ داده برای پیش‌نمایش: یک نسخه با مقادیر واقعی (رندر سمت سرور) و یک نسخهٔ رشته‌ای."""
today = timezone.localdate()
raw = dict(SAMPLE_CONTEXT, date=today)
as_text = {
key: (format_jalali(today) if key == "date" else str(value))
for key, value in raw.items()
}
return raw, as_text
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_templates(request):
raw_sample, text_sample = _sample_context()
rows = []
for template in SMSTemplate.objects.all().order_by("sms_type"):
ui = SMS_TYPE_UI.get(template.sms_type, {})
info = timing_for(template.sms_type)
rows.append({
"template": template,
"code": template.sms_type,
"label": template.get_sms_type_display(),
"icon": ui.get("icon", "✉️"),
"when": ui.get("when", ""),
"timing": info,
"timing_value": current_timing_value(template),
"timing_label": info.get("label") or "",
# پیش‌نمایش اولیه سمت سرور (برای زمانی که جاوااسکریپت اجرا نشود).
"preview": render_template(template.text, raw_sample),
})
data = {
"rows": rows,
"variables": TEMPLATE_VARIABLES,
# پیکربندی جاوااسکریپت صفحه (نمونهٔ مقادیر و فهرست متغیرهای مجاز).
# به‌صورت dict پاس داده می‌شود تا خود ``json_script`` آن را سریال‌سازی کند.
"smsx_config": {
"sample": text_sample,
"tokens": [token for token, _desc in TEMPLATE_VARIABLES],
},
}
return render(request, "admin_panel/sms_templates.html", data)
def _templates_redirect(anchor="", notice="", **extra):
"""بازگشت به صفحهٔ قالب‌ها با باز شدن کارتِ همان نوع پیامک.
کد پیام نتیجه پیش از ``#`` (fragment) در querystring قرار می‌گیرد.
"""
url = reverse("admin_panel:sms_templates")
if notice:
url = f"{url}?{urlencode({'notice': notice, **extra})}"
return redirect(f"{url}#type-{anchor}" if anchor else url)
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_template_edit(request, t_id):
"""ویرایش یک قالب. فیلد زمانی بر اساس نوع پیامک انتخاب و اعتبارسنجی می‌شود.
فرم فقط فیلد زمانیِ مربوط به همان نوع را می‌فرستد؛ ``apply_timing`` تضمین می‌کند
بقیهٔ فیلدهای زمانی صفر/خالی بمانند تا داده هرگز ناهمخوان نشود.
"""
template = get_object_or_404(SMSTemplate, id=t_id)
if request.method == "POST":
template.text = request.POST.get("text", template.text)
template.title = request.POST.get("title", template.title).strip() or template.title
template.is_enabled = request.POST.get("is_enabled") == "on"
template.auto_send = request.POST.get("auto_send") == "on"
info = timing_for(template.sms_type)
value = None
if info["field"] is not None:
raw = (request.POST.get("timing_value") or "").strip()
if raw:
try:
value = max(0, int(raw))
except (TypeError, ValueError):
return _templates_redirect(
template.sms_type, "tpl_bad_timing",
)
apply_timing(template, value)
template.save()
return _templates_redirect(
template.sms_type, "tpl_saved", title=template.title,
)
return _templates_redirect(template.sms_type)
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_customer_toggle(request, c_id):
customer = get_object_or_404(Customer, id=c_id)
if request.method == "POST":
sms_type = request.POST.get("sms_type", "")
valid = {code for code, _ in SMSType.choices}
if sms_type in valid:
enabled = request.POST.get("is_enabled") == "true"
setting, _created = customer.sms_settings.get_or_create(
sms_type=sms_type, defaults={"is_enabled": enabled},
)
setting.is_enabled = enabled
setting.save()
return _notice(_customer_url(c_id), "customer_sms_saved")
return redirect("admin_panel:customer", c_id=c_id)
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_primary_toggle(request, c_id):
"""تعیین/حذف یک شماره به‌عنوان شمارهٔ «اصلی» (گیرنده پیامک).
انتخاب بر اساس **خودِ شماره** (نرمال‌شده) ذخیره می‌شود، نه کلید رکورد؛ چون رکوردهای
شماره در ویرایش مشتری حذف/بازسازی می‌شوند و کلیدشان پایدار نیست. شماره‌های ورودی
اعتبارسنجی می‌شوند تا مقدار بی‌معنی به‌عنوان گیرنده ذخیره نشود.
"""
customer = get_object_or_404(Customer, id=c_id)
if request.method == "POST":
wanted = (request.POST.get("phone") or "").strip()
enabled = request.POST.get("is_primary") == "true"
normalized = next(
(e.normalized for e in customer_phone_entries(customer)
if e.raw == wanted or e.display == wanted or e.normalized == wanted),
"",
)
if not normalized:
return _notice(_customer_url(c_id), "primary_not_found")
current = [n for n in stored_primary_normalized(customer) if n != normalized]
if enabled:
current.append(normalized)
save_primary_normalized(customer, current)
return _notice(_customer_url(c_id), "primary_updated")
return redirect("admin_panel:customer", c_id=c_id)
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_send_custom(request, c_id):
"""ارسال پیامک با متن سفارشی برای همین مشتری.
اگر کادر متن خالی بماند، متن قالب «پیام سفارشی» استفاده می‌شود. متغیرهایی مثل
``{name}`` در متن واردشده هم رندر می‌شوند.
"""
customer = get_object_or_404(Customer, id=c_id)
if request.method == "POST":
text = (request.POST.get("text", "") or "").strip()
if not text:
template = SMSTemplate.objects.filter(
sms_type=SMSType.CUSTOM, is_enabled=True,
).first()
text = (template.text if template else "").strip()
rendered = render_template(text, {"name": customer.name_family}).strip()
if not rendered:
return _notice(_customer_url(c_id), "custom_empty")
created = enqueue_sms(
customer=customer, sms_type=SMSType.CUSTOM, message=rendered,
created_by=request.user,
approved=True, # متن را خودِ مدیر نوشته و ارسال را خواسته است.
)
code = "custom_queued" if created else "custom_no_phone"
return _notice(_customer_url(c_id), code)
return redirect("admin_panel:customer", c_id=c_id)
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_queue(request):
status = request.GET.get("status", "")
delivery = request.GET.get("delivery", "")
qs = SMSQueue.objects.all().select_related("customer").order_by("-created_at")
if status:
qs = qs.filter(status=status)
if delivery:
qs = qs.filter(delivery_status=delivery)
paginator = Paginator(qs, 50)
page = paginator.get_page(request.GET.get("page"))
# آمار روی کل صف (بدون اعمال فیلترها) محاسبه می‌شود تا تصویر کلی همیشه دیده شود.
# order_by() لازم است وگرنه ترتیب پیش‌فرض مدل (created_at-) به GROUP BY اضافه
# می‌شود و هر ردیف گروه جدا می‌شود.
counts = dict(
SMSQueue.objects.order_by().values_list("status").annotate(n=Count("id"))
)
data = {
"queue": page,
"status": status,
"delivery": delivery,
"status_choices": SMSQueue.Status.choices,
"delivery_choices": SMSQueue.DeliveryStatus.choices,
"pending_count": counts.get(SMSQueue.Status.PENDING, 0),
"total_count": sum(counts.values()),
"filtered_count": paginator.count,
# (کد وضعیت، برچسب، تعداد) — به ترتیب تعریف Status.
"status_stats": [
(code, label, counts.get(code, 0))
for code, label in SMSQueue.Status.choices
],
}
return render(request, "admin_panel/sms_queue.html", data)
def _approve(queryset):
"""پیامک‌های «در انتظار تأیید» را تأیید و آمادهٔ ارسال می‌کند."""
return queryset.filter(status=SMSQueue.Status.PENDING).update(
status=SMSQueue.Status.APPROVED, next_retry_at=None, updated_at=timezone.now(),
)
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_queue_approve(request, q_id):
"""تأیید یک پیامک برای ارسال توسط worker."""
get_object_or_404(SMSQueue, id=q_id)
if request.method == "POST":
code = "approved" if _approve(SMSQueue.objects.filter(id=q_id)) else "not_pending"
return _notice(reverse("admin_panel:sms_queue"), code)
return redirect("admin_panel:sms_queue")
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_queue_delete(request, q_id):
"""حذف یک پیامک از صف (مثلاً پس از بازبینی و رد آن)."""
item = get_object_or_404(SMSQueue, id=q_id)
if request.method == "POST":
item.delete()
return _notice(reverse("admin_panel:sms_queue"), "deleted")
return redirect("admin_panel:sms_queue")
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_queue_bulk(request):
"""اقدام گروهی روی صف: تأیید/حذف موارد انتخاب‌شده یا تأیید کل موارد در انتظار."""
if request.method == "POST":
action = request.POST.get("action", "")
if action == "approve_all":
count = _approve(SMSQueue.objects.all())
code, extra = "bulk_approved_all", {"count": count}
else:
ids = request.POST.getlist("ids")
selected = SMSQueue.objects.filter(id__in=ids)
if action == "approve_selected":
count = _approve(selected)
code, extra = "bulk_approved", {"count": count}
elif action == "delete_selected":
count = selected.count()
selected.delete()
code, extra = "bulk_deleted", {"count": count}
else:
code, extra = "invalid_action", {}
return _notice(reverse("admin_panel:sms_queue"), code, **extra)
return redirect("admin_panel:sms_queue")
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_queue_empty(request):
"""خالی کردن کل صف — هر وضعیتی، بدون بازگشت.
پیش از حذف، کلید یکتاسازیِ هر رکورد در دفتر ``SMSDedup`` تضمین می‌شود تا اسکنر
در اجرای بعدی همان پیامک‌ها را از نو نسازد. رکوردهایی که کلید ندارند (مثلاً
پیامک دستی) کلید مشتق از خودشان را می‌گیرند.
"""
if request.method == "POST":
rows = SMSQueue.objects.all()
keys = [k for k in rows.values_list("dedup_key", flat=True) if k]
SMSDedup.objects.bulk_create(
[SMSDedup(key=k) for k in keys], ignore_conflicts=True,
)
count = rows.count()
rows.delete()
return _notice(reverse("admin_panel:sms_queue"), "queue_emptied", count=count)
return redirect("admin_panel:sms_queue")
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_queue_retry(request, q_id):
item = get_object_or_404(SMSQueue, id=q_id)
if request.method == "POST":
item.status = SMSQueue.Status.APPROVED
item.next_retry_at = timezone.now()
item.last_error = ""
# صفر کردن تلاش‌ها تا پیامکِ failed دوباره فرصت ارسال بگیرد.
item.attempts = 0
item.save(update_fields=["status", "next_retry_at", "last_error", "attempts", "updated_at"])
return _notice(reverse("admin_panel:sms_queue"), "retried")
return redirect("admin_panel:sms_queue")