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