"""ویوهای مدیریت پیامک در پنل (نیازمند پرمیشن ``admin_panel.ManageSMS``).""" import re from datetime import datetime, time, timedelta from urllib.parse import urlencode from django.contrib.auth.decorators import login_required, permission_required from django.core.paginator import Paginator from django.db.models import Count, Q 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.parts import ENCODING_LABELS, describe, parts_count from ..sms.phones import (customer_phone_entries, save_primary_normalized, stored_primary_normalized) from ..sms.render import format_jalali, render_template from ..sms.timing import apply_timing, current_timing_value, timing_for _manage_required = permission_required("admin_panel.ManageSMS", raise_exception=True) # صفحات «تنظیمات سامانه» و «قالب‌ها» یک سطح دسترسی جداگانه دارند تا مدیر بتواند # فقط به کاربران موردنظرش اجازهٔ دیدن/ویرایش بدهد (نه هر کاربر دارای ManageSMS). _settings_required = permission_required( "admin_panel.ManageSMSSettings", raise_exception=True, ) # گزارش هزینه فقط روی پیامک‌هایی است که از مرحلهٔ «تأیید مدیر» گذشته و واقعاً به # سامانه/اپراتور سپرده شده‌اند. موارد «در انتظار تأیید» (هنوز ارسال نشده) و # «لغو‌شده» (هرگز ارسال نمی‌شود) نباید در تحلیل هزینه بیایند. SENT_STATUSES = [ SMSQueue.Status.APPROVED, SMSQueue.Status.PROCESSING, SMSQueue.Status.SENT, SMSQueue.Status.FAILED, ] # وضعیت‌های «پایانی» که دیگر کاری روی آن‌ها لازم نیست. صفحهٔ «صف ارسال» فقط مواردِ # نیازمند اقدام را نشان می‌دهد؛ پیامکِ ارسال‌شده از صف بیرون می‌رود و سابقه‌اش در # صفحهٔ «گزارش ارسال» (/panel/sms/report) دیده می‌شود. این کار جلوی حذفِ ناخواستهٔ # تاریخچه از روی صف را می‌گیرد و صف را به فهرستِ کاریِ تمیز تبدیل می‌کند. DONE_STATUSES = [SMSQueue.Status.SENT] def _notice(url, code, **extra): """بازگشت به ``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.SURVEY: {"icon": "⭐", "when": "بی‌درنگ پس از ثبت فاکتور"}, SMSType.CHEQUE_REGISTRATION: {"icon": "🧾", "when": "چند روز پس از سررسید چک"}, SMSType.VISIT: {"icon": "🔧", "when": "پس از گذشت دورهٔ بازدید دستگاه"}, SMSType.VISIT_CREATED: {"icon": "📅", "when": "پس از ثبت اقدام بازدید"}, SMSType.PAYMENT_THANKS: {"icon": "🙏", "when": "پس از ثبت پرداخت"}, SMSType.NESIYE_DUE: {"icon": "💳", "when": "پیش از سررسید نسیه"}, } # نمونهٔ داده برای «پیش‌نمایش زندهٔ» متن در پنل (هیچ پیامکی با این مقادیر ارسال نمی‌شود). 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/') @_settings_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="", 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/') @_settings_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/') @_settings_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() 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="", 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): """صف ارسال: فقط مواردی که هنوز «کارِ باقی‌مانده» دارند. پیامک‌های ارسال‌شده (``sent``) از این فهرست بیرون می‌مانند چون کارشان تمام است و سابقه‌شان در «گزارش ارسال» دیده می‌شود؛ ماندن‌شان در صف فقط باعث می‌شد دکمهٔ «حذف» روی تاریخچه ظاهر شود. بقیهٔ وضعیت‌ها (در انتظار تأیید، تأییدشده، در حال پردازش، ناموفق) در صف می‌مانند چون هنوز نیاز به اقدام دارند. """ status = request.GET.get("status", "") delivery = request.GET.get("delivery", "") qs = ( SMSQueue.objects.exclude(status__in=DONE_STATUSES) .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")) ) # «کل صف» = موارد باقی‌مانده (بدون ارسال‌شده‌ها). queue_total = sum(n for code, n in counts.items() if code not in DONE_STATUSES) data = { "queue": page, "status": status, "delivery": delivery, # گزینه‌های فیلتر وضعیت: فقط وضعیت‌های درون صف (ارسال‌شده حذف می‌شود). "status_choices": [ (code, label) for code, label in SMSQueue.Status.choices if code not in DONE_STATUSES ], "delivery_choices": SMSQueue.DeliveryStatus.choices, "pending_count": counts.get(SMSQueue.Status.PENDING, 0), "total_count": queue_total, # تعداد ارسال‌شده‌ها جداگانه نمایش داده می‌شود (برای رفتن به گزارش). "sent_count": counts.get(SMSQueue.Status.SENT, 0), "filtered_count": paginator.count, # (کد وضعیت، برچسب، تعداد) — فقط وضعیت‌های درون صف. "status_stats": [ (code, label, counts.get(code, 0)) for code, label in SMSQueue.Status.choices if code not in DONE_STATUSES ], } return render(request, "admin_panel/sms_queue.html", data) @login_required(login_url='/panel/p_login/') @_manage_required def customer_sms_history(request, c_id): """سوابق پیامک‌های یک مشتری روی صفحهٔ خود مشتری. همهٔ پیامک‌هایی که برای شماره‌های این مشتری در صف قرار گرفته/ارسال شده‌اند (هر وضعیتی) با متن دقیق، تاریخ شمسی و وضعیت تحویل (دلیوری) نمایش داده می‌شوند و فهرست صفحه‌بندی می‌شود. با ``?status=`` و ``?delivery=`` می‌توان فیلتر کرد. """ customer = get_object_or_404(Customer, id=c_id) status = request.GET.get("status", "") delivery = request.GET.get("delivery", "") qs = ( SMSQueue.objects.filter(customer=customer).order_by("-created_at") ) if status: qs = qs.filter(status=status) if delivery: qs = qs.filter(delivery_status=delivery) paginator = Paginator(qs, 20) page = paginator.get_page(request.GET.get("page")) data = { "customer": customer, "items": page, "status": status, "delivery": delivery, "status_choices": SMSQueue.Status.choices, "delivery_choices": SMSQueue.DeliveryStatus.choices, "total_count": SMSQueue.objects.filter(customer=customer).count(), "filtered_count": paginator.count, } return render(request, "admin_panel/customerSmsHistory.html", data) @login_required(login_url='/panel/p_login/') @_manage_required def sms_report(request): """گزارش پیامک‌های ارسال‌شده برای تحلیل هزینه. امکان فیلتر بر اساس **بازهٔ تاریخ شمسی** (از/تا)، وضعیت ارسال، وضعیت تحویل و نوع پیامک، به‌همراه جستجوی متنی روی متن پیامک/شماره/نام مشتری. برای هر ردیف، «تعداد پیامکی که اپراتور محاسبه کرده» (parts) بر اساس کدگذاری واقعی متن (GSM-7 برای انگلیسی/عدد، UCS-2 برای فارسی/یونیکد) محاسبه و نمایش داده می‌شود؛ جمع کل بخش‌ها هم برای برآورد هزینه گزارش می‌شود. """ date_from_raw = (request.GET.get("date_from") or "").strip() date_to_raw = (request.GET.get("date_to") or "").strip() status = request.GET.get("status", "") delivery = request.GET.get("delivery", "") sms_type = request.GET.get("sms_type", "") query = (request.GET.get("q") or "").strip() qs = ( SMSQueue.objects.select_related("customer") .filter(status__in=SENT_STATUSES) .order_by("-created_at") ) # بازهٔ تاریخ شمسی روی «زمان ایجاد» (ثبت در سیستم) اعمال می‌شود. بازهٔ «تا» تا # پایان همان روز در نظر گرفته می‌شود تا پیامک‌های همان روز هم بیایند. parsed_from = parse_jalali_date(date_from_raw) if date_from_raw else None parsed_to = parse_jalali_date(date_to_raw) if date_to_raw else None if parsed_from is not None: qs = qs.filter(created_at__gte=parsed_from) if parsed_to is not None: qs = qs.filter(created_at__lt=parsed_to + timedelta(days=1)) if status: qs = qs.filter(status=status) if delivery: qs = qs.filter(delivery_status=delivery) if sms_type: qs = qs.filter(sms_type=sms_type) if query: qs = qs.filter( Q(message__icontains=query) | Q(phone_number__icontains=query) | Q(customer__name_family__icontains=query) ) # --- آمار کلی روی کل نتیجهٔ فیلترشده (نه فقط صفحهٔ جاری) --- status_counts = dict( qs.order_by().values_list("status").annotate(n=Count("id")) ) total_records = sum(status_counts.values()) # جمع بخش‌ها (parts) باید در پایتون حساب شود چون به کدگذاری هر متن بستگی دارد. total_parts = 0 gsm7_parts = ucs2_parts = 0 for message in qs.values_list("message", flat=True).iterator(chunk_size=500): info = describe(message) total_parts += info["parts"] if info["encoding"] == "gsm7": gsm7_parts += info["parts"] else: ucs2_parts += info["parts"] paginator = Paginator(qs, 50) page = paginator.get_page(request.GET.get("page")) # برای هر ردیف صفحه، محاسبهٔ کدگذاری/طول/بخش (نمایشی). rows = [] for item in page: info = describe(item.message) rows.append({"item": item, "parts": info["parts"], "encoding": info["encoding_label"], "length": info["length"]}) # برای لینک صفحه‌بندی که همهٔ فیلترها را حفظ کند (بدون خودِ page). params = request.GET.copy() params.pop("page", None) base_query = params.urlencode() data = { "rows": rows, "page": page, "status": status, "delivery": delivery, "sms_type": sms_type, "query": query, "date_from": date_from_raw, "date_to": date_to_raw, "status_choices": [ (code, label) for code, label in SMSQueue.Status.choices if code in SENT_STATUSES ], "delivery_choices": SMSQueue.DeliveryStatus.choices, "type_choices": SMSType.choices, "total_records": total_records, "total_parts": total_parts, "gsm7_parts": gsm7_parts, "ucs2_parts": ucs2_parts, "encoding_labels": ENCODING_LABELS, "status_stats": [ (code, label, status_counts.get(code, 0)) for code, label in SMSQueue.Status.choices if code in SENT_STATUSES ], "filtered": paginator.count, "base_query": base_query, "has_filter": any([date_from_raw, date_to_raw, status, delivery, sms_type, query]), } return render(request, "admin_panel/sms_report.html", data) def _approve(queryset): """پیامک‌های «در انتظار تأیید» را تأیید و آمادهٔ ارسال می‌کند.""" return queryset.filter(status=SMSQueue.Status.PENDING).update( 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": if item.status in DONE_STATUSES: return _notice(reverse("admin_panel:sms_queue"), "delete_done_blocked") item.delete() return _notice(reverse("admin_panel:sms_queue"), "deleted") return redirect("admin_panel:sms_queue") @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": # تاریخچهٔ ارسال‌شده‌ها از طریق صف حذف نمی‌شود. selected = selected.exclude(status__in=DONE_STATUSES) 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.exclude(status__in=DONE_STATUSES) keys = [k for k in rows.values_list("dedup_key", flat=True) if k] SMSDedup.objects.bulk_create( [SMSDedup(key=k) for k in keys], ignore_conflicts=True, ) 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")