Files
khatibi/admin_panel/views/sms.py
T
2026-10-27 11:49:09 +03:30

749 lines
35 KiB
Python

"""ویوهای مدیریت پیامک در پنل (نیازمند پرمیشن ``admin_panel.ManageSMS``)."""
import re
from datetime import datetime, time, timedelta
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, Q
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.parts import ENCODING_LABELS, describe, parts_count
from ..sms.phones import (customer_phone_entries, save_primary_normalized,
stored_primary_normalized)
from ..sms.render import format_jalali, render_template
from ..sms.rules import customer_toggle_map
from ..sms.timing import apply_timing, current_timing_value, timing_for
_manage_required = permission_required("admin_panel.ManageSMS", raise_exception=True)
# صفحات «تنظیمات سامانه» و «قالب‌ها» یک سطح دسترسی جداگانه دارند تا مدیر بتواند
# فقط به کاربران موردنظرش اجازهٔ دیدن/ویرایش بدهد (نه هر کاربر دارای ManageSMS).
_settings_required = permission_required(
"admin_panel.ManageSMSSettings", raise_exception=True,
)
# گزارش هزینه فقط روی پیامک‌هایی است که از مرحلهٔ «تأیید مدیر» گذشته و واقعاً به
# سامانه/اپراتور سپرده شده‌اند. موارد «در انتظار تأیید» (هنوز ارسال نشده) و
# «لغو‌شده» (هرگز ارسال نمی‌شود) نباید در تحلیل هزینه بیایند.
SENT_STATUSES = [
SMSQueue.Status.APPROVED,
SMSQueue.Status.PROCESSING,
SMSQueue.Status.SENT,
SMSQueue.Status.FAILED,
]
# وضعیت‌های «پایانی» که دیگر کاری روی آن‌ها لازم نیست. صفحهٔ «صف ارسال» فقط مواردِ
# نیازمند اقدام را نشان می‌دهد؛ پیامکِ ارسال‌شده از صف بیرون می‌رود و سابقه‌اش در
# صفحهٔ «گزارش ارسال» (/panel/sms/report) دیده می‌شود. این کار جلوی حذفِ ناخواستهٔ
# تاریخچه از روی صف را می‌گیرد و صف را به فهرستِ کاریِ تمیز تبدیل می‌کند.
# پیامک «لغوشده» هم دیگر کاری برای انجام ندارد (مثلاً چون اقدامِ مرتبطش حذف شده)،
# پس از صف بیرون می‌رود.
DONE_STATUSES = [SMSQueue.Status.SENT, SMSQueue.Status.CANCELLED]
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})
def _customer_sms_url(c_id):
"""صفحهٔ «سوابق و تنظیمات پیامک» مشتری — مقصد بازگشتِ عملیات پیامکی.
تنظیمات پیامک از صفحهٔ فاکتور به این صفحه منتقل شده؛ پس بعد از هر تغییر
(تیک نوع پیامک، شمارهٔ اصلی، ارسال سفارشی) کاربر باید همین‌جا بماند، نه به
صفحهٔ اصلی مشتری پرتاب شود.
"""
return reverse("admin_panel:customerSmsHistory", kwargs={"c_id": c_id})
# متغیرهای قابل استفاده در متن قالب‌ها (برای راهنمای پنل).
TEMPLATE_VARIABLES = [
("{name}", "نام مشتری"),
("{amount}", "مبلغ"),
("{amount_sep}", "مبلغ با جداکنندهٔ هزارگان"),
("{date}", "تاریخ جلالی"),
("{days}", "تعداد روز"),
("{check_row}", "شمارهٔ ردیف چک"),
("{sayyad_id}", "شناسهٔ چک صیاد (۱۶ رقم)"),
("{owner_name}", "نام مالک چک"),
("{balance}", "مانده حساب مشتری (بدهکار − بستانکار)"),
("{balance_sep}", "مانده حساب با جداکنندهٔ هزارگان"),
]
# فقط برای نمایش: آیکن و توضیح کوتاه «چه زمانی ارسال می‌شود» هر نوع (لایهٔ UI، نه دامنه).
SMS_TYPE_UI = {
SMSType.WELCOME: {"icon": "👋", "when": "بی‌درنگ پس از ثبت مشتری"},
SMSType.SURVEY: {"icon": "⭐", "when": "بی‌درنگ پس از ثبت فاکتور"},
SMSType.CHEQUE_REGISTRATION: {"icon": "🧾", "when": "چند روز پیش از سررسید چک"},
SMSType.VISIT: {"icon": "🔧", "when": "پس از گذشت دورهٔ بازدید دستگاه"},
SMSType.VISIT_CREATED: {"icon": "📅", "when": "پس از ثبت اقدام بازدید"},
SMSType.INSTALL_CREATED: {"icon": "🛠️", "when": "پس از ثبت اقدام نصب"},
SMSType.PAYMENT_THANKS: {"icon": "🙏", "when": "پس از ثبت پرداخت"},
SMSType.NESIYE_DUE: {"icon": "💳", "when": "پیش از سررسید نسیه"},
}
# نمونهٔ داده برای «پیش‌نمایش زندهٔ» متن در پنل (هیچ پیامکی با این مقادیر ارسال نمی‌شود).
SAMPLE_CONTEXT = {
"name": "مشتری نمونه",
"amount": 250000,
"amount_sep": "250,000",
"date": None, # در زمان ساخت ویو با تاریخ امروز پر می‌شود
"days": 2,
"check_row": "1234",
"sayyad_id": "1234567890123456",
"owner_name": "نام مالک",
"balance": 3500000,
"balance_sep": "3,500,000",
}
@login_required(login_url='/panel/p_login/')
@_settings_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="",
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/')
@_settings_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/')
@_settings_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()
# ``open=settings`` ⇒ صفحه با همان مودال تنظیمات باز می‌شود تا کاربر
# چند نوع را پشت‌سرهم روشن/خاموش کند و هر بار مودال را باز نکند.
return _notice(
_customer_sms_url(c_id), "customer_sms_saved", open="settings",
)
return redirect("admin_panel:customerSmsHistory", 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_sms_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_sms_url(c_id), "primary_updated")
return redirect("admin_panel:customerSmsHistory", 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()
rendered = render_template(text, {"name": customer.name_family}).strip()
if not rendered:
# خطا ⇒ همان مودال ارسال سفارشی دوباره باز شود تا متن از دست نرود.
return _notice(_customer_sms_url(c_id), "custom_empty", open="custom")
created = enqueue_sms(
customer=customer, sms_type="", message=rendered,
created_by=request.user,
approved=True, # متن را خودِ مدیر نوشته و ارسال را خواسته است.
)
code = "custom_queued" if created else "custom_no_phone"
return _notice(_customer_sms_url(c_id), code)
return redirect("admin_panel:customerSmsHistory", c_id=c_id)
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_queue(request):
"""صف ارسال: فقط مواردی که هنوز «کارِ باقی‌مانده» دارند.
پیامک‌های ارسال‌شده (``sent``) از این فهرست بیرون می‌مانند چون کارشان تمام است و
سابقه‌شان در «گزارش ارسال» دیده می‌شود؛ ماندن‌شان در صف فقط باعث می‌شد دکمهٔ
«حذف» روی تاریخچه ظاهر شود. بقیهٔ وضعیت‌ها (در انتظار تأیید، تأییدشده، در حال
پردازش، ناموفق) در صف می‌مانند چون هنوز نیاز به اقدام دارند.
"""
status = request.GET.get("status", "")
delivery = request.GET.get("delivery", "")
qs = (
SMSQueue.objects.exclude(status__in=DONE_STATUSES)
.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"))
)
# «کل صف» = موارد باقی‌مانده (بدون ارسال‌شده‌ها).
queue_total = sum(n for code, n in counts.items() if code not in DONE_STATUSES)
data = {
"queue": page,
"status": status,
"delivery": delivery,
# گزینه‌های فیلتر وضعیت: فقط وضعیت‌های درون صف (ارسال‌شده حذف می‌شود).
"status_choices": [
(code, label) for code, label in SMSQueue.Status.choices
if code not in DONE_STATUSES
],
"delivery_choices": SMSQueue.DeliveryStatus.choices,
"pending_count": counts.get(SMSQueue.Status.PENDING, 0),
"total_count": queue_total,
# تعداد ارسال‌شده‌ها جداگانه نمایش داده می‌شود (برای رفتن به گزارش).
"sent_count": counts.get(SMSQueue.Status.SENT, 0),
"filtered_count": paginator.count,
# (کد وضعیت، برچسب، تعداد) — فقط وضعیت‌های درون صف.
"status_stats": [
(code, label, counts.get(code, 0))
for code, label in SMSQueue.Status.choices
if code not in DONE_STATUSES
],
}
return render(request, "admin_panel/sms_queue.html", data)
@login_required(login_url='/panel/p_login/')
@_manage_required
def customer_sms_history(request, c_id):
"""سوابق + تنظیمات پیامک یک مشتری (همه در یک صفحه).
ساختار صفحه عمداً دو لایه است تا **تنظیمات، سوابق را شلوغ نکند**:
* لایهٔ اول (همیشه دیده می‌شود): آمار کوتاه، فیلترها و فهرست سوابق.
* لایهٔ دوم (فقط با کلیک): تنظیمات پیامک، شماره‌های اصلی و ارسال سفارشی،
همه داخل مودال‌های Bulma.
تنظیماتی که قبلاً داخل باکس «پیامک» صفحهٔ ایجاد فاکتور بود
(``_customer_sms.html``) حالا اینجاست؛ آن باکس حذف شد تا فاکتور و سوابق هر
دو تمام‌عرض بمانند.
"""
customer = get_object_or_404(Customer, id=c_id)
status = request.GET.get("status", "")
delivery = request.GET.get("delivery", "")
qs = (
SMSQueue.objects.filter(customer=customer).order_by("-created_at")
)
if status:
qs = qs.filter(status=status)
if delivery:
qs = qs.filter(delivery_status=delivery)
paginator = Paginator(qs, 20)
page = paginator.get_page(request.GET.get("page"))
# --- آمار کارت‌های بالای صفحه (کل تاریخچهٔ همین مشتری، بدون فیلتر) ---
all_items = SMSQueue.objects.filter(customer=customer)
status_counts = dict(
all_items.order_by().values_list("status").annotate(n=Count("id"))
)
total_count = all_items.count()
# --- تنظیمات: نوع‌های پیامک با وضعیت مؤثر همین مشتری + «ارسال خودکار» قالب ---
# ``auto_send`` تنظیمی سراسری است (صفحهٔ قالب‌ها)، ولی دانستنش اینجا کمک
# می‌کند کاربر بداند پیامکِ این نوع بی‌درنگ می‌رود یا منتظر تأیید می‌ماند.
auto_send = dict(
SMSTemplate.objects.values_list("sms_type", "auto_send")
)
type_labels = dict(SMSType.choices)
sms_toggles = [
{
"code": item["code"],
"label": item["label"],
"enabled": item["enabled"],
"auto_send": bool(auto_send.get(item["code"])),
"icon": SMS_TYPE_UI.get(item["code"], {}).get("icon", "✉️"),
"when": SMS_TYPE_UI.get(item["code"], {}).get("when", ""),
}
for item in customer_toggle_map(customer)
]
enabled_count = sum(1 for item in sms_toggles if item["enabled"])
data = {
"customer": customer,
"items": page,
"status": status,
"delivery": delivery,
"status_choices": SMSQueue.Status.choices,
"delivery_choices": SMSQueue.DeliveryStatus.choices,
"total_count": total_count,
"filtered_count": paginator.count,
# تنظیمات (مودال)
"sms_toggles": sms_toggles,
"sms_enabled_count": enabled_count,
"sms_type_total": len(type_labels),
"phone_entries": customer_phone_entries(customer),
# آمار (نوار بالای صفحه)
"status_counts": status_counts,
"stat_sent": status_counts.get(SMSQueue.Status.SENT, 0),
# «در صف» = هر چیزی که هنوز ارسال نشده و منتظر اقدام/worker است.
"stat_pending": (
status_counts.get(SMSQueue.Status.PENDING, 0)
+ status_counts.get(SMSQueue.Status.APPROVED, 0)
+ status_counts.get(SMSQueue.Status.PROCESSING, 0)
),
"stat_failed": status_counts.get(SMSQueue.Status.FAILED, 0),
}
return render(request, "admin_panel/customerSmsHistory.html", data)
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_report(request):
"""گزارش پیامک‌های ارسال‌شده برای تحلیل هزینه.
امکان فیلتر بر اساس **بازهٔ تاریخ شمسی** (از/تا)، وضعیت ارسال، وضعیت تحویل و
نوع پیامک، به‌همراه جستجوی متنی روی متن پیامک/شماره/نام مشتری.
برای هر ردیف، «تعداد پیامکی که اپراتور محاسبه کرده» (parts) بر اساس کدگذاری
واقعی متن (GSM-7 برای انگلیسی/عدد، UCS-2 برای فارسی/یونیکد) محاسبه و نمایش
داده می‌شود؛ جمع کل بخش‌ها هم برای برآورد هزینه گزارش می‌شود.
"""
date_from_raw = (request.GET.get("date_from") or "").strip()
date_to_raw = (request.GET.get("date_to") or "").strip()
status = request.GET.get("status", "")
delivery = request.GET.get("delivery", "")
sms_type = request.GET.get("sms_type", "")
query = (request.GET.get("q") or "").strip()
qs = (
SMSQueue.objects.select_related("customer")
.filter(status__in=SENT_STATUSES)
.order_by("-created_at")
)
# بازهٔ تاریخ شمسی روی «زمان ایجاد» (ثبت در سیستم) اعمال می‌شود. بازهٔ «تا» تا
# پایان همان روز در نظر گرفته می‌شود تا پیامک‌های همان روز هم بیایند.
parsed_from = parse_jalali_date(date_from_raw) if date_from_raw else None
parsed_to = parse_jalali_date(date_to_raw) if date_to_raw else None
if parsed_from is not None:
qs = qs.filter(created_at__gte=parsed_from)
if parsed_to is not None:
qs = qs.filter(created_at__lt=parsed_to + timedelta(days=1))
if status:
qs = qs.filter(status=status)
if delivery:
qs = qs.filter(delivery_status=delivery)
if sms_type:
qs = qs.filter(sms_type=sms_type)
if query:
qs = qs.filter(
Q(message__icontains=query)
| Q(phone_number__icontains=query)
| Q(customer__name_family__icontains=query)
)
# --- آمار کلی روی کل نتیجهٔ فیلترشده (نه فقط صفحهٔ جاری) ---
status_counts = dict(
qs.order_by().values_list("status").annotate(n=Count("id"))
)
total_records = sum(status_counts.values())
# جمع بخش‌ها (parts) باید در پایتون حساب شود چون به کدگذاری هر متن بستگی دارد.
total_parts = 0
gsm7_parts = ucs2_parts = 0
for message in qs.values_list("message", flat=True).iterator(chunk_size=500):
info = describe(message)
total_parts += info["parts"]
if info["encoding"] == "gsm7":
gsm7_parts += info["parts"]
else:
ucs2_parts += info["parts"]
paginator = Paginator(qs, 50)
page = paginator.get_page(request.GET.get("page"))
# برای هر ردیف صفحه، محاسبهٔ کدگذاری/طول/بخش (نمایشی).
rows = []
for item in page:
info = describe(item.message)
rows.append({"item": item, "parts": info["parts"],
"encoding": info["encoding_label"], "length": info["length"]})
# برای لینک صفحه‌بندی که همهٔ فیلترها را حفظ کند (بدون خودِ page).
params = request.GET.copy()
params.pop("page", None)
base_query = params.urlencode()
data = {
"rows": rows,
"page": page,
"status": status,
"delivery": delivery,
"sms_type": sms_type,
"query": query,
"date_from": date_from_raw,
"date_to": date_to_raw,
"status_choices": [
(code, label) for code, label in SMSQueue.Status.choices
if code in SENT_STATUSES
],
"delivery_choices": SMSQueue.DeliveryStatus.choices,
"type_choices": SMSType.choices,
"total_records": total_records,
"total_parts": total_parts,
"gsm7_parts": gsm7_parts,
"ucs2_parts": ucs2_parts,
"encoding_labels": ENCODING_LABELS,
"status_stats": [
(code, label, status_counts.get(code, 0))
for code, label in SMSQueue.Status.choices
if code in SENT_STATUSES
],
"filtered": paginator.count,
"base_query": base_query,
"has_filter": any([date_from_raw, date_to_raw, status, delivery, sms_type, query]),
}
return render(request, "admin_panel/sms_report.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":
if item.status in DONE_STATUSES:
return _notice(reverse("admin_panel:sms_queue"), "delete_done_blocked")
# تصمیم محصولی: حذف توسط مدیر = «کنسل همین دوره». برای همین کلید یکتاسازی
# پیش از حذف در دفتر ثبت می‌شود؛ وگرنه اسکنر در اجرای بعدی همان پیامک را
# دوباره می‌ساخت.
_remember_keys([item])
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":
# تاریخچهٔ ارسال‌شده‌ها از طریق صف حذف نمی‌شود.
selected = selected.exclude(status__in=DONE_STATUSES)
count = selected.count()
_remember_keys(selected)
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")
def _remember_keys(rows):
"""کلیدهای یکتاسازیِ رکوردهای صف را پیش از حذف در دفتر ``SMSDedup`` ثبت می‌کند.
قرارداد: وقتی مدیر یادآوری را حذف می‌کند، یعنی «همین دوره کنسل شد»؛ وگرنه
اسکنر در اجرای بعدی همان پیامک را از نو می‌سازد و حذفِ مدیر بی‌اثر می‌شود.
"""
keys = [k for k in (row.dedup_key for row in rows) if k]
if keys:
SMSDedup.objects.bulk_create(
[SMSDedup(key=key) for key in keys], ignore_conflicts=True,
)
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_queue_empty(request):
"""خالی کردن صف — فقط موارد باقی‌مانده (بدون بازگشت).
پیامک‌های ارسال‌شده جزو صف نیستند و حذف نمی‌شوند؛ تاریخچه‌شان در «گزارش ارسال»
لازم است. پیش از حذف، کلید یکتاسازیِ هر رکورد در دفتر ``SMSDedup`` تضمین می‌شود
تا اسکنر در اجرای بعدی همان پیامک‌ها را از نو نسازد.
"""
if request.method == "POST":
rows = SMSQueue.objects.exclude(status__in=DONE_STATUSES)
_remember_keys(rows)
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")