473 lines
21 KiB
Python
473 lines
21 KiB
Python
"""ویوهای مدیریت پیامک در پنل (نیازمند پرمیشن ``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")
|