sms
This commit is contained in:
1 parent
250cdbab7f
commit
5c6069c3ef
87 files changed
+29209
-3697
No files matched your search
@@ -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")
|
||||
Reference in new issue
Block a user