This commit is contained in:
javad committed 2026-10-10 14:21:04 +03:30
1 parent 250cdbab7f
commit 5c6069c3ef
87 files changed
+29209 -3697

No files matched your search

+1
View File
@@ -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
+18
View File
@@ -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:
+80 -46
View File
@@ -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))
+472
View File
@@ -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")