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