This commit is contained in:
javad committed 2026-10-11 19:04:20 +03:30
1 parent 1dea3509a7
commit e3a6d1f9d4
42 files changed
+33086 -15779

No files matched your search

+217 -30
View File
@@ -1,11 +1,11 @@
"""ویوهای مدیریت پیامک در پنل (نیازمند پرمیشن ``admin_panel.ManageSMS``)."""
import re
from datetime import datetime, time
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
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
@@ -13,12 +13,34 @@ 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.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]
def _notice(url, code, **extra):
@@ -47,14 +69,13 @@ TEMPLATE_VARIABLES = [
# فقط برای نمایش: آیکن و توضیح کوتاه «چه زمانی ارسال می‌شود» هر نوع (لایهٔ UI، نه دامنه).
SMS_TYPE_UI = {
SMSType.WELCOME: {"icon": "👋", "when": "بیدرنگ پس از ثبت مشتری"},
SMSType.CHEQUE_DUE: {"icon": "🧾", "when": "پیش از سررسید چک"},
SMSType.WELCOME: {"icon": "👋", "when": "بی‌درنگ پس از ثبت مشتری"},
SMSType.SURVEY: {"icon": "⭐", "when": "بی‌درنگ پس از ثبت فاکتور"},
SMSType.CHEQUE_REGISTRATION: {"icon": "🧾", "when": "چند روز پس از سررسید چک"},
SMSType.VISIT: {"icon": "🔧", "when": "پس از گذشت دورهٔ بازدید دستگاه"},
SMSType.VISIT_CREATED: {"icon": "📅", "when": "پس از ثبت اقدام بازدید"},
SMSType.PAYMENT_THANKS: {"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": "ارسال دستی از صفحهٔ مشتری"},
}
# نمونهٔ داده برای «پیش‌نمایش زندهٔ» متن در پنل (هیچ پیامکی با این مقادیر ارسال نمی‌شود).
@@ -70,7 +91,7 @@ SAMPLE_CONTEXT = {
@login_required(login_url='/panel/p_login/')
@_manage_required
@_settings_required
def sms_settings(request):
config = SMSProviderConfig.load()
if request.method == "POST":
@@ -82,7 +103,7 @@ def sms_settings(request):
config.test_message = test_text
config.save(update_fields=["test_message"])
enqueue_sms(
customer=None, sms_type=SMSType.CUSTOM,
customer=None, sms_type="",
message=test_text or config.test_message,
recipients=resolve_recipients_test(phone), created_by=request.user,
approved=True, # ارسال دستیِ مدیر؛ نیاز به بازبینی صف ندارد.
@@ -192,7 +213,7 @@ def _sample_context():
@login_required(login_url='/panel/p_login/')
@_manage_required
@_settings_required
def sms_templates(request):
raw_sample, text_sample = _sample_context()
rows = []
@@ -236,7 +257,7 @@ def _templates_redirect(anchor="", notice="", **extra):
@login_required(login_url='/panel/p_login/')
@_manage_required
@_settings_required
def sms_template_edit(request, t_id):
"""ویرایش یک قالب. فیلد زمانی بر اساس نوع پیامک انتخاب و اعتبارسنجی می‌شود.
@@ -321,22 +342,17 @@ def sms_primary_toggle(request, c_id):
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,
customer=customer, sms_type="", message=rendered,
created_by=request.user,
approved=True, # متن را خودِ مدیر نوشته و ارسال را خواسته است.
)
@@ -348,9 +364,19 @@ def sms_send_custom(request, 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.all().select_related("customer").order_by("-created_at")
qs = (
SMSQueue.objects.exclude(status__in=DONE_STATUSES)
.select_related("customer").order_by("-created_at")
)
if status:
qs = qs.filter(status=status)
if delivery:
@@ -364,24 +390,177 @@ def sms_queue(request):
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": SMSQueue.Status.choices,
# گزینه‌های فیلتر وضعیت: فقط وضعیت‌های درون صف (ارسال‌شده حذف می‌شود).
"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": sum(counts.values()),
"total_count": queue_total,
# تعداد ارسال‌شده‌ها جداگانه نمایش داده می‌شود (برای رفتن به گزارش).
"sent_count": counts.get(SMSQueue.Status.SENT, 0),
"filtered_count": paginator.count,
# (کد وضعیت، برچسب، تعداد) — به ترتیب تعریف Status.
# (کد وضعیت، برچسب، تعداد) — فقط وضعیت‌های درون صف.
"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):
"""سوابق پیامک‌های یک مشتری روی صفحهٔ خود مشتری.
همهٔ پیامک‌هایی که برای شماره‌های این مشتری در صف قرار گرفته/ارسال شده‌اند
(هر وضعیتی) با متن دقیق، تاریخ شمسی و وضعیت تحویل (دلیوری) نمایش داده می‌شوند و
فهرست صفحه‌بندی می‌شود. با ``?status=`` و ``?delivery=`` می‌توان فیلتر کرد.
"""
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"))
data = {
"customer": customer,
"items": page,
"status": status,
"delivery": delivery,
"status_choices": SMSQueue.Status.choices,
"delivery_choices": SMSQueue.DeliveryStatus.choices,
"total_count": SMSQueue.objects.filter(customer=customer).count(),
"filtered_count": paginator.count,
}
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(
@@ -403,9 +582,15 @@ def sms_queue_approve(request, q_id):
@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")
item.delete()
return _notice(reverse("admin_panel:sms_queue"), "deleted")
return redirect("admin_panel:sms_queue")
@@ -427,6 +612,8 @@ def sms_queue_bulk(request):
count = _approve(selected)
code, extra = "bulk_approved", {"count": count}
elif action == "delete_selected":
# تاریخچهٔ ارسال‌شده‌ها از طریق صف حذف نمی‌شود.
selected = selected.exclude(status__in=DONE_STATUSES)
count = selected.count()
selected.delete()
code, extra = "bulk_deleted", {"count": count}
@@ -439,14 +626,14 @@ def sms_queue_bulk(request):
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_queue_empty(request):
"""خالی کردن کل صف — هر وضعیتی، بدون بازگشت.
"""خالی کردن صف — فقط موارد باقی‌مانده (بدون بازگشت).
پیش از حذف، کلید یکتاسازیِ هر رکورد در دفتر ``SMSDedup`` تضمین می‌شود تا اسکنر
در اجرای بعدی همان پیامک‌ها را از نو نسازد. رکوردهایی که کلید ندارند (مثلاً
پیامک دستی) کلید مشتق از خودشان را می‌گیرند.
پیامک‌های ارسال‌شده جزو صف نیستند و حذف نمی‌شوند؛ تاریخچه‌شان در «گزارش ارسال»
لازم است. پیش از حذف، کلید یکتاسازیِ هر رکورد در دفتر ``SMSDedup`` تضمین می‌شود
تا اسکنر در اجرای بعدی همان پیامک‌ها را از نو نسازد.
"""
if request.method == "POST":
rows = SMSQueue.objects.all()
rows = SMSQueue.objects.exclude(status__in=DONE_STATUSES)
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,