sms
This commit is contained in:
1 parent
250cdbab7f
commit
5c6069c3ef
87 files changed
+29209
-3697
No files matched your search
@@ -0,0 +1 @@
|
||||
"""منطق دامنهٔ زیرسیستم پیامک (رندر قالب، گیرندگان، قواعد، صف و اسکنر)."""
|
||||
@@ -0,0 +1,74 @@
|
||||
"""قرار دادن پیامک در صف ارسال (insert-only، بیخطر برای مسیر request)."""
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from django.db import IntegrityError
|
||||
|
||||
from ..models import SMSDedup, SMSQueue, SMSTemplate, SMSType
|
||||
from .recipients import resolve_recipients
|
||||
from .render import render_template
|
||||
from .rules import is_sms_enabled
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def enqueue_sms(*, customer=None, sms_type, context=None, dedup_key=None,
|
||||
message=None, recipients=None, created_by=None, approved=False):
|
||||
"""پیامک را در صف قرار میدهد و تعداد رکوردهای ساختهشده را برمیگرداند.
|
||||
|
||||
بهصورت پیشفرض پیامک در وضعیت «در انتظار تأیید» ثبت میشود و تا وقتی مدیر
|
||||
آن را از پنل تأیید نکند، worker آن را ارسال نمیکند. دو راه برای پرش از این
|
||||
بازبینی هست: ``approved=True`` برای ارسالهای دستیِ صریح مدیر (مثل پیامک سفارشی
|
||||
یا آزمایشی)، و ``SMSTemplate.auto_send`` که مدیر در صفحهٔ قالبها برای هر نوع
|
||||
پیامک روشن میکند تا پیامکهای خودکارِ اسکنر بدون صف تأیید ارسال شوند.
|
||||
|
||||
هیچ استثنایی به فراخوان پرتاب نمیشود؛ خطاها فقط لاگ میشوند تا مسیر اصلی
|
||||
(مثلاً ایجاد مشتری یا اسکنر) هرگز بهخاطر پیامک مختل نشود.
|
||||
"""
|
||||
try:
|
||||
if message is None:
|
||||
template = SMSTemplate.objects.filter(sms_type=sms_type, is_enabled=True).first()
|
||||
if template is None:
|
||||
return 0
|
||||
message = render_template(template.text, context)
|
||||
# «ارسال خودکار» هر نوع پیامک در صفحهٔ قالبها تعیین میشود؛ خاموش = بازبینی مدیر.
|
||||
approved = approved or template.auto_send
|
||||
|
||||
if not is_sms_enabled(customer, sms_type):
|
||||
return 0
|
||||
|
||||
numbers = recipients if recipients is not None else resolve_recipients(customer)
|
||||
if not numbers:
|
||||
return 0
|
||||
|
||||
if dedup_key is None:
|
||||
dedup_key = f"{sms_type}:{uuid4().hex}"
|
||||
|
||||
created = 0
|
||||
for number in numbers:
|
||||
key = dedup_key if len(numbers) == 1 else f"{dedup_key}:{number}"
|
||||
try:
|
||||
# دفتر کلیدها مستقل از صف است: کلیدِ یکبار خورده هرگز دوباره
|
||||
# ایجاد نمیشود، حتی اگر رکورد صف را مدیر حذف کرده باشد.
|
||||
if not SMSDedup.objects.get_or_create(key=key)[1]:
|
||||
continue
|
||||
_, made = SMSQueue.objects.get_or_create(
|
||||
dedup_key=key,
|
||||
defaults={
|
||||
"customer": customer,
|
||||
"sms_type": sms_type,
|
||||
"phone_number": number,
|
||||
"message": message,
|
||||
"created_by": created_by,
|
||||
"status": (SMSQueue.Status.APPROVED if approved
|
||||
else SMSQueue.Status.PENDING),
|
||||
},
|
||||
)
|
||||
created += 1 if made else 0
|
||||
except IntegrityError:
|
||||
# رقابت همزمان روی dedup_key؛ نادیده گرفته میشود.
|
||||
continue
|
||||
return created
|
||||
except Exception:
|
||||
logger.exception("enqueue_sms برای نوع=%s شکست خورد", sms_type)
|
||||
return 0
|
||||
@@ -0,0 +1,59 @@
|
||||
"""پیامهای نتیجهٔ عملیات پنل پیامک.
|
||||
|
||||
جایگزین flash message جنگو است: پیامهای جنگو در session ذخیره میشوند و اگر
|
||||
قالب مقصد آنها را رندر نکند، تا صفحهٔ بعدِ دارای بلوک پیام «سرگردان» میمانند و
|
||||
بیربط به عملیات ظاهر میشوند. اینجا پیام با یک کد در querystring
|
||||
(``?notice=<code>``) رد و بدل میشود؛ چون کد فقط روی همان URL وجود دارد، پیام هم
|
||||
فقط همانجا دیده میشود و هیچجا سرگردان نمیماند.
|
||||
|
||||
مقدار ``{placeholder}`` در متن از پارامترهای querystring پر میشود
|
||||
(نبودِ پارامتر ⇒ رشتهٔ خالی).
|
||||
"""
|
||||
from collections import defaultdict
|
||||
|
||||
# کد → (شدت، متن). شدت به کلاس Bulma نگاشت میشود.
|
||||
NOTICES = {
|
||||
# --- تنظیمات و ارسال آزمایشی ---
|
||||
"test_sent": ("success", "پیامک آزمایشی در صف ارسال قرار گرفت."),
|
||||
"settings_saved": ("success", "تنظیمات سامانه پیامک ذخیره شد."),
|
||||
# --- قالبها ---
|
||||
"tpl_saved": ("success", "قالب «{title}» ذخیره شد."),
|
||||
"tpl_bad_timing": ("error", "مقدار زمانبندی باید یک عدد صحیح باشد."),
|
||||
# --- مشتری ---
|
||||
"customer_sms_saved": ("success", "تنظیم پیامک مشتری بهروزرسانی شد."),
|
||||
"primary_updated": ("success", "شمارهٔ اصلی پیامک بهروزرسانی شد."),
|
||||
"primary_not_found": ("error", "شماره موردنظر برای این مشتری یافت نشد."),
|
||||
"custom_queued": ("success", "پیامک سفارشی در صف ارسال قرار گرفت."),
|
||||
"custom_empty": ("error", "متن پیامک خالی است."),
|
||||
"custom_no_phone": ("error", "شماره اصلی معتبری برای این مشتری یافت نشد."),
|
||||
# --- صف ---
|
||||
"approved": ("success", "پیامک تأیید شد و برای ارسال در صف قرار گرفت."),
|
||||
"not_pending": ("warning", "این پیامک در وضعیت «در انتظار تأیید» نیست."),
|
||||
"deleted": ("success", "پیامک از صف حذف شد."),
|
||||
"bulk_approved_all": ("success", "{count} پیامک تأیید شد و برای ارسال در صف قرار گرفت."),
|
||||
"bulk_approved": ("success", "{count} پیامک تأیید شد."),
|
||||
"bulk_deleted": ("success", "{count} پیامک از صف حذف شد."),
|
||||
"invalid_action": ("error", "اقدام نامعتبر است."),
|
||||
"queue_emptied": ("success", "کل صف خالی شد ({count} پیامک حذف شد)."),
|
||||
"retried": ("success", "پیامک برای ارسال مجدد تأیید شد."),
|
||||
}
|
||||
|
||||
_KIND_CLASSES = {
|
||||
"success": "is-success",
|
||||
"error": "is-danger",
|
||||
"warning": "is-warning",
|
||||
"info": "is-info",
|
||||
}
|
||||
|
||||
|
||||
def resolve(request):
|
||||
"""کد ``?notice=`` را به متن/کلاس قابل رندر در قالب تبدیل میکند."""
|
||||
get = getattr(request, "GET", None) or {}
|
||||
entry = NOTICES.get((get.get("notice") or "").strip())
|
||||
if not entry:
|
||||
return {}
|
||||
kind, text = entry
|
||||
return {
|
||||
"notice_text": text.format_map(defaultdict(str, get)),
|
||||
"notice_kind": _KIND_CLASSES.get(kind, "is-info"),
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"""پاکسازی یکبارهٔ شمارههای قدیمی مشتریان (فیلدهای ``phone1``/``phone2``/``phone3``).
|
||||
|
||||
سالها شمارهها در سه فیلد متنی ذخیره شدهاند و به هر شکلی وارد شدهاند:
|
||||
بدون صفر ابتدایی (``9133722815``)، با ارقام فارسی، با فاصله/خط تیره، یا کلاهی
|
||||
متن آزاد (نام، تاریخ نصب، «معرف …»). این ماژول قاعدهٔ تبدیل را یکجا تعریف میکند
|
||||
تا هم مهاجرت (اجرای خودکار، یکبار) و هم دستور ``fix_customer_phones`` (اجرای
|
||||
دستی روی دیتابیس production) از یک پیادهسازی استفاده کنند.
|
||||
|
||||
قاعده (طبق تصمیم کاربر):
|
||||
|
||||
* شمارهٔ موبایل — هرجای متن که باشد — استخراج و به شکل استاندارد ``09xxxxxxxxx``
|
||||
تبدیل میشود.
|
||||
* شمارهٔ ثابت ۸رقمی (مثل ``57387608``) **دستنخورده** میماند.
|
||||
* بقیه (متن آزاد، تاریخ، تکههای بیمعنی) از فیلد شماره خالی میشود و متن اصلیاش
|
||||
در فیلد ``Customer.notes`` میرود تا چیزی از سیستم گم نشود.
|
||||
|
||||
تابع ``clean_legacy_cell`` خودبسنده و idempotent است: بعد از یکبار اجرا، خروجی
|
||||
همان مقدار فعلی است و دیگر توضیحی اضافه نمیشود؛ پس اجرای دوباره بیخطر است.
|
||||
"""
|
||||
import re
|
||||
|
||||
from persiantools import digits
|
||||
|
||||
from integrations.sms.normalize import normalize_mobile, to_local
|
||||
|
||||
_NOTE_HEADER = "— پاکسازی خودکار فیلدهای تلفن —"
|
||||
|
||||
|
||||
def _numeric_tokens(raw):
|
||||
"""تکههای رقمی متن، پس از تبدیل ارقام فارسی/عربی به انگلیسی."""
|
||||
text = digits.fa_to_en(digits.ar_to_fa(str(raw)))
|
||||
return re.findall(r"\d+", text)
|
||||
|
||||
|
||||
def clean_legacy_cell(raw):
|
||||
"""یک فیلد تلفن را پاکسازی میکند.
|
||||
|
||||
خروجی ``(value, note)`` است:
|
||||
|
||||
* ``value`` مقدار جدید فیلد، یا ``None`` یعنی «دست نزن».
|
||||
* ``note`` متن اصلی برای افزودن به ``Customer.notes``، یا ``None``.
|
||||
|
||||
قاعده: هرچیزِ فیلد که در مقدار جدید نمیماند، **بیکموکاست** در
|
||||
``note`` برمیگردد تا هیچ دادهای گم نشود.
|
||||
"""
|
||||
original = (raw or "").strip()
|
||||
if not original:
|
||||
return None, None
|
||||
|
||||
# ۱) کل فیلد یک موبایل است (شاید با فاصله/خط تیره/پیششماره).
|
||||
whole = normalize_mobile(original)
|
||||
if whole:
|
||||
local = to_local(whole)
|
||||
if local == original:
|
||||
return None, None
|
||||
return local, original
|
||||
|
||||
tokens = _numeric_tokens(original)
|
||||
|
||||
# ۲) موبایلی داخل متن پنهان است (مثل «57322182/9130261671» یا «موبایل: 0912…»).
|
||||
for token in tokens:
|
||||
local = to_local(token)
|
||||
if local:
|
||||
return local, original
|
||||
|
||||
# ۳) شمارهٔ ثابت ۸رقمیِ تنها ⇒ دستنخورده.
|
||||
if len(tokens) == 1 and len(tokens[0]) == 8:
|
||||
return None, None
|
||||
|
||||
# ۴) بقیه (متن آزاد، تاریخ، تکهٔ بیمعنی) ⇒ خالی؛ متن اصلی در یادداشت میماند.
|
||||
return "", original
|
||||
|
||||
|
||||
def _append_note(notes, lines):
|
||||
"""خطوط توضیح را به انتهای یادداشت مشتری اضافه میکند (با سرصفحهٔ یکبار)."""
|
||||
current = (notes or "").strip()
|
||||
block = "\n".join(lines)
|
||||
if _NOTE_HEADER in current:
|
||||
return f"{current}\n{block}"
|
||||
if current:
|
||||
return f"{current}\n\n{_NOTE_HEADER}\n{block}"
|
||||
return f"{_NOTE_HEADER}\n{block}"
|
||||
|
||||
|
||||
def clean_customer(customer, *, dry_run=False):
|
||||
"""فیلدهای تلفن یک مشتری را اصلاح و متنهای حذفشده را در ``notes`` میریزد.
|
||||
|
||||
خروجی: تعداد فیلدهای تغییرکرده. اگر تغییری نباشد، چیزی ذخیره نمیشود.
|
||||
"""
|
||||
changed = 0
|
||||
note_lines = []
|
||||
updates = {}
|
||||
for field in ("phone1", "phone2", "phone3"):
|
||||
value, note = clean_legacy_cell(getattr(customer, field, ""))
|
||||
if value is not None and value != (getattr(customer, field) or ""):
|
||||
updates[field] = value
|
||||
changed += 1
|
||||
if note:
|
||||
note_lines.append(f"«{field}»: {note}")
|
||||
|
||||
if not changed and not note_lines:
|
||||
return 0
|
||||
if dry_run:
|
||||
return changed or (1 if note_lines else 0)
|
||||
|
||||
if note_lines:
|
||||
customer.notes = _append_note(customer.notes, note_lines)
|
||||
updates["notes"] = customer.notes
|
||||
if updates:
|
||||
for field, value in updates.items():
|
||||
setattr(customer, field, value)
|
||||
customer.save(update_fields=list(updates))
|
||||
return changed
|
||||
|
||||
|
||||
def clean_all(*, dry_run=False, batch_size=500, apps=None):
|
||||
"""همهٔ مشتریان را پاکسازی میکند. خروجی: دیکشنری آمار.
|
||||
|
||||
``apps`` فقط برای مهاجرتها است تا مدل را از رجیستری تاریخی بگیرد (نه import
|
||||
زماناجرا)؛ در غیر این صورت مدل زندهٔ ``Customer`` استفاده میشود.
|
||||
"""
|
||||
if apps is not None:
|
||||
Customer = apps.get_model("admin_panel", "Customer")
|
||||
else:
|
||||
from ..models import Customer
|
||||
|
||||
stats = {"customers": 0, "fields": 0, "noted": 0}
|
||||
qs = Customer.objects.all().only(
|
||||
"id", "phone1", "phone2", "phone3", "notes"
|
||||
).order_by("id")
|
||||
for customer in qs.iterator(chunk_size=batch_size):
|
||||
before_notes = (customer.notes or "").strip()
|
||||
changed = clean_customer(customer, dry_run=dry_run)
|
||||
if changed:
|
||||
stats["customers"] += 1
|
||||
stats["fields"] += changed
|
||||
if (not dry_run) and (customer.notes or "").strip() != before_notes:
|
||||
stats["noted"] += 1
|
||||
return stats
|
||||
@@ -0,0 +1,163 @@
|
||||
"""یافتن شمارههای یک مشتری از همهٔ روشهای ذخیرهسازی.
|
||||
|
||||
شمارهها در این پروژه به چند شکل ذخیره میشوند:
|
||||
|
||||
* رکوردهای مدل ``PhoneNumber`` (روش جدید؛ عنوان + شماره)
|
||||
* فیلدهای قدیمی ``Customer.phone1`` / ``phone2`` / ``phone3``
|
||||
* بدون صفر ابتدایی (``9133722815`` بهجای ``09133722815``) و بعضاً مقدارهای
|
||||
بیمعنی مثل ``'0'`` یا شمارهٔ ثابت.
|
||||
|
||||
این ماژول تنها مرجع خواندن شمارههاست: هر جای دیگر (پنل، اسکنر، worker) باید از
|
||||
همین توابع استفاده کند تا رفتار یکسان باشد.
|
||||
|
||||
«شمارهٔ اصلی» بهصورت **مقدارِ خودِ شماره** (نرمالشده) در ``Customer.sms_primary_phones``
|
||||
ذخیره میشود، نه با کلید رکورد. دلیل: دادهٔ مشتریان دائماً تغییر میکند (افزودن، ویرایش،
|
||||
حذف) و کلید رکورد در بازسازیها از بین میرود؛ ولی شماره یک مقدار پایدار است. اگر شمارهای
|
||||
از مشتری حذف یا ویرایش شود، انتخاب اصلی خودبهخود بیاثر میشود و سیستم به شمارهٔ اول
|
||||
برمیگردد.
|
||||
"""
|
||||
from integrations.sms.normalize import normalize_mobile
|
||||
|
||||
|
||||
class PhoneEntry:
|
||||
"""یک شمارهٔ مشتری، مستقل از اینکه کجا ذخیره شده است."""
|
||||
|
||||
__slots__ = ("raw", "display", "normalized", "title", "source", "is_primary")
|
||||
|
||||
def __init__(self, raw, title, source, is_primary=False):
|
||||
self.raw = raw
|
||||
self.title = title
|
||||
self.source = source
|
||||
self.normalized = normalize_mobile(raw)
|
||||
self.display = to_display(raw)
|
||||
self.is_primary = is_primary
|
||||
|
||||
@property
|
||||
def is_mobile(self):
|
||||
return bool(self.normalized)
|
||||
|
||||
def as_dict(self):
|
||||
return {
|
||||
"raw": self.raw,
|
||||
"display": self.display,
|
||||
"normalized": self.normalized,
|
||||
"title": self.title,
|
||||
"source": self.source,
|
||||
"is_primary": self.is_primary,
|
||||
"is_mobile": self.is_mobile,
|
||||
}
|
||||
|
||||
def __repr__(self): # pragma: no cover - کمککنندهٔ دیباگ
|
||||
return f"<PhoneEntry {self.display!r} primary={self.is_primary} source={self.source}>"
|
||||
|
||||
|
||||
def to_display(raw):
|
||||
"""شماره را برای نمایش با صفر ابتدایی برمیگرداند (بدون دستبردن در مقدار ذخیرهشده)."""
|
||||
text = "" if raw is None else str(raw).strip()
|
||||
if not text:
|
||||
return ""
|
||||
normalized = normalize_mobile(text)
|
||||
if normalized:
|
||||
return "0" + normalized[3:] # +989xxxxxxxxx → 09xxxxxxxxx
|
||||
return text
|
||||
|
||||
|
||||
def _iter_sources(customer):
|
||||
"""(title, raw, source) از همهٔ منابع، بدون تکرار — ابتدا رکوردها، سپس فیلدهای قدیمی."""
|
||||
from ..models import PhoneNumber
|
||||
|
||||
seen = set()
|
||||
rows = PhoneNumber.objects.filter(customer=customer).order_by("id")
|
||||
for row in rows:
|
||||
raw = (row.phone_number or "").strip()
|
||||
if not raw:
|
||||
continue
|
||||
key = normalize_mobile(raw) or raw
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
yield (row.title or "تلفن", raw, "phone_number")
|
||||
|
||||
legacy = (
|
||||
("تلفن 1", getattr(customer, "phone1", "")),
|
||||
("تلفن 2", getattr(customer, "phone2", "")),
|
||||
("تلفن 3", getattr(customer, "phone3", "")),
|
||||
)
|
||||
for title, raw in legacy:
|
||||
raw = (raw or "").strip()
|
||||
if not raw:
|
||||
continue
|
||||
key = normalize_mobile(raw) or raw
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
yield (title, raw, "legacy")
|
||||
|
||||
|
||||
def stored_primary_normalized(customer):
|
||||
"""مجموعهٔ شمارههای نرمالشدهای که مدیر بهعنوان «اصلی» انتخاب کرده است."""
|
||||
raw = (getattr(customer, "sms_primary_phones", "") or "").strip()
|
||||
if not raw:
|
||||
return set()
|
||||
return {part.strip() for part in raw.split(",") if part.strip()}
|
||||
|
||||
|
||||
def normalize_primary_values(values):
|
||||
"""فهرست شمارههای خام → رشتهٔ نرمالشدهٔ یکتا برای ذخیره در ``sms_primary_phones``.
|
||||
|
||||
شمارههای نامعتبر (ثابت، ناقص، ``'0'``) نادیده گرفته میشوند؛ پس علامتزدن یک شمارهٔ
|
||||
غیرموبایل بهعنوان «اصلی» بیاثر میماند و سیستم به شمارهٔ اولِ معتبر برمیگردد.
|
||||
"""
|
||||
unique = []
|
||||
for raw in values:
|
||||
number = normalize_mobile(raw)
|
||||
if number and number not in unique:
|
||||
unique.append(number)
|
||||
return ",".join(unique)
|
||||
|
||||
|
||||
def save_primary_normalized(customer, numbers, *, update_fields=("sms_primary_phones",)):
|
||||
"""ذخیرهٔ فهرست شمارههای اصلی (نرمالشده، بدون تکرار، به ترتیب ورودی)."""
|
||||
customer.sms_primary_phones = normalize_primary_values(numbers)
|
||||
customer.save(update_fields=list(update_fields))
|
||||
return stored_primary_normalized(customer)
|
||||
|
||||
|
||||
def customer_phone_entries(customer):
|
||||
"""همهٔ شمارههای مشتری از هر منبعی که ذخیره شده باشند (بدون تکرار)."""
|
||||
if customer is None:
|
||||
return []
|
||||
primary = stored_primary_normalized(customer)
|
||||
return [
|
||||
PhoneEntry(raw, title, source, is_primary=(entry_norm in primary) if entry_norm else False)
|
||||
for title, raw, source in _iter_sources(customer)
|
||||
for entry_norm in [normalize_mobile(raw)]
|
||||
]
|
||||
|
||||
|
||||
def usable_entries(customer):
|
||||
"""فقط شمارههایی که موبایل معتبرند و واقعاً قابل ارسال پیامک هستند."""
|
||||
return [entry for entry in customer_phone_entries(customer) if entry.is_mobile]
|
||||
|
||||
|
||||
def primary_phones(customer):
|
||||
"""شمارههای خام گیرندهٔ پیامک: انتخابشدههای مدیر، وگرنه شمارهٔ اولِ قابل استفاده."""
|
||||
entries = usable_entries(customer)
|
||||
if not entries:
|
||||
return []
|
||||
chosen = [entry for entry in entries if entry.is_primary]
|
||||
if chosen:
|
||||
return [entry.raw for entry in chosen]
|
||||
return [entries[0].raw]
|
||||
|
||||
|
||||
def resolved_recipients(customer):
|
||||
"""گیرندگان نهاییِ نرمالشده (``+989xxxxxxxxx``) و یکتا."""
|
||||
seen = []
|
||||
for entry in usable_entries(customer):
|
||||
if entry.is_primary and entry.normalized not in seen:
|
||||
seen.append(entry.normalized)
|
||||
if seen:
|
||||
return seen
|
||||
entries = usable_entries(customer)
|
||||
return [entries[0].normalized] if entries else []
|
||||
@@ -0,0 +1,22 @@
|
||||
"""تعیین گیرندگان پیامک.
|
||||
|
||||
منطق واقعیِ یافتن شمارهها در ``admin_panel.sms.phones`` است (تنها مرجع خواندن
|
||||
شمارهها). این ماژول فقط نامهای قدیمی را برای سازگاری نگه میدارد و آنها را به
|
||||
همان توابع واگذار میکند تا رفتار در همهٔ مسیرها یکسان بماند.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from .phones import primary_phones as _primary_phones
|
||||
from .phones import resolved_recipients
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def primary_phones(customer):
|
||||
"""شمارههای خام گیرندهٔ پیامک (اصلیهای انتخابشده، وگرنه شمارهٔ اولِ معتبر)."""
|
||||
return _primary_phones(customer)
|
||||
|
||||
|
||||
def resolve_recipients(customer):
|
||||
"""فهرست شمارههای نرمالشدهٔ یکتای گیرنده."""
|
||||
return resolved_recipients(customer)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""رندر قالب متن پیامک با placeholderهای امن."""
|
||||
from persiantools.jdatetime import JalaliDate, JalaliDateTime
|
||||
|
||||
|
||||
class _SafeDict(dict):
|
||||
"""دیکشنری که برای کلیدهای ناشناخته رشته خالی برمیگرداند (بدون خطا)."""
|
||||
|
||||
def __missing__(self, key):
|
||||
return ""
|
||||
|
||||
|
||||
def format_amount(value):
|
||||
try:
|
||||
return f"{int(value):,}"
|
||||
except (TypeError, ValueError):
|
||||
return str(value or "")
|
||||
|
||||
|
||||
def format_jalali(value):
|
||||
"""تبدیل datetime/date به تاریخ جلالی ``YYYY/MM/DD``."""
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
if hasattr(value, "date") and callable(getattr(value, "date")):
|
||||
value = value.date()
|
||||
return JalaliDate(value).strftime("%Y/%m/%d")
|
||||
except Exception:
|
||||
try:
|
||||
return JalaliDateTime(value).strftime("%Y/%m/%d")
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
|
||||
def render_template(text, context=None):
|
||||
ctx = dict(context or {})
|
||||
if "amount" in ctx:
|
||||
ctx.setdefault("amount_sep", format_amount(ctx["amount"]))
|
||||
if "date" in ctx:
|
||||
ctx.setdefault("date_raw", ctx["date"])
|
||||
ctx["date"] = format_jalali(ctx["date"])
|
||||
return (text or "").format_map(_SafeDict(ctx))
|
||||
@@ -0,0 +1,25 @@
|
||||
"""قواعد فعال/غیرفعال بودن پیامک برای مشتری (مدل opt-out)."""
|
||||
from ..models import CustomerSMSSetting, SMSType
|
||||
|
||||
|
||||
def is_sms_enabled(customer, sms_type):
|
||||
"""اگر رکوردی با is_enabled=False وجود داشته باشد غیرفعال است؛ در غیر اینصورت فعال."""
|
||||
if customer is None or sms_type == SMSType.CUSTOM:
|
||||
return True
|
||||
setting = CustomerSMSSetting.objects.filter(customer=customer, sms_type=sms_type).first()
|
||||
if setting is None:
|
||||
return True
|
||||
return setting.is_enabled
|
||||
|
||||
|
||||
def customer_toggle_map(customer):
|
||||
"""فهرست نوعهای پیامک با وضعیت مؤثر (برای نمایش در پنل مشتری)."""
|
||||
overrides = {
|
||||
s.sms_type: s.is_enabled
|
||||
for s in CustomerSMSSetting.objects.filter(customer=customer)
|
||||
}
|
||||
return [
|
||||
{"code": code, "label": label, "enabled": overrides.get(code, True)}
|
||||
for code, label in SMSType.choices
|
||||
if code != SMSType.CUSTOM
|
||||
]
|
||||
@@ -0,0 +1,336 @@
|
||||
"""اسکنر دورهای: پیدا کردن موارد سررسیدشده و قرار دادن پیامک در صف.
|
||||
|
||||
قواعد کنترل جریان (همه از پنل مدیریت قابل تنظیم):
|
||||
|
||||
* **خط شروع (``scan_since``)**: فقط مواردی صف میشوند که «لحظهٔ باز شدن پنجرهٔ یادآوری»
|
||||
آنها از این لحظه به بعد باشد. بنابراین در نخستین اجرا هیچکدام از موارد قدیمی موجود
|
||||
در دیتابیس یکباره ارسال نمیشوند.
|
||||
* **اجازهٔ موارد قدیمی (``allow_backlog`` + ``backlog_max``)**: اگر مدیر صریحاً روشن کند،
|
||||
در هر اجرا حداکثر ``backlog_max`` مورد قدیمی هم صف میشود.
|
||||
* **سقف هر اجرا (``max_per_run``)**: سقف کل صفکردن هر اسکنر در یک اجرا.
|
||||
|
||||
هر اسکنر مستقل و مقاوم به خطاست؛ یک خطا بقیه را متوقف نمیکند. ``dedup_key`` از
|
||||
ارسال تکراری جلوگیری میکند و تضمین میکند اجرای دوبارهٔ اسکنر رکورد جدیدی نسازد.
|
||||
|
||||
یادآوریهای **دورهای** (بازدید و ثبت چک) کلیدشان شامل شمارهٔ دوره است. یعنی اگر
|
||||
مدیر یک مورد را از صف حذف کند، فقط همان دوره کنسل میشود و در دورهٔ بعد دوباره
|
||||
ساخته میشود؛ برای همیشه خاموش نمیماند.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, time, timedelta
|
||||
|
||||
from django.db.models import Q, Sum
|
||||
from django.utils import timezone
|
||||
|
||||
from ..models import (Article, Customer, Factor, Sarresid, SMSProviderConfig,
|
||||
SMSTemplate, SMSType)
|
||||
from .enqueue import enqueue_sms
|
||||
from .timing import DEFAULT_VISIT_MONTHS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# پیشفرض فاصلهٔ تکرار «یادآوری ثبت چک» (روز) وقتی قالبی تنظیم نشده باشد.
|
||||
CHEQUE_REG_INTERVAL_DEFAULT = 3
|
||||
|
||||
|
||||
|
||||
def _template(sms_type):
|
||||
return SMSTemplate.objects.filter(sms_type=sms_type, is_enabled=True).first()
|
||||
|
||||
|
||||
def _aware(value):
|
||||
"""تبدیل تاریخ/ساعت naive به aware (کد قدیمی پروژه جاهایی مقدار naive ذخیره میکند)."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
if timezone.is_naive(value):
|
||||
return timezone.make_aware(value, timezone.get_current_timezone())
|
||||
return value
|
||||
# date → آغاز همان روز
|
||||
return timezone.make_aware(
|
||||
datetime.combine(value, time.min), timezone.get_current_timezone(),
|
||||
)
|
||||
|
||||
|
||||
class _Budget:
|
||||
"""ظرفیت صفکردن یک اسکنر در یک اجرا (خط شروع + سقف قدیمی + سقف کل)."""
|
||||
|
||||
def __init__(self, config):
|
||||
self.since = config.scan_since
|
||||
self.allow_backlog = bool(config.allow_backlog)
|
||||
self.backlog_max = config.backlog_max or 0
|
||||
self.max_per_run = config.max_per_run or 0
|
||||
self.new_count = 0
|
||||
self.backlog_count = 0
|
||||
|
||||
def is_backlog(self, trigger):
|
||||
trigger = _aware(trigger)
|
||||
return self.since is not None and trigger < self.since
|
||||
|
||||
def can_send(self, trigger):
|
||||
if self.max_per_run and (self.new_count + self.backlog_count) >= self.max_per_run:
|
||||
return False
|
||||
if self.is_backlog(trigger):
|
||||
return self.allow_backlog and self.backlog_count < self.backlog_max
|
||||
return True
|
||||
|
||||
def record(self, trigger):
|
||||
if self.is_backlog(trigger):
|
||||
self.backlog_count += 1
|
||||
else:
|
||||
self.new_count += 1
|
||||
|
||||
|
||||
def scan_cheque_due(today):
|
||||
template = _template(SMSType.CHEQUE_DUE)
|
||||
if template is None:
|
||||
return 0
|
||||
budget = _Budget(SMSProviderConfig.load())
|
||||
offset = template.offset_days or 0
|
||||
qs = Sarresid.objects.filter(
|
||||
type="check", payStatus="false",
|
||||
sarresidDT__date__gte=today,
|
||||
sarresidDT__date__lte=today + timedelta(days=offset),
|
||||
).select_related("customer").order_by("sarresidDT")
|
||||
count = 0
|
||||
for s in qs:
|
||||
if not s.customer or not s.sarresidDT:
|
||||
continue
|
||||
# پنجرهٔ یادآوری از «سررسید منهای offset» باز میشود.
|
||||
trigger = _aware(s.sarresidDT) - timedelta(days=offset)
|
||||
if not budget.can_send(trigger):
|
||||
continue
|
||||
days = (s.sarresidDT.date() - today).days
|
||||
made = enqueue_sms(
|
||||
customer=s.customer, sms_type=SMSType.CHEQUE_DUE,
|
||||
context={
|
||||
"name": s.customer.name_family, "amount": s.price,
|
||||
"date": s.sarresidDT, "days": days, "check_row": s.check_row_number,
|
||||
},
|
||||
dedup_key=f"cheque_due:{s.id}",
|
||||
)
|
||||
if made:
|
||||
budget.record(trigger)
|
||||
count += made
|
||||
return count
|
||||
|
||||
|
||||
def scan_nesiye_due(today):
|
||||
template = _template(SMSType.NESIYE_DUE)
|
||||
if template is None:
|
||||
return 0
|
||||
budget = _Budget(SMSProviderConfig.load())
|
||||
offset = template.offset_days or 0
|
||||
qs = Sarresid.objects.filter(
|
||||
type="nesiye", payStatus="false",
|
||||
sarresidDT__date__gte=today,
|
||||
sarresidDT__date__lte=today + timedelta(days=offset),
|
||||
).select_related("customer").order_by("sarresidDT")
|
||||
count = 0
|
||||
for s in qs:
|
||||
if not s.customer or not s.sarresidDT:
|
||||
continue
|
||||
trigger = _aware(s.sarresidDT) - timedelta(days=offset)
|
||||
if not budget.can_send(trigger):
|
||||
continue
|
||||
days = (s.sarresidDT.date() - today).days
|
||||
made = enqueue_sms(
|
||||
customer=s.customer, sms_type=SMSType.NESIYE_DUE,
|
||||
context={
|
||||
"name": s.customer.name_family, "amount": s.price,
|
||||
"date": s.sarresidDT, "days": days,
|
||||
},
|
||||
dedup_key=f"nesiye_due:{s.id}",
|
||||
)
|
||||
if made:
|
||||
budget.record(trigger)
|
||||
count += made
|
||||
return count
|
||||
|
||||
|
||||
def scan_cheque_registration(today):
|
||||
"""یادآوری «ثبت اطلاعات چک» برای چکهای پرداختنشدهٔ ثبتنشده.
|
||||
|
||||
هر ``interval`` روز یکبار تکرار میشود و بهمحض اینکه شمارهٔ صیادی چک ثبت شود
|
||||
(یا چک پرداخت شود) متوقف میگردد. دورهٔ تکرار از پنل قابل تنظیم است.
|
||||
"""
|
||||
template = _template(SMSType.CHEQUE_REGISTRATION)
|
||||
if template is None:
|
||||
return 0
|
||||
budget = _Budget(SMSProviderConfig.load())
|
||||
interval = template.repeat_interval_days or CHEQUE_REG_INTERVAL_DEFAULT
|
||||
qs = Sarresid.objects.filter(
|
||||
type="check", payStatus="false", sarresidDT__date__gte=today,
|
||||
).filter(
|
||||
Q(check_sayyad_id="") | Q(check_sayyad_id__isnull=True),
|
||||
).select_related("customer")
|
||||
count = 0
|
||||
for s in qs:
|
||||
if not s.customer:
|
||||
continue
|
||||
anchor = s.createdDT or s.sarresidDT
|
||||
if not anchor:
|
||||
continue
|
||||
days = (today - anchor.date()).days
|
||||
if days <= 0 or days % interval != 0:
|
||||
continue
|
||||
cycle = days // interval
|
||||
# لحظهٔ باز شدن همین دورهٔ تکرار (نه خودِ چک) ملاک خط شروع است.
|
||||
trigger = _aware(anchor) + timedelta(days=days)
|
||||
if not budget.can_send(trigger):
|
||||
continue
|
||||
made = enqueue_sms(
|
||||
customer=s.customer, sms_type=SMSType.CHEQUE_REGISTRATION,
|
||||
context={
|
||||
"name": s.customer.name_family, "amount": s.price,
|
||||
"date": s.sarresidDT, "days": (s.sarresidDT.date() - today).days
|
||||
if s.sarresidDT else "",
|
||||
"check_row": s.check_row_number,
|
||||
},
|
||||
dedup_key=f"cheque_reg:{s.id}:{cycle}",
|
||||
)
|
||||
if made:
|
||||
budget.record(trigger)
|
||||
count += made
|
||||
return count
|
||||
|
||||
|
||||
def scan_debt_due(today):
|
||||
"""یادآوری بدهی برای ماندهدارها روی سررسید وصول مطالبات."""
|
||||
template = _template(SMSType.DEBT_DUE)
|
||||
if template is None:
|
||||
return 0
|
||||
budget = _Budget(SMSProviderConfig.load())
|
||||
offset = template.offset_days or 0
|
||||
target = today - timedelta(days=offset)
|
||||
qs = Sarresid.objects.filter(
|
||||
payStatus="false", sarresidDT__date=target,
|
||||
).select_related("customer").order_by("sarresidDT")
|
||||
count = 0
|
||||
seen = set()
|
||||
for s in qs:
|
||||
if not s.customer or s.customer_id in seen or not s.sarresidDT:
|
||||
continue
|
||||
trigger = _aware(s.sarresidDT)
|
||||
if not budget.can_send(trigger):
|
||||
continue
|
||||
totals = Article.objects.filter(customer=s.customer).aggregate(
|
||||
d=Sum("debit"), c=Sum("credit"),
|
||||
)
|
||||
net = (totals["d"] or 0) - (totals["c"] or 0)
|
||||
if net <= 0:
|
||||
continue
|
||||
made = enqueue_sms(
|
||||
customer=s.customer, sms_type=SMSType.DEBT_DUE,
|
||||
context={"name": s.customer.name_family, "amount": net},
|
||||
dedup_key=f"debt_due:{s.id}",
|
||||
)
|
||||
if made:
|
||||
seen.add(s.customer_id)
|
||||
budget.record(trigger)
|
||||
count += made
|
||||
return count
|
||||
|
||||
|
||||
def scan_survey(today):
|
||||
"""نظرسنجی چند روز پس از ایجاد فاکتور."""
|
||||
template = _template(SMSType.SURVEY)
|
||||
if template is None:
|
||||
return 0
|
||||
budget = _Budget(SMSProviderConfig.load())
|
||||
offset = template.offset_days or 3
|
||||
target = today - timedelta(days=offset)
|
||||
qs = Factor.objects.filter(
|
||||
is_active=True, create_date__date=target,
|
||||
).exclude(pay_status="cancell").select_related("customer").order_by("create_date")
|
||||
count = 0
|
||||
for f in qs:
|
||||
if not f.customer or not f.create_date:
|
||||
continue
|
||||
trigger = _aware(f.create_date) + timedelta(days=offset)
|
||||
if not budget.can_send(trigger):
|
||||
continue
|
||||
made = enqueue_sms(
|
||||
customer=f.customer, sms_type=SMSType.SURVEY,
|
||||
context={"name": f.customer.name_family, "amount": f.amount},
|
||||
dedup_key=f"survey:{f.id}",
|
||||
)
|
||||
if made:
|
||||
budget.record(trigger)
|
||||
count += made
|
||||
return count
|
||||
|
||||
|
||||
def scan_visit(today):
|
||||
"""یادآوری بازدید برای مشتریان عقبافتاده از بازدید (منطق صفحهٔ «انتظار»).
|
||||
|
||||
لحظهٔ واجدشرطشدن هر مشتری = «آخرین بازدید + دورهٔ مجاز» (یا تاریخ نصب برای
|
||||
مشتریانی که هرگز بازدید نشدهاند). اگر این لحظه پیش از خط شروع باشد، مورد قدیمی
|
||||
تلقی میشود و فقط با اجازهٔ صریح مدیر (و در سقف تعیینشده) ارسال میگردد.
|
||||
"""
|
||||
template = _template(SMSType.VISIT)
|
||||
if template is None:
|
||||
return 0
|
||||
budget = _Budget(SMSProviderConfig.load())
|
||||
# دورهٔ بازدید: اول از قالب (قابل تنظیم از پنل)، وگرنه متغیر محیطی legacy، وگرنه پیشفرض.
|
||||
months = template.repeat_interval_months
|
||||
if not months:
|
||||
try:
|
||||
months = int(os.environ.get("bazdid", DEFAULT_VISIT_MONTHS))
|
||||
except (TypeError, ValueError):
|
||||
months = DEFAULT_VISIT_MONTHS
|
||||
period = timedelta(weeks=int(months * 4.3))
|
||||
period_days = max(period.days, 1)
|
||||
now = timezone.now()
|
||||
qs = Customer.objects.filter(
|
||||
laghv_bazdid=False,
|
||||
is_active=True,
|
||||
installDate__lte=(now - period).date(),
|
||||
).order_by("id")
|
||||
count = 0
|
||||
for customer in qs:
|
||||
anchor = _aware(customer.last_bazdid_dt) or _aware(customer.installDate)
|
||||
if anchor is None:
|
||||
continue
|
||||
trigger = anchor + period
|
||||
if trigger > now:
|
||||
continue # هنوز عقبافتاده نیست
|
||||
if not budget.can_send(trigger):
|
||||
continue
|
||||
# چرخه = شمارهٔ دورهٔ کاملِ سپریشده از لنگر. کلید به چرخه گره میخورد تا
|
||||
# حذفِ یادآوری توسط مدیر فقط همان «دوره» را کنسل کند و در دورههای بعدی
|
||||
# دوباره ساخته شود (نه اینکه برای همیشه خاموش بماند).
|
||||
cycle = max((today - anchor.date()).days // period_days, 0)
|
||||
made = enqueue_sms(
|
||||
customer=customer, sms_type=SMSType.VISIT,
|
||||
context={"name": customer.name_family},
|
||||
dedup_key=f"visit:{customer.id}:{cycle}",
|
||||
)
|
||||
if made:
|
||||
budget.record(trigger)
|
||||
count += made
|
||||
return count
|
||||
|
||||
|
||||
SCANNERS = [
|
||||
("cheque_due", scan_cheque_due),
|
||||
("nesiye_due", scan_nesiye_due),
|
||||
("cheque_registration", scan_cheque_registration),
|
||||
("debt_due", scan_debt_due),
|
||||
("survey", scan_survey),
|
||||
("visit", scan_visit),
|
||||
]
|
||||
|
||||
|
||||
def run_all(today=None):
|
||||
"""همهٔ اسکنرها را اجرا و نگاشت «نام → تعداد درجشده» را برمیگرداند."""
|
||||
today = today or timezone.localdate()
|
||||
results = {}
|
||||
for name, func in SCANNERS:
|
||||
try:
|
||||
results[name] = func(today)
|
||||
except Exception:
|
||||
logger.exception("اسکن پیامک شکست خورد: %s", name)
|
||||
results[name] = -1
|
||||
return results
|
||||
@@ -0,0 +1,112 @@
|
||||
"""معنای «زمانبندی» برای هر نوع پیامک.
|
||||
|
||||
هر نوع پیامک زمانبندی متفاوتی دارد و نمیشود همه را زیر یک برچسب «روز قبل از سررسید»
|
||||
جمع کرد. این ماژول تنها مرجع این معناست: هم پنل قالبها بر اساسش فیلد درست را نشان
|
||||
میدهد، هم اسکنر بر اساسش تصمیم میگیرد. هر نوع دقیقاً به یک فیلد زمانی نگاشت میشود
|
||||
(``field``) تا داده هرگز ناهمخوان نشود؛ بقیهٔ فیلدهای زمانی صفر/خالی میمانند.
|
||||
"""
|
||||
from ..models import SMSType
|
||||
|
||||
NONE = "none"
|
||||
BEFORE_DUE = "before_due"
|
||||
AFTER_CREATION = "after_creation"
|
||||
RECURRING = "recurring"
|
||||
PERIOD_MONTHS = "period_months"
|
||||
|
||||
# پیشفرض دورهٔ بازدید (ماه) وقتی مدیر مقداری در قالب نگذاشته باشد.
|
||||
DEFAULT_VISIT_MONTHS = 9
|
||||
|
||||
TIMING = {
|
||||
SMSType.WELCOME: {
|
||||
"kind": NONE,
|
||||
"field": None,
|
||||
"note": "بهمحض ثبت مشتری و بدون تأخیر ارسال میشود؛ تنظیم زمانی ندارد.",
|
||||
},
|
||||
SMSType.CHEQUE_DUE: {
|
||||
"kind": BEFORE_DUE,
|
||||
"field": "offset_days",
|
||||
"label": "چند روز قبل از سررسید چک",
|
||||
"default": 2,
|
||||
"note": "پنجرهٔ یادآوری از «سررسید − این تعداد روز» باز میشود و تا سررسید ادامه دارد.",
|
||||
},
|
||||
SMSType.NESIYE_DUE: {
|
||||
"kind": BEFORE_DUE,
|
||||
"field": "offset_days",
|
||||
"label": "چند روز قبل از سررسید نسیه",
|
||||
"default": 2,
|
||||
"note": "پنجرهٔ یادآوری از «سررسید − این تعداد روز» باز میشود و تا سررسید ادامه دارد.",
|
||||
},
|
||||
SMSType.CHEQUE_REGISTRATION: {
|
||||
"kind": RECURRING,
|
||||
"field": "repeat_interval_days",
|
||||
"label": "هر چند روز یکبار",
|
||||
"default": 3,
|
||||
"note": "تا زمانی که اطلاعات ثبت چک (شمارهٔ صیادی) وارد نشده و چک پرداخت نشده، "
|
||||
"با این فاصله تکرار میشود.",
|
||||
},
|
||||
SMSType.SURVEY: {
|
||||
"kind": AFTER_CREATION,
|
||||
"field": "offset_days",
|
||||
"label": "چند روز بعد از صدور فاکتور",
|
||||
"default": 3,
|
||||
"note": "این تعداد روز پس از ثبت فاکتور ارسال میشود.",
|
||||
},
|
||||
SMSType.DEBT_DUE: {
|
||||
"kind": NONE,
|
||||
"field": None,
|
||||
"note": "روی سررسیدِ وصول مطالبات و فقط برای مشتریانی که مانده بدهکارند ارسال میشود.",
|
||||
},
|
||||
SMSType.VISIT: {
|
||||
"kind": PERIOD_MONTHS,
|
||||
"field": "repeat_interval_months",
|
||||
"label": "هر چند ماه یکبار",
|
||||
"default": DEFAULT_VISIT_MONTHS,
|
||||
"note": "برای مشتریانی که از این مدت بازدید نشدهاند ارسال میشود.",
|
||||
},
|
||||
SMSType.CUSTOM: {
|
||||
"kind": NONE,
|
||||
"field": None,
|
||||
"note": "فقط بهصورت دستی از صفحهٔ مشتری ارسال میشود؛ تنظیم زمانی ندارد.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def timing_for(sms_type):
|
||||
"""اطلاعات زمانبندی یک نوع پیامک (با پیشفرض بیخطر برای انواع ناشناخته)."""
|
||||
return TIMING.get(sms_type, {"kind": NONE, "field": None, "note": ""})
|
||||
|
||||
|
||||
def apply_timing(template, value):
|
||||
"""فیلد زمانیِ متناسب با نوع قالب را مینشاند و بقیه را صفر/خالی میکند.
|
||||
|
||||
``value`` عدد صحیحِ واردشده از فرم است (یا ``None``). این کار تضمین میکند صرفنظر از
|
||||
اینکه فرم چه بفرستد، فقط یک فیلد زمانی برای هر قالب معنا داشته باشد.
|
||||
"""
|
||||
info = timing_for(template.sms_type)
|
||||
field = info["field"]
|
||||
template.offset_days = 0
|
||||
template.repeat_interval_days = None
|
||||
template.repeat_interval_months = None
|
||||
if field == "offset_days":
|
||||
template.offset_days = value if value is not None else info["default"]
|
||||
elif field == "repeat_interval_days":
|
||||
template.repeat_interval_days = value if value else info["default"]
|
||||
elif field == "repeat_interval_months":
|
||||
template.repeat_interval_months = value if value else None
|
||||
return template
|
||||
|
||||
|
||||
def current_timing_value(template):
|
||||
"""مقدار فعلی فیلد زمانیِ متناسب (برای نمایش در فرم).
|
||||
|
||||
اگر مقدار خالی باشد، پیشفرض همان نوع برگردانده میشود تا فیلدِ تنظیمنشده هم در
|
||||
فرم دیده شود و مدیر بتواند مقدارش را تغییر دهد.
|
||||
"""
|
||||
info = timing_for(template.sms_type)
|
||||
field = info["field"]
|
||||
if field is None:
|
||||
return None
|
||||
value = getattr(template, field)
|
||||
if value is None:
|
||||
value = info.get("default")
|
||||
return value
|
||||
Reference in new issue
Block a user